forked from tangled.org/core
Monorepo for Tangled

appview: profile: introduce profile lexicon

Changed files
+1639 -94
api
appview
cmd
lexicons
actor
+31
api/tangled/actorprofile.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.actor.profile 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + ActorProfileNSID = "sh.tangled.actor.profile" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.actor.profile", &ActorProfile{}) 17 + } // 18 + // RECORDTYPE: ActorProfile 19 + type ActorProfile struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.actor.profile" cborgen:"$type,const=sh.tangled.actor.profile"` 21 + // bluesky: Include link to this account on Bluesky. 22 + Bluesky *bool `json:"bluesky,omitempty" cborgen:"bluesky,omitempty"` 23 + // description: Free-form profile description text. 24 + Description *string `json:"description,omitempty" cborgen:"description,omitempty"` 25 + Links []string `json:"links,omitempty" cborgen:"links,omitempty"` 26 + // location: Free-form location text. 27 + Location *string `json:"location,omitempty" cborgen:"location,omitempty"` 28 + // pinnedRepositories: Any ATURI, it is up to appviews to validate these fields. 29 + PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"` 30 + Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 31 + }
+513
api/tangled/cbor_gen.go
··· 3389 3389 3390 3390 return nil 3391 3391 } 3392 + func (t *ActorProfile) MarshalCBOR(w io.Writer) error { 3393 + if t == nil { 3394 + _, err := w.Write(cbg.CborNull) 3395 + return err 3396 + } 3397 + 3398 + cw := cbg.NewCborWriter(w) 3399 + fieldCount := 7 3400 + 3401 + if t.Bluesky == nil { 3402 + fieldCount-- 3403 + } 3404 + 3405 + if t.Description == nil { 3406 + fieldCount-- 3407 + } 3408 + 3409 + if t.Links == nil { 3410 + fieldCount-- 3411 + } 3412 + 3413 + if t.Location == nil { 3414 + fieldCount-- 3415 + } 3416 + 3417 + if t.PinnedRepositories == nil { 3418 + fieldCount-- 3419 + } 3420 + 3421 + if t.Stats == nil { 3422 + fieldCount-- 3423 + } 3424 + 3425 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 3426 + return err 3427 + } 3428 + 3429 + // t.LexiconTypeID (string) (string) 3430 + if len("$type") > 1000000 { 3431 + return xerrors.Errorf("Value in field \"$type\" was too long") 3432 + } 3433 + 3434 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 3435 + return err 3436 + } 3437 + if _, err := cw.WriteString(string("$type")); err != nil { 3438 + return err 3439 + } 3440 + 3441 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.actor.profile"))); err != nil { 3442 + return err 3443 + } 3444 + if _, err := cw.WriteString(string("sh.tangled.actor.profile")); err != nil { 3445 + return err 3446 + } 3447 + 3448 + // t.Links ([]string) (slice) 3449 + if t.Links != nil { 3450 + 3451 + if len("links") > 1000000 { 3452 + return xerrors.Errorf("Value in field \"links\" was too long") 3453 + } 3454 + 3455 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("links"))); err != nil { 3456 + return err 3457 + } 3458 + if _, err := cw.WriteString(string("links")); err != nil { 3459 + return err 3460 + } 3461 + 3462 + if len(t.Links) > 8192 { 3463 + return xerrors.Errorf("Slice value in field t.Links was too long") 3464 + } 3465 + 3466 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Links))); err != nil { 3467 + return err 3468 + } 3469 + for _, v := range t.Links { 3470 + if len(v) > 1000000 { 3471 + return xerrors.Errorf("Value in field v was too long") 3472 + } 3473 + 3474 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 3475 + return err 3476 + } 3477 + if _, err := cw.WriteString(string(v)); err != nil { 3478 + return err 3479 + } 3480 + 3481 + } 3482 + } 3483 + 3484 + // t.Stats ([]string) (slice) 3485 + if t.Stats != nil { 3486 + 3487 + if len("stats") > 1000000 { 3488 + return xerrors.Errorf("Value in field \"stats\" was too long") 3489 + } 3490 + 3491 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("stats"))); err != nil { 3492 + return err 3493 + } 3494 + if _, err := cw.WriteString(string("stats")); err != nil { 3495 + return err 3496 + } 3497 + 3498 + if len(t.Stats) > 8192 { 3499 + return xerrors.Errorf("Slice value in field t.Stats was too long") 3500 + } 3501 + 3502 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Stats))); err != nil { 3503 + return err 3504 + } 3505 + for _, v := range t.Stats { 3506 + if len(v) > 1000000 { 3507 + return xerrors.Errorf("Value in field v was too long") 3508 + } 3509 + 3510 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 3511 + return err 3512 + } 3513 + if _, err := cw.WriteString(string(v)); err != nil { 3514 + return err 3515 + } 3516 + 3517 + } 3518 + } 3519 + 3520 + // t.Bluesky (bool) (bool) 3521 + if t.Bluesky != nil { 3522 + 3523 + if len("bluesky") > 1000000 { 3524 + return xerrors.Errorf("Value in field \"bluesky\" was too long") 3525 + } 3526 + 3527 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("bluesky"))); err != nil { 3528 + return err 3529 + } 3530 + if _, err := cw.WriteString(string("bluesky")); err != nil { 3531 + return err 3532 + } 3533 + 3534 + if t.Bluesky == nil { 3535 + if _, err := cw.Write(cbg.CborNull); err != nil { 3536 + return err 3537 + } 3538 + } else { 3539 + if err := cbg.WriteBool(w, *t.Bluesky); err != nil { 3540 + return err 3541 + } 3542 + } 3543 + } 3544 + 3545 + // t.Location (string) (string) 3546 + if t.Location != nil { 3547 + 3548 + if len("location") > 1000000 { 3549 + return xerrors.Errorf("Value in field \"location\" was too long") 3550 + } 3551 + 3552 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("location"))); err != nil { 3553 + return err 3554 + } 3555 + if _, err := cw.WriteString(string("location")); err != nil { 3556 + return err 3557 + } 3558 + 3559 + if t.Location == nil { 3560 + if _, err := cw.Write(cbg.CborNull); err != nil { 3561 + return err 3562 + } 3563 + } else { 3564 + if len(*t.Location) > 1000000 { 3565 + return xerrors.Errorf("Value in field t.Location was too long") 3566 + } 3567 + 3568 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Location))); err != nil { 3569 + return err 3570 + } 3571 + if _, err := cw.WriteString(string(*t.Location)); err != nil { 3572 + return err 3573 + } 3574 + } 3575 + } 3576 + 3577 + // t.Description (string) (string) 3578 + if t.Description != nil { 3579 + 3580 + if len("description") > 1000000 { 3581 + return xerrors.Errorf("Value in field \"description\" was too long") 3582 + } 3583 + 3584 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 3585 + return err 3586 + } 3587 + if _, err := cw.WriteString(string("description")); err != nil { 3588 + return err 3589 + } 3590 + 3591 + if t.Description == nil { 3592 + if _, err := cw.Write(cbg.CborNull); err != nil { 3593 + return err 3594 + } 3595 + } else { 3596 + if len(*t.Description) > 1000000 { 3597 + return xerrors.Errorf("Value in field t.Description was too long") 3598 + } 3599 + 3600 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Description))); err != nil { 3601 + return err 3602 + } 3603 + if _, err := cw.WriteString(string(*t.Description)); err != nil { 3604 + return err 3605 + } 3606 + } 3607 + } 3608 + 3609 + // t.PinnedRepositories ([]string) (slice) 3610 + if t.PinnedRepositories != nil { 3611 + 3612 + if len("pinnedRepositories") > 1000000 { 3613 + return xerrors.Errorf("Value in field \"pinnedRepositories\" was too long") 3614 + } 3615 + 3616 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedRepositories"))); err != nil { 3617 + return err 3618 + } 3619 + if _, err := cw.WriteString(string("pinnedRepositories")); err != nil { 3620 + return err 3621 + } 3622 + 3623 + if len(t.PinnedRepositories) > 8192 { 3624 + return xerrors.Errorf("Slice value in field t.PinnedRepositories was too long") 3625 + } 3626 + 3627 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.PinnedRepositories))); err != nil { 3628 + return err 3629 + } 3630 + for _, v := range t.PinnedRepositories { 3631 + if len(v) > 1000000 { 3632 + return xerrors.Errorf("Value in field v was too long") 3633 + } 3634 + 3635 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 3636 + return err 3637 + } 3638 + if _, err := cw.WriteString(string(v)); err != nil { 3639 + return err 3640 + } 3641 + 3642 + } 3643 + } 3644 + return nil 3645 + } 3646 + 3647 + func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) { 3648 + *t = ActorProfile{} 3649 + 3650 + cr := cbg.NewCborReader(r) 3651 + 3652 + maj, extra, err := cr.ReadHeader() 3653 + if err != nil { 3654 + return err 3655 + } 3656 + defer func() { 3657 + if err == io.EOF { 3658 + err = io.ErrUnexpectedEOF 3659 + } 3660 + }() 3661 + 3662 + if maj != cbg.MajMap { 3663 + return fmt.Errorf("cbor input should be of type map") 3664 + } 3665 + 3666 + if extra > cbg.MaxLength { 3667 + return fmt.Errorf("ActorProfile: map struct too large (%d)", extra) 3668 + } 3669 + 3670 + n := extra 3671 + 3672 + nameBuf := make([]byte, 18) 3673 + for i := uint64(0); i < n; i++ { 3674 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 3675 + if err != nil { 3676 + return err 3677 + } 3678 + 3679 + if !ok { 3680 + // Field doesn't exist on this type, so ignore it 3681 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3682 + return err 3683 + } 3684 + continue 3685 + } 3686 + 3687 + switch string(nameBuf[:nameLen]) { 3688 + // t.LexiconTypeID (string) (string) 3689 + case "$type": 3690 + 3691 + { 3692 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3693 + if err != nil { 3694 + return err 3695 + } 3696 + 3697 + t.LexiconTypeID = string(sval) 3698 + } 3699 + // t.Links ([]string) (slice) 3700 + case "links": 3701 + 3702 + maj, extra, err = cr.ReadHeader() 3703 + if err != nil { 3704 + return err 3705 + } 3706 + 3707 + if extra > 8192 { 3708 + return fmt.Errorf("t.Links: array too large (%d)", extra) 3709 + } 3710 + 3711 + if maj != cbg.MajArray { 3712 + return fmt.Errorf("expected cbor array") 3713 + } 3714 + 3715 + if extra > 0 { 3716 + t.Links = make([]string, extra) 3717 + } 3718 + 3719 + for i := 0; i < int(extra); i++ { 3720 + { 3721 + var maj byte 3722 + var extra uint64 3723 + var err error 3724 + _ = maj 3725 + _ = extra 3726 + _ = err 3727 + 3728 + { 3729 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3730 + if err != nil { 3731 + return err 3732 + } 3733 + 3734 + t.Links[i] = string(sval) 3735 + } 3736 + 3737 + } 3738 + } 3739 + // t.Stats ([]string) (slice) 3740 + case "stats": 3741 + 3742 + maj, extra, err = cr.ReadHeader() 3743 + if err != nil { 3744 + return err 3745 + } 3746 + 3747 + if extra > 8192 { 3748 + return fmt.Errorf("t.Stats: array too large (%d)", extra) 3749 + } 3750 + 3751 + if maj != cbg.MajArray { 3752 + return fmt.Errorf("expected cbor array") 3753 + } 3754 + 3755 + if extra > 0 { 3756 + t.Stats = make([]string, extra) 3757 + } 3758 + 3759 + for i := 0; i < int(extra); i++ { 3760 + { 3761 + var maj byte 3762 + var extra uint64 3763 + var err error 3764 + _ = maj 3765 + _ = extra 3766 + _ = err 3767 + 3768 + { 3769 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3770 + if err != nil { 3771 + return err 3772 + } 3773 + 3774 + t.Stats[i] = string(sval) 3775 + } 3776 + 3777 + } 3778 + } 3779 + // t.Bluesky (bool) (bool) 3780 + case "bluesky": 3781 + 3782 + { 3783 + b, err := cr.ReadByte() 3784 + if err != nil { 3785 + return err 3786 + } 3787 + if b != cbg.CborNull[0] { 3788 + if err := cr.UnreadByte(); err != nil { 3789 + return err 3790 + } 3791 + 3792 + maj, extra, err = cr.ReadHeader() 3793 + if err != nil { 3794 + return err 3795 + } 3796 + if maj != cbg.MajOther { 3797 + return fmt.Errorf("booleans must be major type 7") 3798 + } 3799 + 3800 + var val bool 3801 + switch extra { 3802 + case 20: 3803 + val = false 3804 + case 21: 3805 + val = true 3806 + default: 3807 + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 3808 + } 3809 + t.Bluesky = &val 3810 + } 3811 + } 3812 + // t.Location (string) (string) 3813 + case "location": 3814 + 3815 + { 3816 + b, err := cr.ReadByte() 3817 + if err != nil { 3818 + return err 3819 + } 3820 + if b != cbg.CborNull[0] { 3821 + if err := cr.UnreadByte(); err != nil { 3822 + return err 3823 + } 3824 + 3825 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3826 + if err != nil { 3827 + return err 3828 + } 3829 + 3830 + t.Location = (*string)(&sval) 3831 + } 3832 + } 3833 + // t.Description (string) (string) 3834 + case "description": 3835 + 3836 + { 3837 + b, err := cr.ReadByte() 3838 + if err != nil { 3839 + return err 3840 + } 3841 + if b != cbg.CborNull[0] { 3842 + if err := cr.UnreadByte(); err != nil { 3843 + return err 3844 + } 3845 + 3846 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3847 + if err != nil { 3848 + return err 3849 + } 3850 + 3851 + t.Description = (*string)(&sval) 3852 + } 3853 + } 3854 + // t.PinnedRepositories ([]string) (slice) 3855 + case "pinnedRepositories": 3856 + 3857 + maj, extra, err = cr.ReadHeader() 3858 + if err != nil { 3859 + return err 3860 + } 3861 + 3862 + if extra > 8192 { 3863 + return fmt.Errorf("t.PinnedRepositories: array too large (%d)", extra) 3864 + } 3865 + 3866 + if maj != cbg.MajArray { 3867 + return fmt.Errorf("expected cbor array") 3868 + } 3869 + 3870 + if extra > 0 { 3871 + t.PinnedRepositories = make([]string, extra) 3872 + } 3873 + 3874 + for i := 0; i < int(extra); i++ { 3875 + { 3876 + var maj byte 3877 + var extra uint64 3878 + var err error 3879 + _ = maj 3880 + _ = extra 3881 + _ = err 3882 + 3883 + { 3884 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3885 + if err != nil { 3886 + return err 3887 + } 3888 + 3889 + t.PinnedRepositories[i] = string(sval) 3890 + } 3891 + 3892 + } 3893 + } 3894 + 3895 + default: 3896 + // Field doesn't exist on this type, so ignore it 3897 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3898 + return err 3899 + } 3900 + } 3901 + } 3902 + 3903 + return nil 3904 + }
-16
appview/db/artifact.go
··· 57 57 return err 58 58 } 59 59 60 - type filter struct { 61 - key string 62 - arg any 63 - } 64 - 65 - func Filter(key string, arg any) filter { 66 - return filter{ 67 - key: key, 68 - arg: arg, 69 - } 70 - } 71 - 72 - func (f filter) Condition() string { 73 - return fmt.Sprintf("%s = ?", f.key) 74 - } 75 - 76 60 func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) { 77 61 var artifacts []Artifact 78 62
+73
appview/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 + "fmt" 6 7 "log" 7 8 8 9 _ "github.com/mattn/go-sqlite3" ··· 231 232 foreign key (repo_at) references repos(at_uri) on delete cascade 232 233 ); 233 234 235 + create table if not exists profile ( 236 + -- id 237 + id integer primary key autoincrement, 238 + did text not null, 239 + 240 + -- data 241 + description text not null, 242 + include_bluesky integer not null default 0, 243 + location text, 244 + 245 + -- constraints 246 + unique(did) 247 + ); 248 + create table if not exists profile_links ( 249 + -- id 250 + id integer primary key autoincrement, 251 + did text not null, 252 + 253 + -- data 254 + link text not null, 255 + 256 + -- constraints 257 + foreign key (did) references profile(did) on delete cascade 258 + ); 259 + create table if not exists profile_stats ( 260 + -- id 261 + id integer primary key autoincrement, 262 + did text not null, 263 + 264 + -- data 265 + kind text not null check (kind in ( 266 + "merged-pull-request-count", 267 + "closed-pull-request-count", 268 + "open-pull-request-count", 269 + "open-issue-count", 270 + "closed-issue-count", 271 + "repository-count" 272 + )), 273 + 274 + -- constraints 275 + foreign key (did) references profile(did) on delete cascade 276 + ); 277 + create table if not exists profile_pinned_repositories ( 278 + -- id 279 + id integer primary key autoincrement, 280 + did text not null, 281 + 282 + -- data 283 + at_uri text not null, 284 + 285 + -- constraints 286 + unique(did, at_uri), 287 + foreign key (did) references profile(did) on delete cascade, 288 + foreign key (at_uri) references repos(at_uri) on delete cascade 289 + ); 290 + 234 291 create table if not exists migrations ( 235 292 id integer primary key autoincrement, 236 293 name text unique ··· 348 405 349 406 return nil 350 407 } 408 + 409 + type filter struct { 410 + key string 411 + arg any 412 + } 413 + 414 + func Filter(key string, arg any) filter { 415 + return filter{ 416 + key: key, 417 + arg: arg, 418 + } 419 + } 420 + 421 + func (f filter) Condition() string { 422 + return fmt.Sprintf("%s = ?", f.key) 423 + }
+285
appview/db/profile.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 6 + "log" 5 7 "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.sh/tangled.sh/core/api/tangled" 6 11 ) 7 12 8 13 type RepoEvent struct { ··· 162 167 163 168 return &timeline, nil 164 169 } 170 + 171 + type Profile struct { 172 + // ids 173 + ID int 174 + Did string 175 + 176 + // data 177 + Description string 178 + IncludeBluesky bool 179 + Location string 180 + Links [5]string 181 + Stats [2]VanityStat 182 + PinnedRepos [6]syntax.ATURI 183 + } 184 + 185 + func (p Profile) IsLinksEmpty() bool { 186 + for _, l := range p.Links { 187 + if l != "" { 188 + return false 189 + } 190 + } 191 + return true 192 + } 193 + 194 + func (p Profile) IsStatsEmpty() bool { 195 + for _, s := range p.Stats { 196 + if s.Kind != "" { 197 + return false 198 + } 199 + } 200 + return true 201 + } 202 + 203 + func (p Profile) IsPinnedReposEmpty() bool { 204 + for _, r := range p.PinnedRepos { 205 + if r != "" { 206 + return false 207 + } 208 + } 209 + return true 210 + } 211 + 212 + type VanityStatKind string 213 + 214 + const ( 215 + VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count" 216 + VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count" 217 + VanityStatOpenPRCount VanityStatKind = "open-pull-request-count" 218 + VanityStatOpenIssueCount VanityStatKind = "open-issue-count" 219 + VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" 220 + VanityStatRepositoryCount VanityStatKind = "repository-count" 221 + ) 222 + 223 + func (v VanityStatKind) String() string { 224 + switch v { 225 + case VanityStatMergedPRCount: 226 + return "Merged PRs" 227 + case VanityStatClosedPRCount: 228 + return "Closed PRs" 229 + case VanityStatOpenPRCount: 230 + return "Open PRs" 231 + case VanityStatOpenIssueCount: 232 + return "Open Issues" 233 + case VanityStatClosedIssueCount: 234 + return "Closed Issues" 235 + case VanityStatRepositoryCount: 236 + return "Repositories" 237 + } 238 + return "" 239 + } 240 + 241 + type VanityStat struct { 242 + Kind VanityStatKind 243 + Value uint64 244 + } 245 + 246 + func (p *Profile) ProfileAt() syntax.ATURI { 247 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self")) 248 + } 249 + 250 + func UpsertProfile(tx *sql.Tx, profile *Profile) error { 251 + defer tx.Rollback() 252 + 253 + // update links 254 + _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did) 255 + if err != nil { 256 + return err 257 + } 258 + // update vanity stats 259 + _, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did) 260 + if err != nil { 261 + return err 262 + } 263 + 264 + // update pinned repos 265 + _, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did) 266 + if err != nil { 267 + return err 268 + } 269 + 270 + includeBskyValue := 0 271 + if profile.IncludeBluesky { 272 + includeBskyValue = 1 273 + } 274 + 275 + _, err = tx.Exec( 276 + `insert or replace into profile ( 277 + did, 278 + description, 279 + include_bluesky, 280 + location 281 + ) 282 + values (?, ?, ?, ?)`, 283 + profile.Did, 284 + profile.Description, 285 + includeBskyValue, 286 + profile.Location, 287 + ) 288 + 289 + if err != nil { 290 + log.Println("profile", "err", err) 291 + return err 292 + } 293 + 294 + for _, link := range profile.Links { 295 + if link == "" { 296 + continue 297 + } 298 + 299 + _, err := tx.Exec( 300 + `insert into profile_links (did, link) values (?, ?)`, 301 + profile.Did, 302 + link, 303 + ) 304 + 305 + if err != nil { 306 + log.Println("profile_links", "err", err) 307 + return err 308 + } 309 + } 310 + 311 + for _, v := range profile.Stats { 312 + if v.Kind == "" { 313 + continue 314 + } 315 + 316 + _, err := tx.Exec( 317 + `insert into profile_stats (did, kind) values (?, ?)`, 318 + profile.Did, 319 + v.Kind, 320 + ) 321 + 322 + if err != nil { 323 + log.Println("profile_stats", "err", err) 324 + return err 325 + } 326 + } 327 + 328 + for _, pin := range profile.PinnedRepos { 329 + if pin == "" { 330 + continue 331 + } 332 + 333 + _, err := tx.Exec( 334 + `insert into profile_pinned_repositories (did, at_uri) values (?, ?)`, 335 + profile.Did, 336 + pin, 337 + ) 338 + 339 + if err != nil { 340 + log.Println("profile_pinned_repositories") 341 + return err 342 + } 343 + } 344 + 345 + return tx.Commit() 346 + } 347 + 348 + func GetProfile(e Execer, did string) (*Profile, error) { 349 + var profile Profile 350 + profile.Did = did 351 + 352 + includeBluesky := 0 353 + err := e.QueryRow( 354 + `select description, include_bluesky, location from profile where did = ?`, 355 + did, 356 + ).Scan(&profile.Description, &includeBluesky, &profile.Location) 357 + if err == sql.ErrNoRows { 358 + profile := Profile{} 359 + profile.Did = did 360 + return &profile, nil 361 + } 362 + 363 + if err != nil { 364 + return nil, err 365 + } 366 + 367 + if includeBluesky != 0 { 368 + profile.IncludeBluesky = true 369 + } 370 + 371 + rows, err := e.Query(`select link from profile_links where did = ?`, did) 372 + if err != nil { 373 + return nil, err 374 + } 375 + defer rows.Close() 376 + i := 0 377 + for rows.Next() { 378 + if err := rows.Scan(&profile.Links[i]); err != nil { 379 + return nil, err 380 + } 381 + i++ 382 + } 383 + 384 + rows, err = e.Query(`select kind from profile_stats where did = ?`, did) 385 + if err != nil { 386 + return nil, err 387 + } 388 + defer rows.Close() 389 + i = 0 390 + for rows.Next() { 391 + if err := rows.Scan(&profile.Stats[i].Kind); err != nil { 392 + return nil, err 393 + } 394 + value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind) 395 + if err != nil { 396 + return nil, err 397 + } 398 + profile.Stats[i].Value = value 399 + i++ 400 + } 401 + 402 + rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did) 403 + if err != nil { 404 + return nil, err 405 + } 406 + defer rows.Close() 407 + i = 0 408 + for rows.Next() { 409 + if err := rows.Scan(&profile.PinnedRepos[i]); err != nil { 410 + return nil, err 411 + } 412 + i++ 413 + } 414 + 415 + return &profile, nil 416 + } 417 + 418 + func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) { 419 + query := "" 420 + var args []any 421 + switch stat { 422 + case VanityStatMergedPRCount: 423 + query = `select count(id) from pulls where owner_did = ? and state = ?` 424 + args = append(args, did, PullMerged) 425 + case VanityStatClosedPRCount: 426 + query = `select count(id) from pulls where owner_did = ? and state = ?` 427 + args = append(args, did, PullClosed) 428 + case VanityStatOpenPRCount: 429 + query = `select count(id) from pulls where owner_did = ? and state = ?` 430 + args = append(args, did, PullOpen) 431 + case VanityStatOpenIssueCount: 432 + query = `select count(id) from issues where owner_did = ? and open = 1` 433 + args = append(args, did) 434 + case VanityStatClosedIssueCount: 435 + query = `select count(id) from issues where owner_did = ? and open = 0` 436 + args = append(args, did) 437 + case VanityStatRepositoryCount: 438 + query = `select count(id) from repos where did = ?` 439 + args = append(args, did) 440 + } 441 + 442 + var result uint64 443 + err := e.QueryRow(query, args...).Scan(&result) 444 + if err != nil { 445 + return 0, err 446 + } 447 + 448 + return result, nil 449 + }
+6
appview/db/repos.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "fmt" 5 6 "time" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.sh/tangled.sh/core/api/tangled" 8 10 ) 9 11 10 12 type Repo struct { ··· 21 23 22 24 // optional 23 25 Source string 26 + } 27 + 28 + func (r Repo) RepoAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 24 30 } 25 31 26 32 func GetAllRepos(e Execer, limit int) ([]Repo, error) {
+26
appview/pages/pages.go
··· 317 317 CollaboratingRepos []db.Repo 318 318 ProfileStats ProfileStats 319 319 FollowStatus db.FollowStatus 320 + Profile *db.Profile 320 321 AvatarUri string 321 322 ProfileTimeline *db.ProfileTimeline 322 323 ··· 339 340 340 341 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 341 342 return p.executePlain("user/fragments/follow", w, params) 343 + } 344 + 345 + type EditBioParams struct { 346 + LoggedInUser *auth.User 347 + Profile *db.Profile 348 + } 349 + 350 + func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { 351 + return p.executePlain("user/fragments/editBio", w, params) 352 + } 353 + 354 + type EditPinsParams struct { 355 + LoggedInUser *auth.User 356 + Profile *db.Profile 357 + AllRepos []PinnedRepo 358 + DidHandleMap map[string]string 359 + } 360 + 361 + type PinnedRepo struct { 362 + IsPinned bool 363 + db.Repo 364 + } 365 + 366 + func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { 367 + return p.executePlain("user/fragments/editPins", w, params) 342 368 } 343 369 344 370 type RepoActionsFragmentParams struct {
+107
appview/pages/templates/user/fragments/editBio.html
··· 1 + {{ define "user/fragments/editBio" }} 2 + <form 3 + hx-post="/{{ .LoggedInUser.Did }}/profile/bio" 4 + class="flex flex-col gap-4 my-2 max-w-full" 5 + hx-disabled-elt="#save-btn,#cancel-btn" 6 + hx-swap="none"> 7 + <div class="flex flex-col gap-1"> 8 + {{ $description := "" }} 9 + {{ if and .Profile .Profile.Description }} 10 + {{ $description = .Profile.Description }} 11 + {{ end }} 12 + <label class="m-0 p-0" for="description">bio</label> 13 + <textarea 14 + type="text" 15 + class="py-1 px-1 w-full" 16 + name="description" 17 + rows="3" 18 + placeholder="write a bio">{{ $description }}</textarea> 19 + </div> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <label class="m-0 p-0" for="location">location</label> 23 + <div class="flex items-center gap-2 w-full"> 24 + {{ $location := "" }} 25 + {{ if and .Profile .Profile.Location }} 26 + {{ $location = .Profile.Location }} 27 + {{ end }} 28 + <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 29 + <input type="text" class="py-1 px-1 w-full" name="location" value="{{ $location }}"> 30 + </div> 31 + </div> 32 + 33 + <div class="flex flex-col gap-1"> 34 + <label class="m-0 p-0">social links</label> 35 + <div class="flex items-center gap-2 py-1"> 36 + {{ $includeBsky := false }} 37 + {{ if and .Profile .Profile.IncludeBluesky }} 38 + {{ $includeBsky = true }} 39 + {{ end }} 40 + <input type="checkbox" id="includeBluesky" name="includeBluesky" value="on" {{if $includeBsky}}checked{{end}}> 41 + <label for="includeBluesky" class="my-0 py-0 normal-case font-normal">Link to Bluesky account</label> 42 + </div> 43 + 44 + {{ $profile := .Profile }} 45 + {{ range $idx, $s := (sequence 5) }} 46 + {{ $link := "" }} 47 + {{ if and $profile $profile.Links }} 48 + {{ if lt $idx (len $profile.Links) }} 49 + {{ $link = index $profile.Links $idx }} 50 + {{ end }} 51 + {{ end }} 52 + 53 + <div class="flex items-center gap-2 w-full"> 54 + <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 55 + <input type="text" class="py-1 px-1 w-full" name="link{{$idx}}" value="{{ $link }}" placeholder="social link {{add $idx 1}}"> 56 + </div> 57 + {{ end }} 58 + </div> 59 + 60 + <div class="flex flex-col gap-1"> 61 + <label class="m-0 p-0">vanity stats</label> 62 + {{ range $idx, $s := (sequence 2) }} 63 + {{ $stat := "" }} 64 + {{ if and $profile $profile.Stats }} 65 + {{ if lt $idx (len $profile.Stats) }} 66 + {{ $s := index $profile.Stats $idx }} 67 + {{ $stat = $s.Kind }} 68 + {{ end }} 69 + {{ end }} 70 + 71 + {{ block "stat" (list $idx $stat) }} {{ end }} 72 + {{ end }} 73 + </div> 74 + 75 + <div class="flex items-center gap-2 justify-between"> 76 + <button id="save-btn" type="submit" class="btn p-1 w-full flex items-center gap-2 no-underline text-sm"> 77 + {{ i "check" "size-4" }} save 78 + </button> 79 + <a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline"> 80 + <button id="cancel-btn" type="button" class="btn p-1 w-full flex items-center gap-2 no-underline text-sm"> 81 + {{ i "x" "size-4" }} cancel 82 + </button> 83 + </a> 84 + </div> 85 + </form> 86 + {{ end }} 87 + 88 + {{ define "stat" }} 89 + {{ $id := index . 0 }} 90 + {{ $stat := index . 1 }} 91 + <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}}"> 92 + <option value="">choose stat</option> 93 + {{ $stats := assoc 94 + "merged-pull-request-count" "Merged PR Count" 95 + "closed-pull-request-count" "Closed PR Count" 96 + "open-pull-request-count" "Open PR Count" 97 + "open-issue-count" "Open Issue Count" 98 + "closed-issue-count" "Closed Issue Count" 99 + "repository-count" "Repository Count" 100 + }} 101 + {{ range $s := $stats }} 102 + {{ $value := index $s 0 }} 103 + {{ $label := index $s 1 }} 104 + <option value="{{ $value }}"{{ if eq $stat $value }} selected{{ end }}>{{ $label }}</option> 105 + {{ end }} 106 + </select> 107 + {{ end }}
+38
appview/pages/templates/user/fragments/editPins.html
··· 1 + {{ define "user/fragments/editPins" }} 2 + {{ $profile := .Profile }} 3 + <form 4 + hx-post="/{{ .LoggedInUser.Did }}/profile/pins" 5 + hx-disabled-elt="#save-btn,#cancel-btn" 6 + hx-swap="none"> 7 + <div class="flex items-center justify-between mb-2"> 8 + <p class="text-sm font-bold p-2 dark:text-white">SELECT PINNED REPOS</p> 9 + <div class="flex items-center gap-2"> 10 + <button id="save-btn" type="submit" class="btn px-2 flex items-center gap-2 no-underline text-sm"> 11 + {{ i "check" "w-3 h-3" }} save 12 + </button> 13 + <a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline"> 14 + <button id="cancel-btn" type="button" class="btn px-2 w-full flex items-center gap-2 no-underline text-sm"> 15 + {{ i "x" "w-3 h-3" }} cancel 16 + </button> 17 + </a> 18 + </div> 19 + </div> 20 + <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"> 21 + {{ range $idx, $r := .AllRepos }} 22 + <div class="flex items-center gap-2 text-base p-2 border-b border-gray-200 dark:border-gray-700"> 23 + <input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}> 24 + <label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full"> 25 + <div class="flex justify-between items-center w-full"> 26 + <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span> 27 + <div class="flex gap-1 items-center"> 28 + {{ i "star" "size-4 fill-current" }} 29 + <span>{{ .RepoStats.StarCount }}</span> 30 + </div> 31 + </div> 32 + </label> 33 + </div> 34 + {{ end }} 35 + </div> 36 + 37 + </form> 38 + {{ end }}
+142 -75
appview/pages/templates/user/profile.html
··· 1 1 {{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="grid grid-cols-1 md:grid-cols-5 gap-6"> 5 - <div class="md:col-span-1 order-1 md:order-1"> 4 + <div class="grid grid-cols-1 md:grid-cols-8 gap-6"> 5 + <div class="md:col-span-2 order-1 md:order-1"> 6 6 {{ block "profileCard" . }}{{ end }} 7 7 </div> 8 - <div class="md:col-span-2 order-2 md:order-2"> 8 + <div id="all-repos" class="md:col-span-3 order-2 md:order-2"> 9 9 {{ block "ownRepos" . }}{{ end }} 10 10 {{ block "collaboratingRepos" . }}{{ end }} 11 11 </div> 12 - <div class="md:col-span-2 order-3 md:order-3"> 12 + <div class="md:col-span-3 order-3 md:order-3"> 13 13 {{ block "profileTimeline" . }}{{ end }} 14 14 </div> 15 15 </div> 16 16 {{ end }} 17 17 18 18 {{ define "profileTimeline" }} 19 - <p class="text-sm font-bold py-2 dark:text-white px-6">ACTIVITY</p> 19 + <p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p> 20 20 <div class="flex flex-col gap-6 relative"> 21 21 {{ with .ProfileTimeline }} 22 22 {{ range $idx, $byMonth := .ByMonth }} ··· 233 233 <img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" /> 234 234 {{ end }} 235 235 </div> 236 - <div id="text" class="col-span-2 md:col-span-full"> 237 - <p 238 - title="{{ didOrHandle .UserDid .UserHandle }}" 239 - class="text-lg font-bold md:text-center dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 240 - {{ didOrHandle .UserDid .UserHandle }} 236 + <div class="col-span-2 md:col-span-full"> 237 + <p title="{{ didOrHandle .UserDid .UserHandle }}" 238 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 239 + {{ didOrHandle .UserDid .UserHandle }} 241 240 </p> 242 - <div class="text-sm md:text-center dark:text-gray-300"> 243 - <span id="followers">{{ .ProfileStats.Followers }} followers</span> 244 - <span class="px-1 select-none after:content-['·']"></span> 245 - <span id="following">{{ .ProfileStats.Following }} following</span> 246 - </div> 241 + <div id="profile-bio" class="text-sm"> 242 + {{ if .Profile }} 243 + <p>{{ .Profile.Description }}</p> 244 + {{ end }} 247 245 248 - {{ if ne .FollowStatus.String "IsSelf" }} 249 - {{ template "user/fragments/follow" . }} 250 - {{ end }} 246 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 247 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 248 + <span id="followers">{{ .ProfileStats.Followers }} followers</span> 249 + <span class="select-none after:content-['·']"></span> 250 + <span id="following">{{ .ProfileStats.Following }} following</span> 251 + </div> 252 + 253 + {{ $profile := .Profile }} 254 + {{ with .Profile }} 255 + <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 256 + {{ if .Location }} 257 + <div class="flex items-center gap-2"> 258 + <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 259 + <span>{{ .Location }}</span> 260 + </div> 261 + {{ end }} 262 + 263 + {{ if .IncludeBluesky }} 264 + <div class="flex items-center gap-2"> 265 + <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 266 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}"> 267 + bluesky/{{ didOrHandle $.UserDid $.UserHandle }} 268 + </a> 269 + </div> 270 + {{ end }} 271 + 272 + {{ range $link := .Links }} 273 + {{ if $link }} 274 + <div class="flex items-center gap-2"> 275 + <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 276 + <a href="{{ $link }}">{{ $link }}</a> 277 + </div> 278 + {{ end }} 279 + {{ end }} 280 + 281 + {{ if not $profile.IsStatsEmpty }} 282 + <div class="flex items-center justify-evenly gap-2 py-2"> 283 + {{ range $stat := .Stats }} 284 + {{ if $stat.Kind }} 285 + <div class="flex flex-col items-center gap-2"> 286 + <span class="text-xl font-bold">{{ $stat.Value }}</span> 287 + <span>{{ $stat.Kind.String }}</span> 288 + </div> 289 + {{ end }} 290 + {{ end }} 291 + </div> 292 + {{ end }} 293 + 294 + </div> 295 + {{ end }} 296 + 297 + {{ if ne .FollowStatus.String "IsSelf" }} 298 + {{ template "user/fragments/follow" . }} 299 + {{ else }} 300 + <button id="editBtn" 301 + class="btn mt-2 w-full flex items-center gap-2" 302 + hx-target="#profile-bio" 303 + hx-get="/{{ $.UserDid }}/profile/edit-bio" 304 + hx-swap="innerHTML"> 305 + {{ i "pencil" "w-4 h-4" }} 306 + edit profile 307 + </button> 308 + {{ end }} 309 + </div> 310 + <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 251 311 </div> 252 312 </div> 253 313 </div> 254 314 {{ end }} 255 315 256 316 {{ define "ownRepos" }} 257 - <p class="text-sm font-bold py-2 px-6 dark:text-white">REPOS</p> 258 - <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 259 - {{ range .Repos }} 260 - <div 261 - id="repo-card" 262 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800" 263 - > 264 - <div id="repo-card-name" class="font-medium dark:text-white"> 265 - <a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}" 266 - >{{ .Name }}</a 267 - > 317 + <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 318 + <span>PINNED REPOS</span> 319 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .UserDid) }} 320 + <button hx-get="/{{ $.UserDid }}/profile/edit-pins" hx-target="#all-repos" class="btn font-normal text-sm flex gap-2 items-center"> 321 + {{ i "pencil" "w-3 h-3" }} 322 + edit 323 + </button> 324 + {{ end }} 325 + </div> 326 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 327 + {{ range .Repos }} 328 + <div 329 + id="repo-card" 330 + class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 331 + <div id="repo-card-name" class="font-medium"> 332 + <a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}" 333 + >{{ .Name }}</a 334 + > 335 + </div> 336 + {{ if .Description }} 337 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 338 + {{ .Description }} 339 + </div> 340 + {{ end }} 341 + <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 342 + {{ if .RepoStats.StarCount }} 343 + <div class="flex gap-1 items-center text-sm"> 344 + {{ i "star" "w-3 h-3 fill-current" }} 345 + <span>{{ .RepoStats.StarCount }}</span> 268 346 </div> 269 - {{ if .Description }} 270 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 271 - {{ .Description }} 272 - </div> 273 - {{ end }} 274 - <div 275 - class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto" 276 - > 347 + {{ end }} 348 + </div> 349 + </div> 350 + {{ else }} 351 + <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 352 + {{ end }} 353 + </div> 354 + {{ end }} 277 355 278 - {{ if .RepoStats.StarCount }} 279 - <div class="flex gap-1 items-center text-sm"> 280 - {{ i "star" "w-3 h-3 fill-current" }} 281 - <span>{{ .RepoStats.StarCount }}</span> 282 - </div> 283 - {{ end }} 356 + {{ define "collaboratingRepos" }} 357 + {{ if gt (len .CollaboratingRepos) 0 }} 358 + <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 359 + <div id="collaborating" class="grid grid-cols-1 gap-4 mb-6"> 360 + {{ range .CollaboratingRepos }} 361 + <div 362 + id="repo-card" 363 + class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col"> 364 + <div id="repo-card-name" class="font-medium dark:text-white"> 365 + <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 366 + {{ index $.DidHandleMap .Did }}/{{ .Name }} 367 + </a> 368 + </div> 369 + {{ if .Description }} 370 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 371 + {{ .Description }} 284 372 </div> 285 - </div> 286 - {{ else }} 287 - <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 288 - {{ end }} 289 - </div> 373 + {{ end }} 374 + <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 290 375 291 - <p class="text-sm font-bold py-2 px-6 dark:text-white">COLLABORATING ON</p> 292 - <div id="collaborating" class="grid grid-cols-1 gap-4 mb-6"> 293 - {{ range .CollaboratingRepos }} 294 - <div 295 - id="repo-card" 296 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col" 297 - > 298 - <div id="repo-card-name" class="font-medium dark:text-white"> 299 - <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 300 - {{ index $.DidHandleMap .Did }}/{{ .Name }} 301 - </a> 302 - </div> 303 - {{ if .Description }} 304 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 305 - {{ .Description }} 376 + {{ if .RepoStats.StarCount }} 377 + <div class="flex gap-1 items-center text-sm"> 378 + {{ i "star" "w-3 h-3 fill-current" }} 379 + <span>{{ .RepoStats.StarCount }}</span> 306 380 </div> 307 381 {{ end }} 308 - <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 309 - 310 - {{ if .RepoStats.StarCount }} 311 - <div class="flex gap-1 items-center text-sm"> 312 - {{ i "star" "w-3 h-3 fill-current" }} 313 - <span>{{ .RepoStats.StarCount }}</span> 314 - </div> 315 - {{ end }} 316 - </div> 317 382 </div> 318 - {{ else }} 319 - <p class="px-6 dark:text-white">This user is not collaborating.</p> 320 - {{ end }} 383 + </div> 384 + {{ else }} 385 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 386 + {{ end }} 321 387 </div> 388 + {{ end }} 322 389 {{ end }}
+335 -2
appview/state/profile.go
··· 7 7 "fmt" 8 8 "log" 9 9 "net/http" 10 + "net/url" 11 + "slices" 12 + "strings" 10 13 14 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 15 "github.com/bluesky-social/indigo/atproto/identity" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + lexutil "github.com/bluesky-social/indigo/lex/util" 12 18 "github.com/go-chi/chi/v5" 19 + "tangled.sh/tangled.sh/core/api/tangled" 13 20 "tangled.sh/tangled.sh/core/appview/db" 14 21 "tangled.sh/tangled.sh/core/appview/pages" 15 22 ) ··· 27 34 return 28 35 } 29 36 37 + profile, err := db.GetProfile(s.db, ident.DID.String()) 38 + if err != nil { 39 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 40 + } 41 + 30 42 repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 31 43 if err != nil { 32 44 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 33 45 } 34 46 47 + // filter out ones that are pinned 48 + pinnedRepos := []db.Repo{} 49 + for i, r := range repos { 50 + // if this is a pinned repo, add it 51 + if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 52 + pinnedRepos = append(pinnedRepos, r) 53 + } 54 + 55 + // if there are no saved pins, add the first 4 repos 56 + if profile.IsPinnedReposEmpty() && i < 4 { 57 + pinnedRepos = append(pinnedRepos, r) 58 + } 59 + } 60 + 35 61 collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 36 62 if err != nil { 37 63 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 64 + } 65 + 66 + pinnedCollaboratingRepos := []db.Repo{} 67 + for _, r := range collaboratingRepos { 68 + // if this is a pinned repo, add it 69 + if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 70 + pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 71 + } 38 72 } 39 73 40 74 timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) ··· 87 121 LoggedInUser: loggedInUser, 88 122 UserDid: ident.DID.String(), 89 123 UserHandle: ident.Handle.String(), 90 - Repos: repos, 91 - CollaboratingRepos: collaboratingRepos, 124 + Repos: pinnedRepos, 125 + CollaboratingRepos: pinnedCollaboratingRepos, 92 126 ProfileStats: pages.ProfileStats{ 93 127 Followers: followers, 94 128 Following: following, 95 129 }, 130 + Profile: profile, 96 131 FollowStatus: db.FollowStatus(followStatus), 97 132 DidHandleMap: didHandleMap, 98 133 AvatarUri: profileAvatarUri, ··· 107 142 signature := hex.EncodeToString(h.Sum(nil)) 108 143 return fmt.Sprintf("%s/%s/%s", s.config.AvatarHost, signature, handle) 109 144 } 145 + 146 + func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 147 + user := s.auth.GetUser(r) 148 + 149 + err := r.ParseForm() 150 + if err != nil { 151 + log.Println("invalid profile update form", err) 152 + s.pages.Notice(w, "update-profile", "Invalid form.") 153 + return 154 + } 155 + 156 + profile, err := db.GetProfile(s.db, user.Did) 157 + if err != nil { 158 + log.Printf("getting profile data for %s: %s", user.Did, err) 159 + } 160 + 161 + profile.Description = r.FormValue("description") 162 + profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 163 + profile.Location = r.FormValue("location") 164 + 165 + var links [5]string 166 + for i := range 5 { 167 + iLink := r.FormValue(fmt.Sprintf("link%d", i)) 168 + links[i] = iLink 169 + } 170 + profile.Links = links 171 + 172 + // Parse stats (exactly 2) 173 + stat0 := r.FormValue("stat0") 174 + stat1 := r.FormValue("stat1") 175 + 176 + if stat0 != "" { 177 + profile.Stats[0].Kind = db.VanityStatKind(stat0) 178 + } 179 + 180 + if stat1 != "" { 181 + profile.Stats[1].Kind = db.VanityStatKind(stat1) 182 + } 183 + 184 + if err := s.validateProfile(profile); err != nil { 185 + log.Println("invalid profile", err) 186 + s.pages.Notice(w, "update-profile", err.Error()) 187 + return 188 + } 189 + 190 + s.updateProfile(profile, w, r) 191 + return 192 + } 193 + 194 + func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 195 + user := s.auth.GetUser(r) 196 + 197 + err := r.ParseForm() 198 + if err != nil { 199 + log.Println("invalid profile update form", err) 200 + s.pages.Notice(w, "update-profile", "Invalid form.") 201 + return 202 + } 203 + 204 + profile, err := db.GetProfile(s.db, user.Did) 205 + if err != nil { 206 + log.Printf("getting profile data for %s: %s", user.Did, err) 207 + } 208 + 209 + i := 0 210 + var pinnedRepos [6]syntax.ATURI 211 + for key, values := range r.Form { 212 + if i >= 6 { 213 + log.Println("invalid pin update form", err) 214 + s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") 215 + return 216 + } 217 + if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { 218 + aturi, err := syntax.ParseATURI(values[0]) 219 + if err != nil { 220 + log.Println("invalid profile update form", err) 221 + s.pages.Notice(w, "update-profile", "Invalid form.") 222 + return 223 + } 224 + pinnedRepos[i] = aturi 225 + i++ 226 + } 227 + } 228 + profile.PinnedRepos = pinnedRepos 229 + 230 + s.updateProfile(profile, w, r) 231 + return 232 + } 233 + 234 + func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 235 + user := s.auth.GetUser(r) 236 + tx, err := s.db.BeginTx(r.Context(), nil) 237 + if err != nil { 238 + log.Println("failed to start transaction", err) 239 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 240 + return 241 + } 242 + 243 + client, _ := s.auth.AuthorizedClient(r) 244 + 245 + // yeah... lexgen dose not support syntax.ATURI in the record for some reason, 246 + // nor does it support exact size arrays 247 + var pinnedRepoStrings []string 248 + for _, r := range profile.PinnedRepos { 249 + pinnedRepoStrings = append(pinnedRepoStrings, r.String()) 250 + } 251 + 252 + var vanityStats []string 253 + for _, v := range profile.Stats { 254 + vanityStats = append(vanityStats, string(v.Kind)) 255 + } 256 + 257 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 258 + var cid *string 259 + if ex != nil { 260 + cid = ex.Cid 261 + } 262 + 263 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 264 + Collection: tangled.ActorProfileNSID, 265 + Repo: user.Did, 266 + Rkey: "self", 267 + Record: &lexutil.LexiconTypeDecoder{ 268 + Val: &tangled.ActorProfile{ 269 + Bluesky: &profile.IncludeBluesky, 270 + Description: &profile.Description, 271 + Links: profile.Links[:], 272 + Location: &profile.Location, 273 + PinnedRepositories: pinnedRepoStrings, 274 + Stats: vanityStats[:], 275 + }}, 276 + SwapRecord: cid, 277 + }) 278 + if err != nil { 279 + log.Println("failed to update profile", err) 280 + s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 281 + return 282 + } 283 + 284 + err = db.UpsertProfile(tx, profile) 285 + if err != nil { 286 + log.Println("failed to update profile", err) 287 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 288 + return 289 + } 290 + 291 + s.pages.HxRedirect(w, "/"+user.Did) 292 + return 293 + } 294 + 295 + func (s *State) validateProfile(profile *db.Profile) error { 296 + // ensure description is not too long 297 + if len(profile.Description) > 256 { 298 + return fmt.Errorf("Entered bio is too long.") 299 + } 300 + 301 + // ensure description is not too long 302 + if len(profile.Location) > 40 { 303 + return fmt.Errorf("Entered location is too long.") 304 + } 305 + 306 + // ensure links are in order 307 + err := validateLinks(profile) 308 + if err != nil { 309 + return err 310 + } 311 + 312 + // ensure all pinned repos are either own repos or collaborating repos 313 + repos, err := db.GetAllReposByDid(s.db, profile.Did) 314 + if err != nil { 315 + log.Printf("getting repos for %s: %s", profile.Did, err) 316 + } 317 + 318 + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.Did) 319 + if err != nil { 320 + log.Printf("getting collaborating repos for %s: %s", profile.Did, err) 321 + } 322 + 323 + var validRepos []syntax.ATURI 324 + for _, r := range repos { 325 + validRepos = append(validRepos, r.RepoAt()) 326 + } 327 + for _, r := range collaboratingRepos { 328 + validRepos = append(validRepos, r.RepoAt()) 329 + } 330 + 331 + for _, pinned := range profile.PinnedRepos { 332 + if pinned == "" { 333 + continue 334 + } 335 + if !slices.Contains(validRepos, pinned) { 336 + return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned) 337 + } 338 + } 339 + 340 + return nil 341 + } 342 + 343 + func validateLinks(profile *db.Profile) error { 344 + for i, link := range profile.Links { 345 + if link == "" { 346 + continue 347 + } 348 + 349 + parsedURL, err := url.Parse(link) 350 + if err != nil { 351 + return fmt.Errorf("Invalid URL '%s': %v\n", link, err) 352 + } 353 + 354 + if parsedURL.Scheme == "" { 355 + if strings.HasPrefix(link, "//") { 356 + profile.Links[i] = "https:" + link 357 + } else { 358 + profile.Links[i] = "https://" + link 359 + } 360 + continue 361 + } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 362 + return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme) 363 + } 364 + 365 + // catch relative paths 366 + if parsedURL.Host == "" { 367 + return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link) 368 + } 369 + } 370 + return nil 371 + } 372 + 373 + func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 374 + user := s.auth.GetUser(r) 375 + 376 + profile, err := db.GetProfile(s.db, user.Did) 377 + if err != nil { 378 + log.Printf("getting profile data for %s: %s", user.Did, err) 379 + } 380 + 381 + s.pages.EditBioFragment(w, pages.EditBioParams{ 382 + LoggedInUser: user, 383 + Profile: profile, 384 + }) 385 + } 386 + 387 + func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 388 + user := s.auth.GetUser(r) 389 + 390 + profile, err := db.GetProfile(s.db, user.Did) 391 + if err != nil { 392 + log.Printf("getting profile data for %s: %s", user.Did, err) 393 + } 394 + 395 + repos, err := db.GetAllReposByDid(s.db, user.Did) 396 + if err != nil { 397 + log.Printf("getting repos for %s: %s", user.Did, err) 398 + } 399 + 400 + collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 401 + if err != nil { 402 + log.Printf("getting collaborating repos for %s: %s", user.Did, err) 403 + } 404 + 405 + allRepos := []pages.PinnedRepo{} 406 + 407 + for _, r := range repos { 408 + isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 409 + allRepos = append(allRepos, pages.PinnedRepo{ 410 + IsPinned: isPinned, 411 + Repo: r, 412 + }) 413 + } 414 + for _, r := range collaboratingRepos { 415 + isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 416 + allRepos = append(allRepos, pages.PinnedRepo{ 417 + IsPinned: isPinned, 418 + Repo: r, 419 + }) 420 + } 421 + 422 + var didsToResolve []string 423 + for _, r := range allRepos { 424 + didsToResolve = append(didsToResolve, r.Did) 425 + } 426 + resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 427 + didHandleMap := make(map[string]string) 428 + for _, identity := range resolvedIds { 429 + if !identity.Handle.IsInvalidHandle() { 430 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 431 + } else { 432 + didHandleMap[identity.DID.String()] = identity.DID.String() 433 + } 434 + } 435 + 436 + s.pages.EditPinsFragment(w, pages.EditPinsParams{ 437 + LoggedInUser: user, 438 + Profile: profile, 439 + AllRepos: allRepos, 440 + DidHandleMap: didHandleMap, 441 + }) 442 + }
+8
appview/state/router.go
··· 54 54 55 55 r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) { 56 56 r.Get("/", s.ProfilePage) 57 + r.Route("/profile", func(r chi.Router) { 58 + r.Use(middleware.AuthMiddleware(s.auth)) 59 + r.Get("/edit-bio", s.EditBioFragment) 60 + r.Get("/edit-pins", s.EditPinsFragment) 61 + r.Post("/bio", s.UpdateProfileBio) 62 + r.Post("/pins", s.UpdateProfilePins) 63 + }) 64 + 57 65 r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) { 58 66 r.Get("/", s.RepoIndex) 59 67 r.Get("/commits/{ref}", s.RepoLog)
+1
cmd/gen.go
··· 27 27 tangled.RepoPullStatus{}, 28 28 tangled.RepoPullComment{}, 29 29 tangled.RepoArtifact{}, 30 + tangled.ActorProfile{}, 30 31 ); err != nil { 31 32 panic(err) 32 33 }
+2 -1
flake.nix
··· 163 163 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 164 164 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 165 165 ''; 166 + CGO_ENABLED=1; 166 167 }; 167 168 }); 168 169 apps = forAllSystems (system: let ··· 171 172 pkgs.writeShellScriptBin "run" 172 173 '' 173 174 TANGLED_DEV=true ${pkgs.air}/bin/air -c /dev/null \ 174 - -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" \ 175 + -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 175 176 -build.bin "./out/${name}.out" \ 176 177 -build.stop_on_error "true" \ 177 178 -build.include_ext "go"
+72
lexicons/actor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.actor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A declaration of a Tangled account profile.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "properties": { 12 + "required": [ 13 + "bluesky", 14 + ], 15 + "description": { 16 + "type": "string", 17 + "description": "Free-form profile description text.", 18 + "maxGraphemes": 256, 19 + "maxLength": 2560 20 + }, 21 + "links": { 22 + "type": "array", 23 + "minLength": 0, 24 + "maxLength": 5, 25 + "items": { 26 + "type": "string", 27 + "description": "Any URI, intended for social profiles or websites, can be used to link DIDs/AT-URIs too.", 28 + "format": "uri" 29 + } 30 + }, 31 + "stats": { 32 + "type": "array", 33 + "minLength": 0, 34 + "maxLength": 2, 35 + "items": { 36 + "type": "string", 37 + "description": "Vanity stats.", 38 + "enum": [ 39 + "merged-pull-request-count", 40 + "closed-pull-request-count", 41 + "open-pull-request-count", 42 + "open-issue-count", 43 + "closed-issue-count", 44 + "repository-count" 45 + ] 46 + } 47 + }, 48 + "bluesky": { 49 + "type": "boolean", 50 + "description": "Include link to this account on Bluesky." 51 + }, 52 + "location": { 53 + "type": "string", 54 + "description": "Free-form location text.", 55 + "maxGraphemes": 40, 56 + "maxLength": 400 57 + }, 58 + "pinnedRepositories": { 59 + "type": "array", 60 + "description": "Any ATURI, it is up to appviews to validate these fields.", 61 + "minLength": 0, 62 + "maxLength": 6, 63 + "items": { 64 + "type": "string", 65 + "format": "at-uri" 66 + } 67 + } 68 + } 69 + } 70 + } 71 + } 72 + }