+31
api/tangled/actorprofile.go
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+1
cmd/gen.go
+2
-1
flake.nix
+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
+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
+
}