forked from tangled.org/core
this repo has no description

appview: profile: introduce profile lexicon

Changed files
+1639 -94
api
appview
cmd
lexicons
actor
+31
api/tangled/actorprofile.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.actor.profile
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
ActorProfileNSID = "sh.tangled.actor.profile"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.actor.profile", &ActorProfile{})
+
} //
+
// RECORDTYPE: ActorProfile
+
type ActorProfile struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.actor.profile" cborgen:"$type,const=sh.tangled.actor.profile"`
+
// bluesky: Include link to this account on Bluesky.
+
Bluesky *bool `json:"bluesky,omitempty" cborgen:"bluesky,omitempty"`
+
// description: Free-form profile description text.
+
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
+
Links []string `json:"links,omitempty" cborgen:"links,omitempty"`
+
// location: Free-form location text.
+
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
+
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
+
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
+
}
+513
api/tangled/cbor_gen.go
···
return nil
}
···
return nil
}
+
func (t *ActorProfile) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
fieldCount := 7
+
+
if t.Bluesky == nil {
+
fieldCount--
+
}
+
+
if t.Description == nil {
+
fieldCount--
+
}
+
+
if t.Links == nil {
+
fieldCount--
+
}
+
+
if t.Location == nil {
+
fieldCount--
+
}
+
+
if t.PinnedRepositories == nil {
+
fieldCount--
+
}
+
+
if t.Stats == nil {
+
fieldCount--
+
}
+
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+
return err
+
}
+
+
// t.LexiconTypeID (string) (string)
+
if len("$type") > 1000000 {
+
return xerrors.Errorf("Value in field \"$type\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("$type")); err != nil {
+
return err
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.actor.profile"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("sh.tangled.actor.profile")); err != nil {
+
return err
+
}
+
+
// t.Links ([]string) (slice)
+
if t.Links != nil {
+
+
if len("links") > 1000000 {
+
return xerrors.Errorf("Value in field \"links\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("links"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("links")); err != nil {
+
return err
+
}
+
+
if len(t.Links) > 8192 {
+
return xerrors.Errorf("Slice value in field t.Links was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Links))); err != nil {
+
return err
+
}
+
for _, v := range t.Links {
+
if len(v) > 1000000 {
+
return xerrors.Errorf("Value in field v was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(v)); err != nil {
+
return err
+
}
+
+
}
+
}
+
+
// t.Stats ([]string) (slice)
+
if t.Stats != nil {
+
+
if len("stats") > 1000000 {
+
return xerrors.Errorf("Value in field \"stats\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("stats"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("stats")); err != nil {
+
return err
+
}
+
+
if len(t.Stats) > 8192 {
+
return xerrors.Errorf("Slice value in field t.Stats was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Stats))); err != nil {
+
return err
+
}
+
for _, v := range t.Stats {
+
if len(v) > 1000000 {
+
return xerrors.Errorf("Value in field v was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(v)); err != nil {
+
return err
+
}
+
+
}
+
}
+
+
// t.Bluesky (bool) (bool)
+
if t.Bluesky != nil {
+
+
if len("bluesky") > 1000000 {
+
return xerrors.Errorf("Value in field \"bluesky\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("bluesky"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("bluesky")); err != nil {
+
return err
+
}
+
+
if t.Bluesky == nil {
+
if _, err := cw.Write(cbg.CborNull); err != nil {
+
return err
+
}
+
} else {
+
if err := cbg.WriteBool(w, *t.Bluesky); err != nil {
+
return err
+
}
+
}
+
}
+
+
// t.Location (string) (string)
+
if t.Location != nil {
+
+
if len("location") > 1000000 {
+
return xerrors.Errorf("Value in field \"location\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("location"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("location")); err != nil {
+
return err
+
}
+
+
if t.Location == nil {
+
if _, err := cw.Write(cbg.CborNull); err != nil {
+
return err
+
}
+
} else {
+
if len(*t.Location) > 1000000 {
+
return xerrors.Errorf("Value in field t.Location was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Location))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(*t.Location)); err != nil {
+
return err
+
}
+
}
+
}
+
+
// t.Description (string) (string)
+
if t.Description != nil {
+
+
if len("description") > 1000000 {
+
return xerrors.Errorf("Value in field \"description\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("description")); err != nil {
+
return err
+
}
+
+
if t.Description == nil {
+
if _, err := cw.Write(cbg.CborNull); err != nil {
+
return err
+
}
+
} else {
+
if len(*t.Description) > 1000000 {
+
return xerrors.Errorf("Value in field t.Description was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Description))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(*t.Description)); err != nil {
+
return err
+
}
+
}
+
}
+
+
// t.PinnedRepositories ([]string) (slice)
+
if t.PinnedRepositories != nil {
+
+
if len("pinnedRepositories") > 1000000 {
+
return xerrors.Errorf("Value in field \"pinnedRepositories\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedRepositories"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("pinnedRepositories")); err != nil {
+
return err
+
}
+
+
if len(t.PinnedRepositories) > 8192 {
+
return xerrors.Errorf("Slice value in field t.PinnedRepositories was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.PinnedRepositories))); err != nil {
+
return err
+
}
+
for _, v := range t.PinnedRepositories {
+
if len(v) > 1000000 {
+
return xerrors.Errorf("Value in field v was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(v)); err != nil {
+
return err
+
}
+
+
}
+
}
+
return nil
+
}
+
+
func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = ActorProfile{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("ActorProfile: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 18)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// t.LexiconTypeID (string) (string)
+
case "$type":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.LexiconTypeID = string(sval)
+
}
+
// t.Links ([]string) (slice)
+
case "links":
+
+
maj, extra, err = cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
+
if extra > 8192 {
+
return fmt.Errorf("t.Links: array too large (%d)", extra)
+
}
+
+
if maj != cbg.MajArray {
+
return fmt.Errorf("expected cbor array")
+
}
+
+
if extra > 0 {
+
t.Links = make([]string, extra)
+
}
+
+
for i := 0; i < int(extra); i++ {
+
{
+
var maj byte
+
var extra uint64
+
var err error
+
_ = maj
+
_ = extra
+
_ = err
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Links[i] = string(sval)
+
}
+
+
}
+
}
+
// t.Stats ([]string) (slice)
+
case "stats":
+
+
maj, extra, err = cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
+
if extra > 8192 {
+
return fmt.Errorf("t.Stats: array too large (%d)", extra)
+
}
+
+
if maj != cbg.MajArray {
+
return fmt.Errorf("expected cbor array")
+
}
+
+
if extra > 0 {
+
t.Stats = make([]string, extra)
+
}
+
+
for i := 0; i < int(extra); i++ {
+
{
+
var maj byte
+
var extra uint64
+
var err error
+
_ = maj
+
_ = extra
+
_ = err
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Stats[i] = string(sval)
+
}
+
+
}
+
}
+
// t.Bluesky (bool) (bool)
+
case "bluesky":
+
+
{
+
b, err := cr.ReadByte()
+
if err != nil {
+
return err
+
}
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
+
maj, extra, err = cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
if maj != cbg.MajOther {
+
return fmt.Errorf("booleans must be major type 7")
+
}
+
+
var val bool
+
switch extra {
+
case 20:
+
val = false
+
case 21:
+
val = true
+
default:
+
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
+
}
+
t.Bluesky = &val
+
}
+
}
+
// t.Location (string) (string)
+
case "location":
+
+
{
+
b, err := cr.ReadByte()
+
if err != nil {
+
return err
+
}
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Location = (*string)(&sval)
+
}
+
}
+
// t.Description (string) (string)
+
case "description":
+
+
{
+
b, err := cr.ReadByte()
+
if err != nil {
+
return err
+
}
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Description = (*string)(&sval)
+
}
+
}
+
// t.PinnedRepositories ([]string) (slice)
+
case "pinnedRepositories":
+
+
maj, extra, err = cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
+
if extra > 8192 {
+
return fmt.Errorf("t.PinnedRepositories: array too large (%d)", extra)
+
}
+
+
if maj != cbg.MajArray {
+
return fmt.Errorf("expected cbor array")
+
}
+
+
if extra > 0 {
+
t.PinnedRepositories = make([]string, extra)
+
}
+
+
for i := 0; i < int(extra); i++ {
+
{
+
var maj byte
+
var extra uint64
+
var err error
+
_ = maj
+
_ = extra
+
_ = err
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.PinnedRepositories[i] = string(sval)
+
}
+
+
}
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
-16
appview/db/artifact.go
···
return err
}
-
type filter struct {
-
key string
-
arg any
-
}
-
-
func Filter(key string, arg any) filter {
-
return filter{
-
key: key,
-
arg: arg,
-
}
-
}
-
-
func (f filter) Condition() string {
-
return fmt.Sprintf("%s = ?", f.key)
-
}
-
func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) {
var artifacts []Artifact
···
return err
}
func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) {
var artifacts []Artifact
+73
appview/db/db.go
···
import (
"context"
"database/sql"
"log"
_ "github.com/mattn/go-sqlite3"
···
foreign key (repo_at) references repos(at_uri) on delete cascade
);
create table if not exists migrations (
id integer primary key autoincrement,
name text unique
···
return nil
}
···
import (
"context"
"database/sql"
+
"fmt"
"log"
_ "github.com/mattn/go-sqlite3"
···
foreign key (repo_at) references repos(at_uri) on delete cascade
);
+
create table if not exists profile (
+
-- id
+
id integer primary key autoincrement,
+
did text not null,
+
+
-- data
+
description text not null,
+
include_bluesky integer not null default 0,
+
location text,
+
+
-- constraints
+
unique(did)
+
);
+
create table if not exists profile_links (
+
-- id
+
id integer primary key autoincrement,
+
did text not null,
+
+
-- data
+
link text not null,
+
+
-- constraints
+
foreign key (did) references profile(did) on delete cascade
+
);
+
create table if not exists profile_stats (
+
-- id
+
id integer primary key autoincrement,
+
did text not null,
+
+
-- data
+
kind text not null check (kind in (
+
"merged-pull-request-count",
+
"closed-pull-request-count",
+
"open-pull-request-count",
+
"open-issue-count",
+
"closed-issue-count",
+
"repository-count"
+
)),
+
+
-- constraints
+
foreign key (did) references profile(did) on delete cascade
+
);
+
create table if not exists profile_pinned_repositories (
+
-- id
+
id integer primary key autoincrement,
+
did text not null,
+
+
-- data
+
at_uri text not null,
+
+
-- constraints
+
unique(did, at_uri),
+
foreign key (did) references profile(did) on delete cascade,
+
foreign key (at_uri) references repos(at_uri) on delete cascade
+
);
+
create table if not exists migrations (
id integer primary key autoincrement,
name text unique
···
return nil
}
+
+
type filter struct {
+
key string
+
arg any
+
}
+
+
func Filter(key string, arg any) filter {
+
return filter{
+
key: key,
+
arg: arg,
+
}
+
}
+
+
func (f filter) Condition() string {
+
return fmt.Sprintf("%s = ?", f.key)
+
}
+285
appview/db/profile.go
···
package db
import (
"fmt"
"time"
)
type RepoEvent struct {
···
return &timeline, nil
}
···
package db
import (
+
"database/sql"
"fmt"
+
"log"
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.sh/tangled.sh/core/api/tangled"
)
type RepoEvent struct {
···
return &timeline, nil
}
+
+
type Profile struct {
+
// ids
+
ID int
+
Did string
+
+
// data
+
Description string
+
IncludeBluesky bool
+
Location string
+
Links [5]string
+
Stats [2]VanityStat
+
PinnedRepos [6]syntax.ATURI
+
}
+
+
func (p Profile) IsLinksEmpty() bool {
+
for _, l := range p.Links {
+
if l != "" {
+
return false
+
}
+
}
+
return true
+
}
+
+
func (p Profile) IsStatsEmpty() bool {
+
for _, s := range p.Stats {
+
if s.Kind != "" {
+
return false
+
}
+
}
+
return true
+
}
+
+
func (p Profile) IsPinnedReposEmpty() bool {
+
for _, r := range p.PinnedRepos {
+
if r != "" {
+
return false
+
}
+
}
+
return true
+
}
+
+
type VanityStatKind string
+
+
const (
+
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
+
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
+
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
+
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
+
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
+
VanityStatRepositoryCount VanityStatKind = "repository-count"
+
)
+
+
func (v VanityStatKind) String() string {
+
switch v {
+
case VanityStatMergedPRCount:
+
return "Merged PRs"
+
case VanityStatClosedPRCount:
+
return "Closed PRs"
+
case VanityStatOpenPRCount:
+
return "Open PRs"
+
case VanityStatOpenIssueCount:
+
return "Open Issues"
+
case VanityStatClosedIssueCount:
+
return "Closed Issues"
+
case VanityStatRepositoryCount:
+
return "Repositories"
+
}
+
return ""
+
}
+
+
type VanityStat struct {
+
Kind VanityStatKind
+
Value uint64
+
}
+
+
func (p *Profile) ProfileAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
+
}
+
+
func UpsertProfile(tx *sql.Tx, profile *Profile) error {
+
defer tx.Rollback()
+
+
// update links
+
_, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did)
+
if err != nil {
+
return err
+
}
+
// update vanity stats
+
_, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did)
+
if err != nil {
+
return err
+
}
+
+
// update pinned repos
+
_, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did)
+
if err != nil {
+
return err
+
}
+
+
includeBskyValue := 0
+
if profile.IncludeBluesky {
+
includeBskyValue = 1
+
}
+
+
_, err = tx.Exec(
+
`insert or replace into profile (
+
did,
+
description,
+
include_bluesky,
+
location
+
)
+
values (?, ?, ?, ?)`,
+
profile.Did,
+
profile.Description,
+
includeBskyValue,
+
profile.Location,
+
)
+
+
if err != nil {
+
log.Println("profile", "err", err)
+
return err
+
}
+
+
for _, link := range profile.Links {
+
if link == "" {
+
continue
+
}
+
+
_, err := tx.Exec(
+
`insert into profile_links (did, link) values (?, ?)`,
+
profile.Did,
+
link,
+
)
+
+
if err != nil {
+
log.Println("profile_links", "err", err)
+
return err
+
}
+
}
+
+
for _, v := range profile.Stats {
+
if v.Kind == "" {
+
continue
+
}
+
+
_, err := tx.Exec(
+
`insert into profile_stats (did, kind) values (?, ?)`,
+
profile.Did,
+
v.Kind,
+
)
+
+
if err != nil {
+
log.Println("profile_stats", "err", err)
+
return err
+
}
+
}
+
+
for _, pin := range profile.PinnedRepos {
+
if pin == "" {
+
continue
+
}
+
+
_, err := tx.Exec(
+
`insert into profile_pinned_repositories (did, at_uri) values (?, ?)`,
+
profile.Did,
+
pin,
+
)
+
+
if err != nil {
+
log.Println("profile_pinned_repositories")
+
return err
+
}
+
}
+
+
return tx.Commit()
+
}
+
+
func GetProfile(e Execer, did string) (*Profile, error) {
+
var profile Profile
+
profile.Did = did
+
+
includeBluesky := 0
+
err := e.QueryRow(
+
`select description, include_bluesky, location from profile where did = ?`,
+
did,
+
).Scan(&profile.Description, &includeBluesky, &profile.Location)
+
if err == sql.ErrNoRows {
+
profile := Profile{}
+
profile.Did = did
+
return &profile, nil
+
}
+
+
if err != nil {
+
return nil, err
+
}
+
+
if includeBluesky != 0 {
+
profile.IncludeBluesky = true
+
}
+
+
rows, err := e.Query(`select link from profile_links where did = ?`, did)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
i := 0
+
for rows.Next() {
+
if err := rows.Scan(&profile.Links[i]); err != nil {
+
return nil, err
+
}
+
i++
+
}
+
+
rows, err = e.Query(`select kind from profile_stats where did = ?`, did)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
i = 0
+
for rows.Next() {
+
if err := rows.Scan(&profile.Stats[i].Kind); err != nil {
+
return nil, err
+
}
+
value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind)
+
if err != nil {
+
return nil, err
+
}
+
profile.Stats[i].Value = value
+
i++
+
}
+
+
rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
i = 0
+
for rows.Next() {
+
if err := rows.Scan(&profile.PinnedRepos[i]); err != nil {
+
return nil, err
+
}
+
i++
+
}
+
+
return &profile, nil
+
}
+
+
func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) {
+
query := ""
+
var args []any
+
switch stat {
+
case VanityStatMergedPRCount:
+
query = `select count(id) from pulls where owner_did = ? and state = ?`
+
args = append(args, did, PullMerged)
+
case VanityStatClosedPRCount:
+
query = `select count(id) from pulls where owner_did = ? and state = ?`
+
args = append(args, did, PullClosed)
+
case VanityStatOpenPRCount:
+
query = `select count(id) from pulls where owner_did = ? and state = ?`
+
args = append(args, did, PullOpen)
+
case VanityStatOpenIssueCount:
+
query = `select count(id) from issues where owner_did = ? and open = 1`
+
args = append(args, did)
+
case VanityStatClosedIssueCount:
+
query = `select count(id) from issues where owner_did = ? and open = 0`
+
args = append(args, did)
+
case VanityStatRepositoryCount:
+
query = `select count(id) from repos where did = ?`
+
args = append(args, did)
+
}
+
+
var result uint64
+
err := e.QueryRow(query, args...).Scan(&result)
+
if err != nil {
+
return 0, err
+
}
+
+
return result, nil
+
}
+6
appview/db/repos.go
···
import (
"database/sql"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
)
type Repo struct {
···
// optional
Source string
}
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
···
import (
"database/sql"
+
"fmt"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.sh/tangled.sh/core/api/tangled"
)
type Repo struct {
···
// optional
Source string
+
}
+
+
func (r Repo) RepoAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
}
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
+26
appview/pages/pages.go
···
CollaboratingRepos []db.Repo
ProfileStats ProfileStats
FollowStatus db.FollowStatus
AvatarUri string
ProfileTimeline *db.ProfileTimeline
···
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
return p.executePlain("user/fragments/follow", w, params)
}
type RepoActionsFragmentParams struct {
···
CollaboratingRepos []db.Repo
ProfileStats ProfileStats
FollowStatus db.FollowStatus
+
Profile *db.Profile
AvatarUri string
ProfileTimeline *db.ProfileTimeline
···
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
return p.executePlain("user/fragments/follow", w, params)
+
}
+
+
type EditBioParams struct {
+
LoggedInUser *auth.User
+
Profile *db.Profile
+
}
+
+
func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
+
return p.executePlain("user/fragments/editBio", w, params)
+
}
+
+
type EditPinsParams struct {
+
LoggedInUser *auth.User
+
Profile *db.Profile
+
AllRepos []PinnedRepo
+
DidHandleMap map[string]string
+
}
+
+
type PinnedRepo struct {
+
IsPinned bool
+
db.Repo
+
}
+
+
func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
+
return p.executePlain("user/fragments/editPins", w, params)
}
type RepoActionsFragmentParams struct {
+107
appview/pages/templates/user/fragments/editBio.html
···
···
+
{{ define "user/fragments/editBio" }}
+
<form
+
hx-post="/{{ .LoggedInUser.Did }}/profile/bio"
+
class="flex flex-col gap-4 my-2 max-w-full"
+
hx-disabled-elt="#save-btn,#cancel-btn"
+
hx-swap="none">
+
<div class="flex flex-col gap-1">
+
{{ $description := "" }}
+
{{ if and .Profile .Profile.Description }}
+
{{ $description = .Profile.Description }}
+
{{ end }}
+
<label class="m-0 p-0" for="description">bio</label>
+
<textarea
+
type="text"
+
class="py-1 px-1 w-full"
+
name="description"
+
rows="3"
+
placeholder="write a bio">{{ $description }}</textarea>
+
</div>
+
+
<div class="flex flex-col gap-1">
+
<label class="m-0 p-0" for="location">location</label>
+
<div class="flex items-center gap-2 w-full">
+
{{ $location := "" }}
+
{{ if and .Profile .Profile.Location }}
+
{{ $location = .Profile.Location }}
+
{{ end }}
+
<span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span>
+
<input type="text" class="py-1 px-1 w-full" name="location" value="{{ $location }}">
+
</div>
+
</div>
+
+
<div class="flex flex-col gap-1">
+
<label class="m-0 p-0">social links</label>
+
<div class="flex items-center gap-2 py-1">
+
{{ $includeBsky := false }}
+
{{ if and .Profile .Profile.IncludeBluesky }}
+
{{ $includeBsky = true }}
+
{{ end }}
+
<input type="checkbox" id="includeBluesky" name="includeBluesky" value="on" {{if $includeBsky}}checked{{end}}>
+
<label for="includeBluesky" class="my-0 py-0 normal-case font-normal">Link to Bluesky account</label>
+
</div>
+
+
{{ $profile := .Profile }}
+
{{ range $idx, $s := (sequence 5) }}
+
{{ $link := "" }}
+
{{ if and $profile $profile.Links }}
+
{{ if lt $idx (len $profile.Links) }}
+
{{ $link = index $profile.Links $idx }}
+
{{ end }}
+
{{ end }}
+
+
<div class="flex items-center gap-2 w-full">
+
<span class="flex-shrink-0">{{ i "link" "size-4" }}</span>
+
<input type="text" class="py-1 px-1 w-full" name="link{{$idx}}" value="{{ $link }}" placeholder="social link {{add $idx 1}}">
+
</div>
+
{{ end }}
+
</div>
+
+
<div class="flex flex-col gap-1">
+
<label class="m-0 p-0">vanity stats</label>
+
{{ range $idx, $s := (sequence 2) }}
+
{{ $stat := "" }}
+
{{ if and $profile $profile.Stats }}
+
{{ if lt $idx (len $profile.Stats) }}
+
{{ $s := index $profile.Stats $idx }}
+
{{ $stat = $s.Kind }}
+
{{ end }}
+
{{ end }}
+
+
{{ block "stat" (list $idx $stat) }} {{ end }}
+
{{ end }}
+
</div>
+
+
<div class="flex items-center gap-2 justify-between">
+
<button id="save-btn" type="submit" class="btn p-1 w-full flex items-center gap-2 no-underline text-sm">
+
{{ i "check" "size-4" }} save
+
</button>
+
<a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline">
+
<button id="cancel-btn" type="button" class="btn p-1 w-full flex items-center gap-2 no-underline text-sm">
+
{{ i "x" "size-4" }} cancel
+
</button>
+
</a>
+
</div>
+
</form>
+
{{ end }}
+
+
{{ define "stat" }}
+
{{ $id := index . 0 }}
+
{{ $stat := index . 1 }}
+
<select class="stat-group w-full p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700 text-sm" id="stat{{$id}}" name="stat{{$id}}">
+
<option value="">choose stat</option>
+
{{ $stats := assoc
+
"merged-pull-request-count" "Merged PR Count"
+
"closed-pull-request-count" "Closed PR Count"
+
"open-pull-request-count" "Open PR Count"
+
"open-issue-count" "Open Issue Count"
+
"closed-issue-count" "Closed Issue Count"
+
"repository-count" "Repository Count"
+
}}
+
{{ range $s := $stats }}
+
{{ $value := index $s 0 }}
+
{{ $label := index $s 1 }}
+
<option value="{{ $value }}"{{ if eq $stat $value }} selected{{ end }}>{{ $label }}</option>
+
{{ end }}
+
</select>
+
{{ end }}
+38
appview/pages/templates/user/fragments/editPins.html
···
···
+
{{ define "user/fragments/editPins" }}
+
{{ $profile := .Profile }}
+
<form
+
hx-post="/{{ .LoggedInUser.Did }}/profile/pins"
+
hx-disabled-elt="#save-btn,#cancel-btn"
+
hx-swap="none">
+
<div class="flex items-center justify-between mb-2">
+
<p class="text-sm font-bold p-2 dark:text-white">SELECT PINNED REPOS</p>
+
<div class="flex items-center gap-2">
+
<button id="save-btn" type="submit" class="btn px-2 flex items-center gap-2 no-underline text-sm">
+
{{ i "check" "w-3 h-3" }} save
+
</button>
+
<a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline">
+
<button id="cancel-btn" type="button" class="btn px-2 w-full flex items-center gap-2 no-underline text-sm">
+
{{ i "x" "w-3 h-3" }} cancel
+
</button>
+
</a>
+
</div>
+
</div>
+
<div id="repos" class="grid grid-cols-1 gap-1 mb-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
+
{{ range $idx, $r := .AllRepos }}
+
<div class="flex items-center gap-2 text-base p-2 border-b border-gray-200 dark:border-gray-700">
+
<input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}>
+
<label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full">
+
<div class="flex justify-between items-center w-full">
+
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span>
+
<div class="flex gap-1 items-center">
+
{{ i "star" "size-4 fill-current" }}
+
<span>{{ .RepoStats.StarCount }}</span>
+
</div>
+
</div>
+
</label>
+
</div>
+
{{ end }}
+
</div>
+
+
</form>
+
{{ end }}
+142 -75
appview/pages/templates/user/profile.html
···
{{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }}
{{ define "content" }}
-
<div class="grid grid-cols-1 md:grid-cols-5 gap-6">
-
<div class="md:col-span-1 order-1 md:order-1">
{{ block "profileCard" . }}{{ end }}
</div>
-
<div class="md:col-span-2 order-2 md:order-2">
{{ block "ownRepos" . }}{{ end }}
{{ block "collaboratingRepos" . }}{{ end }}
</div>
-
<div class="md:col-span-2 order-3 md:order-3">
{{ block "profileTimeline" . }}{{ end }}
</div>
</div>
{{ end }}
{{ define "profileTimeline" }}
-
<p class="text-sm font-bold py-2 dark:text-white px-6">ACTIVITY</p>
<div class="flex flex-col gap-6 relative">
{{ with .ProfileTimeline }}
{{ range $idx, $byMonth := .ByMonth }}
···
<img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" />
{{ end }}
</div>
-
<div id="text" class="col-span-2 md:col-span-full">
-
<p
-
title="{{ didOrHandle .UserDid .UserHandle }}"
-
class="text-lg font-bold md:text-center dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
-
{{ didOrHandle .UserDid .UserHandle }}
</p>
-
<div class="text-sm md:text-center dark:text-gray-300">
-
<span id="followers">{{ .ProfileStats.Followers }} followers</span>
-
<span class="px-1 select-none after:content-['·']"></span>
-
<span id="following">{{ .ProfileStats.Following }} following</span>
-
</div>
-
{{ if ne .FollowStatus.String "IsSelf" }}
-
{{ template "user/fragments/follow" . }}
-
{{ end }}
</div>
</div>
</div>
{{ end }}
{{ define "ownRepos" }}
-
<p class="text-sm font-bold py-2 px-6 dark:text-white">REPOS</p>
-
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
-
{{ range .Repos }}
-
<div
-
id="repo-card"
-
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"
-
>
-
<div id="repo-card-name" class="font-medium dark:text-white">
-
<a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}"
-
>{{ .Name }}</a
-
>
</div>
-
{{ if .Description }}
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
-
{{ .Description }}
-
</div>
-
{{ end }}
-
<div
-
class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"
-
>
-
{{ if .RepoStats.StarCount }}
-
<div class="flex gap-1 items-center text-sm">
-
{{ i "star" "w-3 h-3 fill-current" }}
-
<span>{{ .RepoStats.StarCount }}</span>
-
</div>
-
{{ end }}
</div>
-
</div>
-
{{ else }}
-
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
-
{{ end }}
-
</div>
-
<p class="text-sm font-bold py-2 px-6 dark:text-white">COLLABORATING ON</p>
-
<div id="collaborating" class="grid grid-cols-1 gap-4 mb-6">
-
{{ range .CollaboratingRepos }}
-
<div
-
id="repo-card"
-
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col"
-
>
-
<div id="repo-card-name" class="font-medium dark:text-white">
-
<a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}">
-
{{ index $.DidHandleMap .Did }}/{{ .Name }}
-
</a>
-
</div>
-
{{ if .Description }}
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
-
{{ .Description }}
</div>
{{ end }}
-
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
-
-
{{ if .RepoStats.StarCount }}
-
<div class="flex gap-1 items-center text-sm">
-
{{ i "star" "w-3 h-3 fill-current" }}
-
<span>{{ .RepoStats.StarCount }}</span>
-
</div>
-
{{ end }}
-
</div>
</div>
-
{{ else }}
-
<p class="px-6 dark:text-white">This user is not collaborating.</p>
-
{{ end }}
</div>
{{ end }}
···
{{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }}
{{ define "content" }}
+
<div class="grid grid-cols-1 md:grid-cols-8 gap-6">
+
<div class="md:col-span-2 order-1 md:order-1">
{{ block "profileCard" . }}{{ end }}
</div>
+
<div id="all-repos" class="md:col-span-3 order-2 md:order-2">
{{ block "ownRepos" . }}{{ end }}
{{ block "collaboratingRepos" . }}{{ end }}
</div>
+
<div class="md:col-span-3 order-3 md:order-3">
{{ block "profileTimeline" . }}{{ end }}
</div>
</div>
{{ end }}
{{ define "profileTimeline" }}
+
<p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p>
<div class="flex flex-col gap-6 relative">
{{ with .ProfileTimeline }}
{{ range $idx, $byMonth := .ByMonth }}
···
<img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" />
{{ end }}
</div>
+
<div class="col-span-2 md:col-span-full">
+
<p title="{{ didOrHandle .UserDid .UserHandle }}"
+
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
+
{{ didOrHandle .UserDid .UserHandle }}
</p>
+
<div id="profile-bio" class="text-sm">
+
{{ if .Profile }}
+
<p>{{ .Profile.Description }}</p>
+
{{ end }}
+
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+
<span id="followers">{{ .ProfileStats.Followers }} followers</span>
+
<span class="select-none after:content-['·']"></span>
+
<span id="following">{{ .ProfileStats.Following }} following</span>
+
</div>
+
+
{{ $profile := .Profile }}
+
{{ with .Profile }}
+
<div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
+
{{ if .Location }}
+
<div class="flex items-center gap-2">
+
<span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span>
+
<span>{{ .Location }}</span>
+
</div>
+
{{ end }}
+
+
{{ if .IncludeBluesky }}
+
<div class="flex items-center gap-2">
+
<span class="flex-shrink-0">{{ i "link" "size-4" }}</span>
+
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">
+
bluesky/{{ didOrHandle $.UserDid $.UserHandle }}
+
</a>
+
</div>
+
{{ end }}
+
+
{{ range $link := .Links }}
+
{{ if $link }}
+
<div class="flex items-center gap-2">
+
<span class="flex-shrink-0">{{ i "link" "size-4" }}</span>
+
<a href="{{ $link }}">{{ $link }}</a>
+
</div>
+
{{ end }}
+
{{ end }}
+
+
{{ if not $profile.IsStatsEmpty }}
+
<div class="flex items-center justify-evenly gap-2 py-2">
+
{{ range $stat := .Stats }}
+
{{ if $stat.Kind }}
+
<div class="flex flex-col items-center gap-2">
+
<span class="text-xl font-bold">{{ $stat.Value }}</span>
+
<span>{{ $stat.Kind.String }}</span>
+
</div>
+
{{ end }}
+
{{ end }}
+
</div>
+
{{ end }}
+
+
</div>
+
{{ end }}
+
+
{{ if ne .FollowStatus.String "IsSelf" }}
+
{{ template "user/fragments/follow" . }}
+
{{ else }}
+
<button id="editBtn"
+
class="btn mt-2 w-full flex items-center gap-2"
+
hx-target="#profile-bio"
+
hx-get="/{{ $.UserDid }}/profile/edit-bio"
+
hx-swap="innerHTML">
+
{{ i "pencil" "w-4 h-4" }}
+
edit profile
+
</button>
+
{{ end }}
+
</div>
+
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
</div>
</div>
</div>
{{ end }}
{{ define "ownRepos" }}
+
<div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2">
+
<span>PINNED REPOS</span>
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .UserDid) }}
+
<button hx-get="/{{ $.UserDid }}/profile/edit-pins" hx-target="#all-repos" class="btn font-normal text-sm flex gap-2 items-center">
+
{{ i "pencil" "w-3 h-3" }}
+
edit
+
</button>
+
{{ end }}
+
</div>
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .Repos }}
+
<div
+
id="repo-card"
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
+
<div id="repo-card-name" class="font-medium">
+
<a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}"
+
>{{ .Name }}</a
+
>
+
</div>
+
{{ if .Description }}
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
+
{{ .Description }}
+
</div>
+
{{ end }}
+
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
+
{{ if .RepoStats.StarCount }}
+
<div class="flex gap-1 items-center text-sm">
+
{{ i "star" "w-3 h-3 fill-current" }}
+
<span>{{ .RepoStats.StarCount }}</span>
</div>
+
{{ end }}
+
</div>
+
</div>
+
{{ else }}
+
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
+
{{ end }}
+
</div>
+
{{ end }}
+
{{ define "collaboratingRepos" }}
+
{{ if gt (len .CollaboratingRepos) 0 }}
+
<p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p>
+
<div id="collaborating" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .CollaboratingRepos }}
+
<div
+
id="repo-card"
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col">
+
<div id="repo-card-name" class="font-medium dark:text-white">
+
<a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}">
+
{{ index $.DidHandleMap .Did }}/{{ .Name }}
+
</a>
+
</div>
+
{{ if .Description }}
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
+
{{ .Description }}
</div>
+
{{ end }}
+
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
+
{{ if .RepoStats.StarCount }}
+
<div class="flex gap-1 items-center text-sm">
+
{{ i "star" "w-3 h-3 fill-current" }}
+
<span>{{ .RepoStats.StarCount }}</span>
</div>
{{ end }}
</div>
+
</div>
+
{{ else }}
+
<p class="px-6 dark:text-white">This user is not collaborating.</p>
+
{{ end }}
</div>
+
{{ end }}
{{ end }}
+335 -2
appview/state/profile.go
···
"fmt"
"log"
"net/http"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/go-chi/chi/v5"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
)
···
return
}
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
if err != nil {
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
}
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
if err != nil {
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
}
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
···
LoggedInUser: loggedInUser,
UserDid: ident.DID.String(),
UserHandle: ident.Handle.String(),
-
Repos: repos,
-
CollaboratingRepos: collaboratingRepos,
ProfileStats: pages.ProfileStats{
Followers: followers,
Following: following,
},
FollowStatus: db.FollowStatus(followStatus),
DidHandleMap: didHandleMap,
AvatarUri: profileAvatarUri,
···
signature := hex.EncodeToString(h.Sum(nil))
return fmt.Sprintf("%s/%s/%s", s.config.AvatarHost, signature, handle)
}
···
"fmt"
"log"
"net/http"
+
"net/url"
+
"slices"
+
"strings"
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/go-chi/chi/v5"
+
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
)
···
return
}
+
profile, err := db.GetProfile(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
+
}
+
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
if err != nil {
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
}
+
// filter out ones that are pinned
+
pinnedRepos := []db.Repo{}
+
for i, r := range repos {
+
// if this is a pinned repo, add it
+
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
+
pinnedRepos = append(pinnedRepos, r)
+
}
+
+
// if there are no saved pins, add the first 4 repos
+
if profile.IsPinnedReposEmpty() && i < 4 {
+
pinnedRepos = append(pinnedRepos, r)
+
}
+
}
+
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
if err != nil {
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
+
}
+
+
pinnedCollaboratingRepos := []db.Repo{}
+
for _, r := range collaboratingRepos {
+
// if this is a pinned repo, add it
+
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
+
pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
+
}
}
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
···
LoggedInUser: loggedInUser,
UserDid: ident.DID.String(),
UserHandle: ident.Handle.String(),
+
Repos: pinnedRepos,
+
CollaboratingRepos: pinnedCollaboratingRepos,
ProfileStats: pages.ProfileStats{
Followers: followers,
Following: following,
},
+
Profile: profile,
FollowStatus: db.FollowStatus(followStatus),
DidHandleMap: didHandleMap,
AvatarUri: profileAvatarUri,
···
signature := hex.EncodeToString(h.Sum(nil))
return fmt.Sprintf("%s/%s/%s", s.config.AvatarHost, signature, handle)
}
+
+
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
+
err := r.ParseForm()
+
if err != nil {
+
log.Println("invalid profile update form", err)
+
s.pages.Notice(w, "update-profile", "Invalid form.")
+
return
+
}
+
+
profile, err := db.GetProfile(s.db, user.Did)
+
if err != nil {
+
log.Printf("getting profile data for %s: %s", user.Did, err)
+
}
+
+
profile.Description = r.FormValue("description")
+
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
+
profile.Location = r.FormValue("location")
+
+
var links [5]string
+
for i := range 5 {
+
iLink := r.FormValue(fmt.Sprintf("link%d", i))
+
links[i] = iLink
+
}
+
profile.Links = links
+
+
// Parse stats (exactly 2)
+
stat0 := r.FormValue("stat0")
+
stat1 := r.FormValue("stat1")
+
+
if stat0 != "" {
+
profile.Stats[0].Kind = db.VanityStatKind(stat0)
+
}
+
+
if stat1 != "" {
+
profile.Stats[1].Kind = db.VanityStatKind(stat1)
+
}
+
+
if err := s.validateProfile(profile); err != nil {
+
log.Println("invalid profile", err)
+
s.pages.Notice(w, "update-profile", err.Error())
+
return
+
}
+
+
s.updateProfile(profile, w, r)
+
return
+
}
+
+
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
+
err := r.ParseForm()
+
if err != nil {
+
log.Println("invalid profile update form", err)
+
s.pages.Notice(w, "update-profile", "Invalid form.")
+
return
+
}
+
+
profile, err := db.GetProfile(s.db, user.Did)
+
if err != nil {
+
log.Printf("getting profile data for %s: %s", user.Did, err)
+
}
+
+
i := 0
+
var pinnedRepos [6]syntax.ATURI
+
for key, values := range r.Form {
+
if i >= 6 {
+
log.Println("invalid pin update form", err)
+
s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.")
+
return
+
}
+
if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 {
+
aturi, err := syntax.ParseATURI(values[0])
+
if err != nil {
+
log.Println("invalid profile update form", err)
+
s.pages.Notice(w, "update-profile", "Invalid form.")
+
return
+
}
+
pinnedRepos[i] = aturi
+
i++
+
}
+
}
+
profile.PinnedRepos = pinnedRepos
+
+
s.updateProfile(profile, w, r)
+
return
+
}
+
+
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
log.Println("failed to start transaction", err)
+
s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
+
return
+
}
+
+
client, _ := s.auth.AuthorizedClient(r)
+
+
// yeah... lexgen dose not support syntax.ATURI in the record for some reason,
+
// nor does it support exact size arrays
+
var pinnedRepoStrings []string
+
for _, r := range profile.PinnedRepos {
+
pinnedRepoStrings = append(pinnedRepoStrings, r.String())
+
}
+
+
var vanityStats []string
+
for _, v := range profile.Stats {
+
vanityStats = append(vanityStats, string(v.Kind))
+
}
+
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
+
var cid *string
+
if ex != nil {
+
cid = ex.Cid
+
}
+
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.ActorProfileNSID,
+
Repo: user.Did,
+
Rkey: "self",
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.ActorProfile{
+
Bluesky: &profile.IncludeBluesky,
+
Description: &profile.Description,
+
Links: profile.Links[:],
+
Location: &profile.Location,
+
PinnedRepositories: pinnedRepoStrings,
+
Stats: vanityStats[:],
+
}},
+
SwapRecord: cid,
+
})
+
if err != nil {
+
log.Println("failed to update profile", err)
+
s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.")
+
return
+
}
+
+
err = db.UpsertProfile(tx, profile)
+
if err != nil {
+
log.Println("failed to update profile", err)
+
s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
+
return
+
}
+
+
s.pages.HxRedirect(w, "/"+user.Did)
+
return
+
}
+
+
func (s *State) validateProfile(profile *db.Profile) error {
+
// ensure description is not too long
+
if len(profile.Description) > 256 {
+
return fmt.Errorf("Entered bio is too long.")
+
}
+
+
// ensure description is not too long
+
if len(profile.Location) > 40 {
+
return fmt.Errorf("Entered location is too long.")
+
}
+
+
// ensure links are in order
+
err := validateLinks(profile)
+
if err != nil {
+
return err
+
}
+
+
// ensure all pinned repos are either own repos or collaborating repos
+
repos, err := db.GetAllReposByDid(s.db, profile.Did)
+
if err != nil {
+
log.Printf("getting repos for %s: %s", profile.Did, err)
+
}
+
+
collaboratingRepos, err := db.CollaboratingIn(s.db, profile.Did)
+
if err != nil {
+
log.Printf("getting collaborating repos for %s: %s", profile.Did, err)
+
}
+
+
var validRepos []syntax.ATURI
+
for _, r := range repos {
+
validRepos = append(validRepos, r.RepoAt())
+
}
+
for _, r := range collaboratingRepos {
+
validRepos = append(validRepos, r.RepoAt())
+
}
+
+
for _, pinned := range profile.PinnedRepos {
+
if pinned == "" {
+
continue
+
}
+
if !slices.Contains(validRepos, pinned) {
+
return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned)
+
}
+
}
+
+
return nil
+
}
+
+
func validateLinks(profile *db.Profile) error {
+
for i, link := range profile.Links {
+
if link == "" {
+
continue
+
}
+
+
parsedURL, err := url.Parse(link)
+
if err != nil {
+
return fmt.Errorf("Invalid URL '%s': %v\n", link, err)
+
}
+
+
if parsedURL.Scheme == "" {
+
if strings.HasPrefix(link, "//") {
+
profile.Links[i] = "https:" + link
+
} else {
+
profile.Links[i] = "https://" + link
+
}
+
continue
+
} else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
+
return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme)
+
}
+
+
// catch relative paths
+
if parsedURL.Host == "" {
+
return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link)
+
}
+
}
+
return nil
+
}
+
+
func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
+
profile, err := db.GetProfile(s.db, user.Did)
+
if err != nil {
+
log.Printf("getting profile data for %s: %s", user.Did, err)
+
}
+
+
s.pages.EditBioFragment(w, pages.EditBioParams{
+
LoggedInUser: user,
+
Profile: profile,
+
})
+
}
+
+
func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
+
profile, err := db.GetProfile(s.db, user.Did)
+
if err != nil {
+
log.Printf("getting profile data for %s: %s", user.Did, err)
+
}
+
+
repos, err := db.GetAllReposByDid(s.db, user.Did)
+
if err != nil {
+
log.Printf("getting repos for %s: %s", user.Did, err)
+
}
+
+
collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did)
+
if err != nil {
+
log.Printf("getting collaborating repos for %s: %s", user.Did, err)
+
}
+
+
allRepos := []pages.PinnedRepo{}
+
+
for _, r := range repos {
+
isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
+
allRepos = append(allRepos, pages.PinnedRepo{
+
IsPinned: isPinned,
+
Repo: r,
+
})
+
}
+
for _, r := range collaboratingRepos {
+
isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
+
allRepos = append(allRepos, pages.PinnedRepo{
+
IsPinned: isPinned,
+
Repo: r,
+
})
+
}
+
+
var didsToResolve []string
+
for _, r := range allRepos {
+
didsToResolve = append(didsToResolve, r.Did)
+
}
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
} else {
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
}
+
}
+
+
s.pages.EditPinsFragment(w, pages.EditPinsParams{
+
LoggedInUser: user,
+
Profile: profile,
+
AllRepos: allRepos,
+
DidHandleMap: didHandleMap,
+
})
+
}
+8
appview/state/router.go
···
r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) {
r.Get("/", s.ProfilePage)
r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) {
r.Get("/", s.RepoIndex)
r.Get("/commits/{ref}", s.RepoLog)
···
r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) {
r.Get("/", s.ProfilePage)
+
r.Route("/profile", func(r chi.Router) {
+
r.Use(middleware.AuthMiddleware(s.auth))
+
r.Get("/edit-bio", s.EditBioFragment)
+
r.Get("/edit-pins", s.EditPinsFragment)
+
r.Post("/bio", s.UpdateProfileBio)
+
r.Post("/pins", s.UpdateProfilePins)
+
})
+
r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) {
r.Get("/", s.RepoIndex)
r.Get("/commits/{ref}", s.RepoLog)
+1
cmd/gen.go
···
tangled.RepoPullStatus{},
tangled.RepoPullComment{},
tangled.RepoArtifact{},
); err != nil {
panic(err)
}
···
tangled.RepoPullStatus{},
tangled.RepoPullComment{},
tangled.RepoArtifact{},
+
tangled.ActorProfile{},
); err != nil {
panic(err)
}
+2 -1
flake.nix
···
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
'';
};
});
apps = forAllSystems (system: let
···
pkgs.writeShellScriptBin "run"
''
TANGLED_DEV=true ${pkgs.air}/bin/air -c /dev/null \
-
-build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
-build.bin "./out/${name}.out" \
-build.stop_on_error "true" \
-build.include_ext "go"
···
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
'';
+
CGO_ENABLED=1;
};
});
apps = forAllSystems (system: let
···
pkgs.writeShellScriptBin "run"
''
TANGLED_DEV=true ${pkgs.air}/bin/air -c /dev/null \
+
-build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
-build.bin "./out/${name}.out" \
-build.stop_on_error "true" \
-build.include_ext "go"
+72
lexicons/actor/profile.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.actor.profile",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "A declaration of a Tangled account profile.",
+
"key": "literal:self",
+
"record": {
+
"type": "object",
+
"properties": {
+
"required": [
+
"bluesky",
+
],
+
"description": {
+
"type": "string",
+
"description": "Free-form profile description text.",
+
"maxGraphemes": 256,
+
"maxLength": 2560
+
},
+
"links": {
+
"type": "array",
+
"minLength": 0,
+
"maxLength": 5,
+
"items": {
+
"type": "string",
+
"description": "Any URI, intended for social profiles or websites, can be used to link DIDs/AT-URIs too.",
+
"format": "uri"
+
}
+
},
+
"stats": {
+
"type": "array",
+
"minLength": 0,
+
"maxLength": 2,
+
"items": {
+
"type": "string",
+
"description": "Vanity stats.",
+
"enum": [
+
"merged-pull-request-count",
+
"closed-pull-request-count",
+
"open-pull-request-count",
+
"open-issue-count",
+
"closed-issue-count",
+
"repository-count"
+
]
+
}
+
},
+
"bluesky": {
+
"type": "boolean",
+
"description": "Include link to this account on Bluesky."
+
},
+
"location": {
+
"type": "string",
+
"description": "Free-form location text.",
+
"maxGraphemes": 40,
+
"maxLength": 400
+
},
+
"pinnedRepositories": {
+
"type": "array",
+
"description": "Any ATURI, it is up to appviews to validate these fields.",
+
"minLength": 0,
+
"maxLength": 6,
+
"items": {
+
"type": "string",
+
"format": "at-uri"
+
}
+
}
+
}
+
}
+
}
+
}
+
}