+6
.tangled/workflows/test.yml
+6
.tangled/workflows/test.yml
+10
api/tangled/repotree.go
+10
api/tangled/repotree.go
···
31
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32
// parent: The parent path in the tree
33
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34
// ref: The git reference used
35
Ref string `json:"ref" cborgen:"ref"`
36
}
37
38
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
···
31
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32
// parent: The parent path in the tree
33
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34
+
// readme: Readme for this file tree
35
+
Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"`
36
// ref: The git reference used
37
Ref string `json:"ref" cborgen:"ref"`
38
+
}
39
+
40
+
// RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema.
41
+
type RepoTree_Readme struct {
42
+
// contents: Contents of the readme file
43
+
Contents string `json:"contents" cborgen:"contents"`
44
+
// filename: Name of the readme file
45
+
Filename string `json:"filename" cborgen:"filename"`
46
}
47
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+4
-2
appview/config/config.go
+4
-2
appview/config/config.go
+172
-10
appview/db/db.go
+172
-10
appview/db/db.go
···
527
-- label to subscribe to
528
label_at text not null,
529
530
-
unique (repo_at, label_at),
531
-
foreign key (label_at) references label_definitions (at_uri)
532
);
533
534
create table if not exists migrations (
···
536
name text unique
537
);
538
539
-
-- indexes for better star query performance
540
create index if not exists idx_stars_created on stars(created);
541
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
542
`)
···
788
_, err := tx.Exec(`
789
alter table spindles add column needs_upgrade integer not null default 0;
790
`)
791
-
if err != nil {
792
-
return err
793
-
}
794
-
795
-
_, err = tx.Exec(`
796
-
update spindles set needs_upgrade = 1;
797
-
`)
798
return err
799
})
800
···
931
_, err = tx.Exec(`drop table comments`)
932
return err
933
})
934
935
return &DB{db}, nil
936
}
···
527
-- label to subscribe to
528
label_at text not null,
529
530
+
unique (repo_at, label_at)
531
+
);
532
+
533
+
create table if not exists notifications (
534
+
id integer primary key autoincrement,
535
+
recipient_did text not null,
536
+
actor_did text not null,
537
+
type text not null,
538
+
entity_type text not null,
539
+
entity_id text not null,
540
+
read integer not null default 0,
541
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
542
+
repo_id integer references repos(id),
543
+
issue_id integer references issues(id),
544
+
pull_id integer references pulls(id)
545
+
);
546
+
547
+
create table if not exists notification_preferences (
548
+
id integer primary key autoincrement,
549
+
user_did text not null unique,
550
+
repo_starred integer not null default 1,
551
+
issue_created integer not null default 1,
552
+
issue_commented integer not null default 1,
553
+
pull_created integer not null default 1,
554
+
pull_commented integer not null default 1,
555
+
followed integer not null default 1,
556
+
pull_merged integer not null default 1,
557
+
issue_closed integer not null default 1,
558
+
email_notifications integer not null default 0
559
);
560
561
create table if not exists migrations (
···
563
name text unique
564
);
565
566
+
-- indexes for better performance
567
+
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
568
+
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
569
create index if not exists idx_stars_created on stars(created);
570
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
571
`)
···
817
_, err := tx.Exec(`
818
alter table spindles add column needs_upgrade integer not null default 0;
819
`)
820
return err
821
})
822
···
953
_, err = tx.Exec(`drop table comments`)
954
return err
955
})
956
+
957
+
// add generated at_uri column to pulls table
958
+
//
959
+
// this requires a full table recreation because stored columns
960
+
// cannot be added via alter
961
+
//
962
+
// disable foreign-keys for the next migration
963
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
964
+
runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
965
+
_, err := tx.Exec(`
966
+
create table if not exists pulls_new (
967
+
-- identifiers
968
+
id integer primary key autoincrement,
969
+
pull_id integer not null,
970
+
at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
971
+
972
+
-- at identifiers
973
+
repo_at text not null,
974
+
owner_did text not null,
975
+
rkey text not null,
976
+
977
+
-- content
978
+
title text not null,
979
+
body text not null,
980
+
target_branch text not null,
981
+
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
982
+
983
+
-- source info
984
+
source_branch text,
985
+
source_repo_at text,
986
+
987
+
-- stacking
988
+
stack_id text,
989
+
change_id text,
990
+
parent_change_id text,
991
+
992
+
-- meta
993
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
994
+
995
+
-- constraints
996
+
unique(repo_at, pull_id),
997
+
unique(at_uri),
998
+
foreign key (repo_at) references repos(at_uri) on delete cascade
999
+
);
1000
+
`)
1001
+
if err != nil {
1002
+
return err
1003
+
}
1004
+
1005
+
// transfer data
1006
+
_, err = tx.Exec(`
1007
+
insert into pulls_new (
1008
+
id, pull_id, repo_at, owner_did, rkey,
1009
+
title, body, target_branch, state,
1010
+
source_branch, source_repo_at,
1011
+
stack_id, change_id, parent_change_id,
1012
+
created
1013
+
)
1014
+
select
1015
+
id, pull_id, repo_at, owner_did, rkey,
1016
+
title, body, target_branch, state,
1017
+
source_branch, source_repo_at,
1018
+
stack_id, change_id, parent_change_id,
1019
+
created
1020
+
from pulls;
1021
+
`)
1022
+
if err != nil {
1023
+
return err
1024
+
}
1025
+
1026
+
// drop old table
1027
+
_, err = tx.Exec(`drop table pulls`)
1028
+
if err != nil {
1029
+
return err
1030
+
}
1031
+
1032
+
// rename new table
1033
+
_, err = tx.Exec(`alter table pulls_new rename to pulls`)
1034
+
return err
1035
+
})
1036
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1037
+
1038
+
// remove repo_at and pull_id from pull_submissions and replace with pull_at
1039
+
//
1040
+
// this requires a full table recreation because stored columns
1041
+
// cannot be added via alter
1042
+
//
1043
+
// disable foreign-keys for the next migration
1044
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1045
+
runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1046
+
_, err := tx.Exec(`
1047
+
create table if not exists pull_submissions_new (
1048
+
-- identifiers
1049
+
id integer primary key autoincrement,
1050
+
pull_at text not null,
1051
+
1052
+
-- content, these are immutable, and require a resubmission to update
1053
+
round_number integer not null default 0,
1054
+
patch text,
1055
+
source_rev text,
1056
+
1057
+
-- meta
1058
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1059
+
1060
+
-- constraints
1061
+
unique(pull_at, round_number),
1062
+
foreign key (pull_at) references pulls(at_uri) on delete cascade
1063
+
);
1064
+
`)
1065
+
if err != nil {
1066
+
return err
1067
+
}
1068
+
1069
+
// transfer data, constructing pull_at from pulls table
1070
+
_, err = tx.Exec(`
1071
+
insert into pull_submissions_new (id, pull_at, round_number, patch, created)
1072
+
select
1073
+
ps.id,
1074
+
'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
1075
+
ps.round_number,
1076
+
ps.patch,
1077
+
ps.created
1078
+
from pull_submissions ps
1079
+
join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
1080
+
`)
1081
+
if err != nil {
1082
+
return err
1083
+
}
1084
+
1085
+
// drop old table
1086
+
_, err = tx.Exec(`drop table pull_submissions`)
1087
+
if err != nil {
1088
+
return err
1089
+
}
1090
+
1091
+
// rename new table
1092
+
_, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
1093
+
return err
1094
+
})
1095
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1096
1097
return &DB{db}, nil
1098
}
+13
-9
appview/db/email.go
+13
-9
appview/db/email.go
···
71
return did, nil
72
}
73
74
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
75
-
if len(ems) == 0 {
76
return make(map[string]string), nil
77
}
78
···
80
if isVerifiedFilter {
81
verifiedFilter = 1
82
}
83
84
// Create placeholders for the IN clause
85
-
placeholders := make([]string, len(ems))
86
-
args := make([]any, len(ems)+1)
87
88
args[0] = verifiedFilter
89
-
for i, em := range ems {
90
-
placeholders[i] = "?"
91
-
args[i+1] = em
92
}
93
94
query := `
···
104
return nil, err
105
}
106
defer rows.Close()
107
-
108
-
assoc := make(map[string]string)
109
110
for rows.Next() {
111
var email, did string
···
71
return did, nil
72
}
73
74
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
75
+
if len(emails) == 0 {
76
return make(map[string]string), nil
77
}
78
···
80
if isVerifiedFilter {
81
verifiedFilter = 1
82
}
83
+
84
+
assoc := make(map[string]string)
85
86
// Create placeholders for the IN clause
87
+
placeholders := make([]string, 0, len(emails))
88
+
args := make([]any, 1, len(emails)+1)
89
90
args[0] = verifiedFilter
91
+
for _, email := range emails {
92
+
if strings.HasPrefix(email, "did:") {
93
+
assoc[email] = email
94
+
continue
95
+
}
96
+
placeholders = append(placeholders, "?")
97
+
args = append(args, email)
98
}
99
100
query := `
···
110
return nil, err
111
}
112
defer rows.Close()
113
114
for rows.Next() {
115
var email, did string
+1
-1
appview/db/label.go
+1
-1
appview/db/label.go
+34
appview/db/language.go
+34
appview/db/language.go
···
1
package db
2
3
import (
4
+
"database/sql"
5
"fmt"
6
"strings"
7
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
"tangled.org/core/appview/models"
10
)
11
···
84
85
return nil
86
}
87
+
88
+
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
+
var conditions []string
90
+
var args []any
91
+
for _, filter := range filters {
92
+
conditions = append(conditions, filter.Condition())
93
+
args = append(args, filter.Arg()...)
94
+
}
95
+
96
+
whereClause := ""
97
+
if conditions != nil {
98
+
whereClause = " where " + strings.Join(conditions, " and ")
99
+
}
100
+
101
+
query := fmt.Sprintf(`delete from repo_languages %s`, whereClause)
102
+
103
+
_, err := e.Exec(query, args...)
104
+
return err
105
+
}
106
+
107
+
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
+
err := DeleteRepoLanguages(
109
+
tx,
110
+
FilterEq("repo_at", repoAt),
111
+
FilterEq("ref", ref),
112
+
)
113
+
if err != nil {
114
+
return fmt.Errorf("failed to delete existing languages: %w", err)
115
+
}
116
+
117
+
return InsertRepoLanguages(tx, langs)
118
+
}
+450
appview/db/notifications.go
+450
appview/db/notifications.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"strings"
9
+
"time"
10
+
11
+
"tangled.org/core/appview/models"
12
+
"tangled.org/core/appview/pagination"
13
+
)
14
+
15
+
func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error {
16
+
query := `
17
+
INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
18
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
19
+
`
20
+
21
+
result, err := d.DB.ExecContext(ctx, query,
22
+
notification.RecipientDid,
23
+
notification.ActorDid,
24
+
string(notification.Type),
25
+
notification.EntityType,
26
+
notification.EntityId,
27
+
notification.Read,
28
+
notification.RepoId,
29
+
notification.IssueId,
30
+
notification.PullId,
31
+
)
32
+
if err != nil {
33
+
return fmt.Errorf("failed to create notification: %w", err)
34
+
}
35
+
36
+
id, err := result.LastInsertId()
37
+
if err != nil {
38
+
return fmt.Errorf("failed to get notification ID: %w", err)
39
+
}
40
+
41
+
notification.ID = id
42
+
return nil
43
+
}
44
+
45
+
// GetNotificationsPaginated retrieves notifications with filters and pagination
46
+
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) {
47
+
var conditions []string
48
+
var args []any
49
+
50
+
for _, filter := range filters {
51
+
conditions = append(conditions, filter.Condition())
52
+
args = append(args, filter.Arg()...)
53
+
}
54
+
55
+
whereClause := ""
56
+
if len(conditions) > 0 {
57
+
whereClause = "WHERE " + conditions[0]
58
+
for _, condition := range conditions[1:] {
59
+
whereClause += " AND " + condition
60
+
}
61
+
}
62
+
63
+
query := fmt.Sprintf(`
64
+
select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id
65
+
from notifications
66
+
%s
67
+
order by created desc
68
+
limit ? offset ?
69
+
`, whereClause)
70
+
71
+
args = append(args, page.Limit, page.Offset)
72
+
73
+
rows, err := e.QueryContext(context.Background(), query, args...)
74
+
if err != nil {
75
+
return nil, fmt.Errorf("failed to query notifications: %w", err)
76
+
}
77
+
defer rows.Close()
78
+
79
+
var notifications []*models.Notification
80
+
for rows.Next() {
81
+
var n models.Notification
82
+
var typeStr string
83
+
var createdStr string
84
+
err := rows.Scan(
85
+
&n.ID,
86
+
&n.RecipientDid,
87
+
&n.ActorDid,
88
+
&typeStr,
89
+
&n.EntityType,
90
+
&n.EntityId,
91
+
&n.Read,
92
+
&createdStr,
93
+
&n.RepoId,
94
+
&n.IssueId,
95
+
&n.PullId,
96
+
)
97
+
if err != nil {
98
+
return nil, fmt.Errorf("failed to scan notification: %w", err)
99
+
}
100
+
n.Type = models.NotificationType(typeStr)
101
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
102
+
if err != nil {
103
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
104
+
}
105
+
notifications = append(notifications, &n)
106
+
}
107
+
108
+
return notifications, nil
109
+
}
110
+
111
+
// GetNotificationsWithEntities retrieves notifications with their related entities
112
+
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) {
113
+
var conditions []string
114
+
var args []any
115
+
116
+
for _, filter := range filters {
117
+
conditions = append(conditions, filter.Condition())
118
+
args = append(args, filter.Arg()...)
119
+
}
120
+
121
+
whereClause := ""
122
+
if len(conditions) > 0 {
123
+
whereClause = "WHERE " + conditions[0]
124
+
for _, condition := range conditions[1:] {
125
+
whereClause += " AND " + condition
126
+
}
127
+
}
128
+
129
+
query := fmt.Sprintf(`
130
+
select
131
+
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
132
+
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
133
+
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
134
+
i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open,
135
+
p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state
136
+
from notifications n
137
+
left join repos r on n.repo_id = r.id
138
+
left join issues i on n.issue_id = i.id
139
+
left join pulls p on n.pull_id = p.id
140
+
%s
141
+
order by n.created desc
142
+
limit ? offset ?
143
+
`, whereClause)
144
+
145
+
args = append(args, page.Limit, page.Offset)
146
+
147
+
rows, err := e.QueryContext(context.Background(), query, args...)
148
+
if err != nil {
149
+
return nil, fmt.Errorf("failed to query notifications with entities: %w", err)
150
+
}
151
+
defer rows.Close()
152
+
153
+
var notifications []*models.NotificationWithEntity
154
+
for rows.Next() {
155
+
var n models.Notification
156
+
var typeStr string
157
+
var createdStr string
158
+
var repo models.Repo
159
+
var issue models.Issue
160
+
var pull models.Pull
161
+
var rId, iId, pId sql.NullInt64
162
+
var rDid, rName, rDescription sql.NullString
163
+
var iDid sql.NullString
164
+
var iIssueId sql.NullInt64
165
+
var iTitle sql.NullString
166
+
var iOpen sql.NullBool
167
+
var pOwnerDid sql.NullString
168
+
var pPullId sql.NullInt64
169
+
var pTitle sql.NullString
170
+
var pState sql.NullInt64
171
+
172
+
err := rows.Scan(
173
+
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
174
+
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
175
+
&rId, &rDid, &rName, &rDescription,
176
+
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
177
+
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
178
+
)
179
+
if err != nil {
180
+
return nil, fmt.Errorf("failed to scan notification with entities: %w", err)
181
+
}
182
+
183
+
n.Type = models.NotificationType(typeStr)
184
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
185
+
if err != nil {
186
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
187
+
}
188
+
189
+
nwe := &models.NotificationWithEntity{Notification: &n}
190
+
191
+
// populate repo if present
192
+
if rId.Valid {
193
+
repo.Id = rId.Int64
194
+
if rDid.Valid {
195
+
repo.Did = rDid.String
196
+
}
197
+
if rName.Valid {
198
+
repo.Name = rName.String
199
+
}
200
+
if rDescription.Valid {
201
+
repo.Description = rDescription.String
202
+
}
203
+
nwe.Repo = &repo
204
+
}
205
+
206
+
// populate issue if present
207
+
if iId.Valid {
208
+
issue.Id = iId.Int64
209
+
if iDid.Valid {
210
+
issue.Did = iDid.String
211
+
}
212
+
if iIssueId.Valid {
213
+
issue.IssueId = int(iIssueId.Int64)
214
+
}
215
+
if iTitle.Valid {
216
+
issue.Title = iTitle.String
217
+
}
218
+
if iOpen.Valid {
219
+
issue.Open = iOpen.Bool
220
+
}
221
+
nwe.Issue = &issue
222
+
}
223
+
224
+
// populate pull if present
225
+
if pId.Valid {
226
+
pull.ID = int(pId.Int64)
227
+
if pOwnerDid.Valid {
228
+
pull.OwnerDid = pOwnerDid.String
229
+
}
230
+
if pPullId.Valid {
231
+
pull.PullId = int(pPullId.Int64)
232
+
}
233
+
if pTitle.Valid {
234
+
pull.Title = pTitle.String
235
+
}
236
+
if pState.Valid {
237
+
pull.State = models.PullState(pState.Int64)
238
+
}
239
+
nwe.Pull = &pull
240
+
}
241
+
242
+
notifications = append(notifications, nwe)
243
+
}
244
+
245
+
return notifications, nil
246
+
}
247
+
248
+
// GetNotifications retrieves notifications with filters
249
+
func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) {
250
+
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
251
+
}
252
+
253
+
func CountNotifications(e Execer, filters ...filter) (int64, error) {
254
+
var conditions []string
255
+
var args []any
256
+
for _, filter := range filters {
257
+
conditions = append(conditions, filter.Condition())
258
+
args = append(args, filter.Arg()...)
259
+
}
260
+
261
+
whereClause := ""
262
+
if conditions != nil {
263
+
whereClause = " where " + strings.Join(conditions, " and ")
264
+
}
265
+
266
+
query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause)
267
+
var count int64
268
+
err := e.QueryRow(query, args...).Scan(&count)
269
+
270
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
271
+
return 0, err
272
+
}
273
+
274
+
return count, nil
275
+
}
276
+
277
+
func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error {
278
+
idFilter := FilterEq("id", notificationID)
279
+
recipientFilter := FilterEq("recipient_did", userDID)
280
+
281
+
query := fmt.Sprintf(`
282
+
UPDATE notifications
283
+
SET read = 1
284
+
WHERE %s AND %s
285
+
`, idFilter.Condition(), recipientFilter.Condition())
286
+
287
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
288
+
289
+
result, err := d.DB.ExecContext(ctx, query, args...)
290
+
if err != nil {
291
+
return fmt.Errorf("failed to mark notification as read: %w", err)
292
+
}
293
+
294
+
rowsAffected, err := result.RowsAffected()
295
+
if err != nil {
296
+
return fmt.Errorf("failed to get rows affected: %w", err)
297
+
}
298
+
299
+
if rowsAffected == 0 {
300
+
return fmt.Errorf("notification not found or access denied")
301
+
}
302
+
303
+
return nil
304
+
}
305
+
306
+
func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error {
307
+
recipientFilter := FilterEq("recipient_did", userDID)
308
+
readFilter := FilterEq("read", 0)
309
+
310
+
query := fmt.Sprintf(`
311
+
UPDATE notifications
312
+
SET read = 1
313
+
WHERE %s AND %s
314
+
`, recipientFilter.Condition(), readFilter.Condition())
315
+
316
+
args := append(recipientFilter.Arg(), readFilter.Arg()...)
317
+
318
+
_, err := d.DB.ExecContext(ctx, query, args...)
319
+
if err != nil {
320
+
return fmt.Errorf("failed to mark all notifications as read: %w", err)
321
+
}
322
+
323
+
return nil
324
+
}
325
+
326
+
func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error {
327
+
idFilter := FilterEq("id", notificationID)
328
+
recipientFilter := FilterEq("recipient_did", userDID)
329
+
330
+
query := fmt.Sprintf(`
331
+
DELETE FROM notifications
332
+
WHERE %s AND %s
333
+
`, idFilter.Condition(), recipientFilter.Condition())
334
+
335
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
336
+
337
+
result, err := d.DB.ExecContext(ctx, query, args...)
338
+
if err != nil {
339
+
return fmt.Errorf("failed to delete notification: %w", err)
340
+
}
341
+
342
+
rowsAffected, err := result.RowsAffected()
343
+
if err != nil {
344
+
return fmt.Errorf("failed to get rows affected: %w", err)
345
+
}
346
+
347
+
if rowsAffected == 0 {
348
+
return fmt.Errorf("notification not found or access denied")
349
+
}
350
+
351
+
return nil
352
+
}
353
+
354
+
func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) {
355
+
userFilter := FilterEq("user_did", userDID)
356
+
357
+
query := fmt.Sprintf(`
358
+
SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created,
359
+
pull_commented, followed, pull_merged, issue_closed, email_notifications
360
+
FROM notification_preferences
361
+
WHERE %s
362
+
`, userFilter.Condition())
363
+
364
+
var prefs models.NotificationPreferences
365
+
err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan(
366
+
&prefs.ID,
367
+
&prefs.UserDid,
368
+
&prefs.RepoStarred,
369
+
&prefs.IssueCreated,
370
+
&prefs.IssueCommented,
371
+
&prefs.PullCreated,
372
+
&prefs.PullCommented,
373
+
&prefs.Followed,
374
+
&prefs.PullMerged,
375
+
&prefs.IssueClosed,
376
+
&prefs.EmailNotifications,
377
+
)
378
+
379
+
if err != nil {
380
+
if err == sql.ErrNoRows {
381
+
return &models.NotificationPreferences{
382
+
UserDid: userDID,
383
+
RepoStarred: true,
384
+
IssueCreated: true,
385
+
IssueCommented: true,
386
+
PullCreated: true,
387
+
PullCommented: true,
388
+
Followed: true,
389
+
PullMerged: true,
390
+
IssueClosed: true,
391
+
EmailNotifications: false,
392
+
}, nil
393
+
}
394
+
return nil, fmt.Errorf("failed to get notification preferences: %w", err)
395
+
}
396
+
397
+
return &prefs, nil
398
+
}
399
+
400
+
func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
401
+
query := `
402
+
INSERT OR REPLACE INTO notification_preferences
403
+
(user_did, repo_starred, issue_created, issue_commented, pull_created,
404
+
pull_commented, followed, pull_merged, issue_closed, email_notifications)
405
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
406
+
`
407
+
408
+
result, err := d.DB.ExecContext(ctx, query,
409
+
prefs.UserDid,
410
+
prefs.RepoStarred,
411
+
prefs.IssueCreated,
412
+
prefs.IssueCommented,
413
+
prefs.PullCreated,
414
+
prefs.PullCommented,
415
+
prefs.Followed,
416
+
prefs.PullMerged,
417
+
prefs.IssueClosed,
418
+
prefs.EmailNotifications,
419
+
)
420
+
if err != nil {
421
+
return fmt.Errorf("failed to update notification preferences: %w", err)
422
+
}
423
+
424
+
if prefs.ID == 0 {
425
+
id, err := result.LastInsertId()
426
+
if err != nil {
427
+
return fmt.Errorf("failed to get preferences ID: %w", err)
428
+
}
429
+
prefs.ID = id
430
+
}
431
+
432
+
return nil
433
+
}
434
+
435
+
func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
436
+
cutoff := time.Now().Add(-olderThan)
437
+
createdFilter := FilterLte("created", cutoff)
438
+
439
+
query := fmt.Sprintf(`
440
+
DELETE FROM notifications
441
+
WHERE %s
442
+
`, createdFilter.Condition())
443
+
444
+
_, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...)
445
+
if err != nil {
446
+
return fmt.Errorf("failed to cleanup old notifications: %w", err)
447
+
}
448
+
449
+
return nil
450
+
}
+155
-228
appview/db/pulls.go
+155
-228
appview/db/pulls.go
···
1
package db
2
3
import (
4
"database/sql"
5
"fmt"
6
-
"log"
7
"sort"
8
"strings"
9
"time"
···
55
parentChangeId = &pull.ParentChangeId
56
}
57
58
-
_, err = tx.Exec(
59
`
60
insert into pulls (
61
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id
···
79
return err
80
}
81
82
_, err = tx.Exec(`
83
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
84
-
values (?, ?, ?, ?, ?)
85
-
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
86
return err
87
}
88
···
101
}
102
103
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
104
-
pulls := make(map[int]*models.Pull)
105
106
var conditions []string
107
var args []any
···
121
122
query := fmt.Sprintf(`
123
select
124
owner_did,
125
repo_at,
126
pull_id,
···
154
var createdAt string
155
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
156
err := rows.Scan(
157
&pull.OwnerDid,
158
&pull.RepoAt,
159
&pull.PullId,
···
202
pull.ParentChangeId = parentChangeId.String
203
}
204
205
-
pulls[pull.PullId] = &pull
206
}
207
208
-
// get latest round no. for each pull
209
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
210
-
submissionsQuery := fmt.Sprintf(`
211
-
select
212
-
id, pull_id, round_number, patch, created, source_rev
213
-
from
214
-
pull_submissions
215
-
where
216
-
repo_at in (%s) and pull_id in (%s)
217
-
`, inClause, inClause)
218
-
219
-
args = make([]any, len(pulls)*2)
220
-
idx := 0
221
-
for _, p := range pulls {
222
-
args[idx] = p.RepoAt
223
-
idx += 1
224
-
}
225
for _, p := range pulls {
226
-
args[idx] = p.PullId
227
-
idx += 1
228
}
229
-
submissionsRows, err := e.Query(submissionsQuery, args...)
230
if err != nil {
231
-
return nil, err
232
}
233
-
defer submissionsRows.Close()
234
235
-
for submissionsRows.Next() {
236
-
var s models.PullSubmission
237
-
var sourceRev sql.NullString
238
-
var createdAt string
239
-
err := submissionsRows.Scan(
240
-
&s.ID,
241
-
&s.PullId,
242
-
&s.RoundNumber,
243
-
&s.Patch,
244
-
&createdAt,
245
-
&sourceRev,
246
-
)
247
-
if err != nil {
248
-
return nil, err
249
-
}
250
-
251
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
252
-
if err != nil {
253
-
return nil, err
254
-
}
255
-
s.Created = createdTime
256
-
257
-
if sourceRev.Valid {
258
-
s.SourceRev = sourceRev.String
259
}
260
261
-
if p, ok := pulls[s.PullId]; ok {
262
-
p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1)
263
-
p.Submissions[s.RoundNumber] = &s
264
-
}
265
}
266
-
if err := rows.Err(); err != nil {
267
-
return nil, err
268
}
269
270
-
// get comment count on latest submission on each pull
271
-
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
272
-
commentsQuery := fmt.Sprintf(`
273
-
select
274
-
count(id), pull_id
275
-
from
276
-
pull_comments
277
-
where
278
-
submission_id in (%s)
279
-
group by
280
-
submission_id
281
-
`, inClause)
282
-
283
-
args = []any{}
284
for _, p := range pulls {
285
-
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
286
}
287
-
commentsRows, err := e.Query(commentsQuery, args...)
288
-
if err != nil {
289
-
return nil, err
290
}
291
-
defer commentsRows.Close()
292
-
293
-
for commentsRows.Next() {
294
-
var commentCount, pullId int
295
-
err := commentsRows.Scan(
296
-
&commentCount,
297
-
&pullId,
298
-
)
299
-
if err != nil {
300
-
return nil, err
301
-
}
302
-
if p, ok := pulls[pullId]; ok {
303
-
p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount)
304
-
}
305
}
306
-
if err := rows.Err(); err != nil {
307
-
return nil, err
308
}
309
310
orderedByPullId := []*models.Pull{}
···
323
}
324
325
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
326
-
query := `
327
-
select
328
-
owner_did,
329
-
pull_id,
330
-
created,
331
-
title,
332
-
state,
333
-
target_branch,
334
-
repo_at,
335
-
body,
336
-
rkey,
337
-
source_branch,
338
-
source_repo_at,
339
-
stack_id,
340
-
change_id,
341
-
parent_change_id
342
-
from
343
-
pulls
344
-
where
345
-
repo_at = ? and pull_id = ?
346
-
`
347
-
row := e.QueryRow(query, repoAt, pullId)
348
-
349
-
var pull models.Pull
350
-
var createdAt string
351
-
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
352
-
err := row.Scan(
353
-
&pull.OwnerDid,
354
-
&pull.PullId,
355
-
&createdAt,
356
-
&pull.Title,
357
-
&pull.State,
358
-
&pull.TargetBranch,
359
-
&pull.RepoAt,
360
-
&pull.Body,
361
-
&pull.Rkey,
362
-
&sourceBranch,
363
-
&sourceRepoAt,
364
-
&stackId,
365
-
&changeId,
366
-
&parentChangeId,
367
-
)
368
if err != nil {
369
return nil, err
370
}
371
372
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
373
-
if err != nil {
374
-
return nil, err
375
-
}
376
-
pull.Created = createdTime
377
378
-
// populate source
379
-
if sourceBranch.Valid {
380
-
pull.PullSource = &models.PullSource{
381
-
Branch: sourceBranch.String,
382
-
}
383
-
if sourceRepoAt.Valid {
384
-
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
385
-
if err != nil {
386
-
return nil, err
387
-
}
388
-
pull.PullSource.RepoAt = &sourceRepoAtParsed
389
-
}
390
}
391
392
-
if stackId.Valid {
393
-
pull.StackId = stackId.String
394
-
}
395
-
if changeId.Valid {
396
-
pull.ChangeId = changeId.String
397
-
}
398
-
if parentChangeId.Valid {
399
-
pull.ParentChangeId = parentChangeId.String
400
}
401
402
-
submissionsQuery := `
403
select
404
-
id, pull_id, repo_at, round_number, patch, created, source_rev
405
from
406
pull_submissions
407
-
where
408
-
repo_at = ? and pull_id = ?
409
-
`
410
-
submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId)
411
if err != nil {
412
return nil, err
413
}
414
-
defer submissionsRows.Close()
415
416
-
submissionsMap := make(map[int]*models.PullSubmission)
417
418
-
for submissionsRows.Next() {
419
var submission models.PullSubmission
420
-
var submissionCreatedStr string
421
-
var submissionSourceRev sql.NullString
422
-
err := submissionsRows.Scan(
423
&submission.ID,
424
-
&submission.PullId,
425
-
&submission.RepoAt,
426
&submission.RoundNumber,
427
&submission.Patch,
428
-
&submissionCreatedStr,
429
-
&submissionSourceRev,
430
)
431
if err != nil {
432
return nil, err
433
}
434
435
-
submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
436
if err != nil {
437
return nil, err
438
}
439
-
submission.Created = submissionCreatedTime
440
441
-
if submissionSourceRev.Valid {
442
-
submission.SourceRev = submissionSourceRev.String
443
}
444
445
-
submissionsMap[submission.ID] = &submission
446
}
447
-
if err = submissionsRows.Close(); err != nil {
448
return nil, err
449
}
450
-
if len(submissionsMap) == 0 {
451
-
return &pull, nil
452
}
453
454
var args []any
455
-
for k := range submissionsMap {
456
-
args = append(args, k)
457
}
458
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
459
-
commentsQuery := fmt.Sprintf(`
460
select
461
id,
462
pull_id,
···
468
created
469
from
470
pull_comments
471
-
where
472
-
submission_id IN (%s)
473
order by
474
created asc
475
-
`, inClause)
476
-
commentsRows, err := e.Query(commentsQuery, args...)
477
if err != nil {
478
return nil, err
479
}
480
-
defer commentsRows.Close()
481
482
-
for commentsRows.Next() {
483
var comment models.PullComment
484
-
var commentCreatedStr string
485
-
err := commentsRows.Scan(
486
&comment.ID,
487
&comment.PullId,
488
&comment.SubmissionId,
···
490
&comment.OwnerDid,
491
&comment.CommentAt,
492
&comment.Body,
493
-
&commentCreatedStr,
494
)
495
if err != nil {
496
return nil, err
497
}
498
499
-
commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
500
-
if err != nil {
501
-
return nil, err
502
}
503
-
comment.Created = commentCreatedTime
504
505
-
// Add the comment to its submission
506
-
if submission, ok := submissionsMap[comment.SubmissionId]; ok {
507
-
submission.Comments = append(submission.Comments, comment)
508
-
}
509
-
510
-
}
511
-
if err = commentsRows.Err(); err != nil {
512
-
return nil, err
513
-
}
514
-
515
-
var pullSourceRepo *models.Repo
516
-
if pull.PullSource != nil {
517
-
if pull.PullSource.RepoAt != nil {
518
-
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
519
-
if err != nil {
520
-
log.Printf("failed to get repo by at uri: %v", err)
521
-
} else {
522
-
pull.PullSource.Repo = pullSourceRepo
523
-
}
524
-
}
525
}
526
527
-
pull.Submissions = make([]*models.PullSubmission, len(submissionsMap))
528
-
for _, submission := range submissionsMap {
529
-
pull.Submissions[submission.RoundNumber] = submission
530
}
531
532
-
return &pull, nil
533
}
534
535
// timeframe here is directly passed into the sql query filter, and any
···
666
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
667
newRoundNumber := len(pull.Submissions)
668
_, err := e.Exec(`
669
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
670
-
values (?, ?, ?, ?, ?)
671
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
672
673
return err
674
}
···
1
package db
2
3
import (
4
+
"cmp"
5
"database/sql"
6
+
"errors"
7
"fmt"
8
+
"maps"
9
+
"slices"
10
"sort"
11
"strings"
12
"time"
···
58
parentChangeId = &pull.ParentChangeId
59
}
60
61
+
result, err := tx.Exec(
62
`
63
insert into pulls (
64
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id
···
82
return err
83
}
84
85
+
// Set the database primary key ID
86
+
id, err := result.LastInsertId()
87
+
if err != nil {
88
+
return err
89
+
}
90
+
pull.ID = int(id)
91
+
92
_, err = tx.Exec(`
93
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
94
+
values (?, ?, ?, ?)
95
+
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
96
return err
97
}
98
···
111
}
112
113
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
114
+
pulls := make(map[syntax.ATURI]*models.Pull)
115
116
var conditions []string
117
var args []any
···
131
132
query := fmt.Sprintf(`
133
select
134
+
id,
135
owner_did,
136
repo_at,
137
pull_id,
···
165
var createdAt string
166
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
167
err := rows.Scan(
168
+
&pull.ID,
169
&pull.OwnerDid,
170
&pull.RepoAt,
171
&pull.PullId,
···
214
pull.ParentChangeId = parentChangeId.String
215
}
216
217
+
pulls[pull.PullAt()] = &pull
218
}
219
220
+
var pullAts []syntax.ATURI
221
for _, p := range pulls {
222
+
pullAts = append(pullAts, p.PullAt())
223
}
224
+
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
225
if err != nil {
226
+
return nil, fmt.Errorf("failed to get submissions: %w", err)
227
}
228
229
+
for pullAt, submissions := range submissionsMap {
230
+
if p, ok := pulls[pullAt]; ok {
231
+
p.Submissions = submissions
232
}
233
+
}
234
235
+
// collect allLabels for each issue
236
+
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
237
+
if err != nil {
238
+
return nil, fmt.Errorf("failed to query labels: %w", err)
239
}
240
+
for pullAt, labels := range allLabels {
241
+
if p, ok := pulls[pullAt]; ok {
242
+
p.Labels = labels
243
+
}
244
}
245
246
+
// collect pull source for all pulls that need it
247
+
var sourceAts []syntax.ATURI
248
for _, p := range pulls {
249
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
250
+
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
251
+
}
252
}
253
+
sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts))
254
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
255
+
return nil, fmt.Errorf("failed to get source repos: %w", err)
256
}
257
+
sourceRepoMap := make(map[syntax.ATURI]*models.Repo)
258
+
for _, r := range sourceRepos {
259
+
sourceRepoMap[r.RepoAt()] = &r
260
}
261
+
for _, p := range pulls {
262
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
263
+
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
264
+
p.PullSource.Repo = sourceRepo
265
+
}
266
+
}
267
}
268
269
orderedByPullId := []*models.Pull{}
···
282
}
283
284
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
285
+
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
286
if err != nil {
287
return nil, err
288
}
289
+
if pulls == nil {
290
+
return nil, sql.ErrNoRows
291
+
}
292
293
+
return pulls[0], nil
294
+
}
295
296
+
// mapping from pull -> pull submissions
297
+
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
298
+
var conditions []string
299
+
var args []any
300
+
for _, filter := range filters {
301
+
conditions = append(conditions, filter.Condition())
302
+
args = append(args, filter.Arg()...)
303
}
304
305
+
whereClause := ""
306
+
if conditions != nil {
307
+
whereClause = " where " + strings.Join(conditions, " and ")
308
}
309
310
+
query := fmt.Sprintf(`
311
select
312
+
id,
313
+
pull_at,
314
+
round_number,
315
+
patch,
316
+
created,
317
+
source_rev
318
from
319
pull_submissions
320
+
%s
321
+
order by
322
+
round_number asc
323
+
`, whereClause)
324
+
325
+
rows, err := e.Query(query, args...)
326
if err != nil {
327
return nil, err
328
}
329
+
defer rows.Close()
330
331
+
submissionMap := make(map[int]*models.PullSubmission)
332
333
+
for rows.Next() {
334
var submission models.PullSubmission
335
+
var createdAt string
336
+
var sourceRev sql.NullString
337
+
err := rows.Scan(
338
&submission.ID,
339
+
&submission.PullAt,
340
&submission.RoundNumber,
341
&submission.Patch,
342
+
&createdAt,
343
+
&sourceRev,
344
)
345
if err != nil {
346
return nil, err
347
}
348
349
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
350
if err != nil {
351
return nil, err
352
}
353
+
submission.Created = createdTime
354
355
+
if sourceRev.Valid {
356
+
submission.SourceRev = sourceRev.String
357
}
358
359
+
submissionMap[submission.ID] = &submission
360
}
361
+
362
+
if err := rows.Err(); err != nil {
363
return nil, err
364
}
365
+
366
+
// Get comments for all submissions using GetPullComments
367
+
submissionIds := slices.Collect(maps.Keys(submissionMap))
368
+
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
369
+
if err != nil {
370
+
return nil, err
371
+
}
372
+
for _, comment := range comments {
373
+
if submission, ok := submissionMap[comment.SubmissionId]; ok {
374
+
submission.Comments = append(submission.Comments, comment)
375
+
}
376
+
}
377
+
378
+
// group the submissions by pull_at
379
+
m := make(map[syntax.ATURI][]*models.PullSubmission)
380
+
for _, s := range submissionMap {
381
+
m[s.PullAt] = append(m[s.PullAt], s)
382
+
}
383
+
384
+
// sort each one by round number
385
+
for _, s := range m {
386
+
slices.SortFunc(s, func(a, b *models.PullSubmission) int {
387
+
return cmp.Compare(a.RoundNumber, b.RoundNumber)
388
+
})
389
}
390
391
+
return m, nil
392
+
}
393
+
394
+
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
395
+
var conditions []string
396
var args []any
397
+
for _, filter := range filters {
398
+
conditions = append(conditions, filter.Condition())
399
+
args = append(args, filter.Arg()...)
400
+
}
401
+
402
+
whereClause := ""
403
+
if conditions != nil {
404
+
whereClause = " where " + strings.Join(conditions, " and ")
405
}
406
+
407
+
query := fmt.Sprintf(`
408
select
409
id,
410
pull_id,
···
416
created
417
from
418
pull_comments
419
+
%s
420
order by
421
created asc
422
+
`, whereClause)
423
+
424
+
rows, err := e.Query(query, args...)
425
if err != nil {
426
return nil, err
427
}
428
+
defer rows.Close()
429
430
+
var comments []models.PullComment
431
+
for rows.Next() {
432
var comment models.PullComment
433
+
var createdAt string
434
+
err := rows.Scan(
435
&comment.ID,
436
&comment.PullId,
437
&comment.SubmissionId,
···
439
&comment.OwnerDid,
440
&comment.CommentAt,
441
&comment.Body,
442
+
&createdAt,
443
)
444
if err != nil {
445
return nil, err
446
}
447
448
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
449
+
comment.Created = t
450
}
451
452
+
comments = append(comments, comment)
453
}
454
455
+
if err := rows.Err(); err != nil {
456
+
return nil, err
457
}
458
459
+
return comments, nil
460
}
461
462
// timeframe here is directly passed into the sql query filter, and any
···
593
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
594
newRoundNumber := len(pull.Submissions)
595
_, err := e.Exec(`
596
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
597
+
values (?, ?, ?, ?)
598
+
`, pull.PullAt(), newRoundNumber, newPatch, sourceRev)
599
600
return err
601
}
+65
-23
appview/db/repos.go
+65
-23
appview/db/repos.go
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
"tangled.org/core/appview/models"
14
)
15
16
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
17
repoMap := make(map[syntax.ATURI]*models.Repo)
18
···
35
36
repoQuery := fmt.Sprintf(
37
`select
38
did,
39
name,
40
knot,
···
63
var description, source, spindle sql.NullString
64
65
err := rows.Scan(
66
&repo.Did,
67
&repo.Name,
68
&repo.Knot,
···
131
132
languageQuery := fmt.Sprintf(
133
`
134
-
select
135
-
repo_at, language
136
-
from
137
-
repo_languages r1
138
-
where
139
-
repo_at IN (%s)
140
and is_default_ref = 1
141
-
and id = (
142
-
select id
143
-
from repo_languages r2
144
-
where r2.repo_at = r1.repo_at
145
-
and r2.is_default_ref = 1
146
-
order by bytes desc
147
-
limit 1
148
-
);
149
`,
150
inClause,
151
)
···
328
var repo models.Repo
329
var nullableDescription sql.NullString
330
331
-
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
332
333
var createdAt string
334
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
335
return nil, err
336
}
337
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
346
return &repo, nil
347
}
348
349
-
func AddRepo(e Execer, repo *models.Repo) error {
350
-
_, err := e.Exec(
351
`insert into repos
352
(did, name, knot, rkey, at_uri, description, source)
353
values (?, ?, ?, ?, ?, ?, ?)`,
354
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
355
)
356
-
return err
357
}
358
359
func RemoveRepo(e Execer, did, name string) error {
···
374
var repos []models.Repo
375
376
rows, err := e.Query(
377
-
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
378
from repos r
379
left join collaborators c on r.at_uri = c.repo_at
380
where (r.did = ? or c.subject_did = ?)
···
394
var nullableDescription sql.NullString
395
var nullableSource sql.NullString
396
397
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
398
if err != nil {
399
return nil, err
400
}
···
431
var nullableSource sql.NullString
432
433
row := e.QueryRow(
434
-
`select did, name, knot, rkey, description, created, source
435
from repos
436
where did = ? and name = ? and source is not null and source != ''`,
437
did, name,
438
)
439
440
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
441
if err != nil {
442
return nil, err
443
}
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
securejoin "github.com/cyphar/filepath-securejoin"
14
+
"tangled.org/core/api/tangled"
15
"tangled.org/core/appview/models"
16
)
17
18
+
type Repo struct {
19
+
Id int64
20
+
Did string
21
+
Name string
22
+
Knot string
23
+
Rkey string
24
+
Created time.Time
25
+
Description string
26
+
Spindle string
27
+
28
+
// optionally, populate this when querying for reverse mappings
29
+
RepoStats *models.RepoStats
30
+
31
+
// optional
32
+
Source string
33
+
}
34
+
35
+
func (r Repo) RepoAt() syntax.ATURI {
36
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
37
+
}
38
+
39
+
func (r Repo) DidSlashRepo() string {
40
+
p, _ := securejoin.SecureJoin(r.Did, r.Name)
41
+
return p
42
+
}
43
+
44
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
45
repoMap := make(map[syntax.ATURI]*models.Repo)
46
···
63
64
repoQuery := fmt.Sprintf(
65
`select
66
+
id,
67
did,
68
name,
69
knot,
···
92
var description, source, spindle sql.NullString
93
94
err := rows.Scan(
95
+
&repo.Id,
96
&repo.Did,
97
&repo.Name,
98
&repo.Knot,
···
161
162
languageQuery := fmt.Sprintf(
163
`
164
+
select repo_at, language
165
+
from (
166
+
select
167
+
repo_at,
168
+
language,
169
+
row_number() over (
170
+
partition by repo_at
171
+
order by bytes desc
172
+
) as rn
173
+
from repo_languages
174
+
where repo_at in (%s)
175
and is_default_ref = 1
176
+
)
177
+
where rn = 1
178
`,
179
inClause,
180
)
···
357
var repo models.Repo
358
var nullableDescription sql.NullString
359
360
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
361
362
var createdAt string
363
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
364
return nil, err
365
}
366
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
375
return &repo, nil
376
}
377
378
+
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
379
+
_, err := tx.Exec(
380
`insert into repos
381
(did, name, knot, rkey, at_uri, description, source)
382
values (?, ?, ?, ?, ?, ?, ?)`,
383
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
384
)
385
+
if err != nil {
386
+
return fmt.Errorf("failed to insert repo: %w", err)
387
+
}
388
+
389
+
for _, dl := range repo.Labels {
390
+
if err := SubscribeLabel(tx, &models.RepoLabel{
391
+
RepoAt: repo.RepoAt(),
392
+
LabelAt: syntax.ATURI(dl),
393
+
}); err != nil {
394
+
return fmt.Errorf("failed to subscribe to label: %w", err)
395
+
}
396
+
}
397
+
398
+
return nil
399
}
400
401
func RemoveRepo(e Execer, did, name string) error {
···
416
var repos []models.Repo
417
418
rows, err := e.Query(
419
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
420
from repos r
421
left join collaborators c on r.at_uri = c.repo_at
422
where (r.did = ? or c.subject_did = ?)
···
436
var nullableDescription sql.NullString
437
var nullableSource sql.NullString
438
439
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
440
if err != nil {
441
return nil, err
442
}
···
473
var nullableSource sql.NullString
474
475
row := e.QueryRow(
476
+
`select id, did, name, knot, rkey, description, created, source
477
from repos
478
where did = ? and name = ? and source is not null and source != ''`,
479
did, name,
480
)
481
482
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
483
if err != nil {
484
return nil, err
485
}
+11
appview/db/star.go
+11
appview/db/star.go
···
5
"errors"
6
"fmt"
7
"log"
8
+
"slices"
9
"strings"
10
"time"
11
···
209
for _, s := range starMap {
210
stars = append(stars, s...)
211
}
212
+
213
+
slices.SortFunc(stars, func(a, b models.Star) int {
214
+
if a.Created.After(b.Created) {
215
+
return -1
216
+
}
217
+
if b.Created.After(a.Created) {
218
+
return 1
219
+
}
220
+
return 0
221
+
})
222
223
return stars, nil
224
}
+80
appview/ingester.go
+80
appview/ingester.go
···
5
"encoding/json"
6
"fmt"
7
"log/slog"
8
+
"maps"
9
+
"slices"
10
11
"time"
12
···
82
err = i.ingestIssueComment(e)
83
case tangled.LabelDefinitionNSID:
84
err = i.ingestLabelDefinition(e)
85
+
case tangled.LabelOpNSID:
86
+
err = i.ingestLabelOp(e)
87
}
88
l = i.Logger.With("nsid", e.Commit.Collection)
89
}
···
957
958
return nil
959
}
960
+
961
+
func (i *Ingester) ingestLabelOp(e *jmodels.Event) error {
962
+
did := e.Did
963
+
rkey := e.Commit.RKey
964
+
965
+
var err error
966
+
967
+
l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
968
+
l.Info("ingesting record")
969
+
970
+
ddb, ok := i.Db.Execer.(*db.DB)
971
+
if !ok {
972
+
return fmt.Errorf("failed to index label op, invalid db cast")
973
+
}
974
+
975
+
switch e.Commit.Operation {
976
+
case jmodels.CommitOperationCreate:
977
+
raw := json.RawMessage(e.Commit.Record)
978
+
record := tangled.LabelOp{}
979
+
err = json.Unmarshal(raw, &record)
980
+
if err != nil {
981
+
return fmt.Errorf("invalid record: %w", err)
982
+
}
983
+
984
+
subject := syntax.ATURI(record.Subject)
985
+
collection := subject.Collection()
986
+
987
+
var repo *models.Repo
988
+
switch collection {
989
+
case tangled.RepoIssueNSID:
990
+
i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject))
991
+
if err != nil || len(i) != 1 {
992
+
return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
993
+
}
994
+
repo = i[0].Repo
995
+
default:
996
+
return fmt.Errorf("unsupport label subject: %s", collection)
997
+
}
998
+
999
+
actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels))
1000
+
if err != nil {
1001
+
return fmt.Errorf("failed to build label application ctx: %w", err)
1002
+
}
1003
+
1004
+
ops := models.LabelOpsFromRecord(did, rkey, record)
1005
+
1006
+
for _, o := range ops {
1007
+
def, ok := actx.Defs[o.OperandKey]
1008
+
if !ok {
1009
+
return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs)))
1010
+
}
1011
+
if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil {
1012
+
return fmt.Errorf("failed to validate labelop: %w", err)
1013
+
}
1014
+
}
1015
+
1016
+
tx, err := ddb.Begin()
1017
+
if err != nil {
1018
+
return err
1019
+
}
1020
+
defer tx.Rollback()
1021
+
1022
+
for _, o := range ops {
1023
+
_, err = db.AddLabelOp(tx, &o)
1024
+
if err != nil {
1025
+
return fmt.Errorf("failed to add labelop: %w", err)
1026
+
}
1027
+
}
1028
+
1029
+
if err = tx.Commit(); err != nil {
1030
+
return err
1031
+
}
1032
+
}
1033
+
1034
+
return nil
1035
+
}
+13
-1
appview/issues/issues.go
+13
-1
appview/issues/issues.go
···
301
return
302
}
303
304
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
305
return
306
} else {
···
434
435
// reset atUri to make rollback a no-op
436
atUri = ""
437
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
438
}
439
···
790
return
791
}
792
793
-
labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
794
if err != nil {
795
log.Println("failed to fetch labels", err)
796
rp.pages.Error503(w)
···
301
return
302
}
303
304
+
// notify about the issue closure
305
+
rp.notifier.NewIssueClosed(r.Context(), issue)
306
+
307
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
308
return
309
} else {
···
437
438
// reset atUri to make rollback a no-op
439
atUri = ""
440
+
441
+
// notify about the new comment
442
+
comment.Id = commentId
443
+
rp.notifier.NewIssueComment(r.Context(), &comment)
444
+
445
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
446
}
447
···
798
return
799
}
800
801
+
labelDefs, err := db.GetLabelDefinitions(
802
+
rp.db,
803
+
db.FilterIn("at_uri", f.Repo.Labels),
804
+
db.FilterContains("scope", tangled.RepoIssueNSID),
805
+
)
806
if err != nil {
807
log.Println("failed to fetch labels", err)
808
rp.pages.Error503(w)
+14
-7
appview/labels/labels.go
+14
-7
appview/labels/labels.go
···
23
"tangled.org/core/appview/validator"
24
"tangled.org/core/appview/xrpcclient"
25
"tangled.org/core/log"
26
"tangled.org/core/tid"
27
)
28
···
32
db *db.DB
33
logger *slog.Logger
34
validator *validator.Validator
35
}
36
37
func New(
···
39
pages *pages.Pages,
40
db *db.DB,
41
validator *validator.Validator,
42
) *Labels {
43
logger := log.New("labels")
44
···
48
db: db,
49
logger: logger,
50
validator: validator,
51
}
52
}
53
···
86
repoAt := r.Form.Get("repo")
87
subjectUri := r.Form.Get("subject")
88
89
// find all the labels that this repo subscribes to
90
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
91
if err != nil {
···
104
return
105
}
106
107
-
l.logger.Info("actx", "labels", labelAts)
108
-
l.logger.Info("actx", "defs", actx.Defs)
109
-
110
// calculate the start state by applying already known labels
111
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
112
if err != nil {
···
155
}
156
}
157
158
-
// reduce the opset
159
-
labelOps = models.ReduceLabelOps(labelOps)
160
-
161
for i := range labelOps {
162
def := actx.Defs[labelOps[i].OperandKey]
163
-
if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil {
164
fail(fmt.Sprintf("Invalid form data: %s", err), err)
165
return
166
}
167
}
168
169
// next, apply all ops introduced in this request and filter out ones that are no-ops
170
validLabelOps := labelOps[:0]
···
23
"tangled.org/core/appview/validator"
24
"tangled.org/core/appview/xrpcclient"
25
"tangled.org/core/log"
26
+
"tangled.org/core/rbac"
27
"tangled.org/core/tid"
28
)
29
···
33
db *db.DB
34
logger *slog.Logger
35
validator *validator.Validator
36
+
enforcer *rbac.Enforcer
37
}
38
39
func New(
···
41
pages *pages.Pages,
42
db *db.DB,
43
validator *validator.Validator,
44
+
enforcer *rbac.Enforcer,
45
) *Labels {
46
logger := log.New("labels")
47
···
51
db: db,
52
logger: logger,
53
validator: validator,
54
+
enforcer: enforcer,
55
}
56
}
57
···
90
repoAt := r.Form.Get("repo")
91
subjectUri := r.Form.Get("subject")
92
93
+
repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt))
94
+
if err != nil {
95
+
fail("Failed to get repository.", err)
96
+
return
97
+
}
98
+
99
// find all the labels that this repo subscribes to
100
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
101
if err != nil {
···
114
return
115
}
116
117
// calculate the start state by applying already known labels
118
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
119
if err != nil {
···
162
}
163
}
164
165
for i := range labelOps {
166
def := actx.Defs[labelOps[i].OperandKey]
167
+
if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil {
168
fail(fmt.Sprintf("Invalid form data: %s", err), err)
169
return
170
}
171
}
172
+
173
+
// reduce the opset
174
+
labelOps = models.ReduceLabelOps(labelOps)
175
176
// next, apply all ops introduced in this request and filter out ones that are no-ops
177
validLabelOps := labelOps[:0]
+9
appview/middleware/middleware.go
+9
appview/middleware/middleware.go
···
43
44
type middlewareFunc func(http.Handler) http.Handler
45
46
+
func (mw *Middleware) TryRefreshSession() middlewareFunc {
47
+
return func(next http.Handler) http.Handler {
48
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
+
_, _, _ = mw.oauth.GetSession(r)
50
+
next.ServeHTTP(w, r)
51
+
})
52
+
}
53
+
}
54
+
55
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
56
return func(next http.Handler) http.Handler {
57
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+83
-14
appview/models/label.go
+83
-14
appview/models/label.go
···
1
package models
2
3
import (
4
"crypto/sha1"
5
"encoding/hex"
6
"errors"
7
"fmt"
8
"slices"
9
"time"
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
"tangled.org/core/api/tangled"
13
"tangled.org/core/consts"
14
)
15
16
type ConcreteType string
···
227
}
228
229
var ops []LabelOp
230
-
for _, o := range record.Add {
231
if o != nil {
232
op := mkOp(o)
233
-
op.Operation = LabelOperationAdd
234
ops = append(ops, op)
235
}
236
}
237
-
for _, o := range record.Delete {
238
if o != nil {
239
op := mkOp(o)
240
-
op.Operation = LabelOperationDel
241
ops = append(ops, op)
242
}
243
}
···
455
return result
456
}
457
458
func DefaultLabelDefs() []string {
459
-
rkeys := []string{
460
-
"wontfix",
461
-
"duplicate",
462
-
"assignee",
463
-
"good-first-issue",
464
-
"documentation",
465
}
466
467
-
defs := make([]string, len(rkeys))
468
-
for i, r := range rkeys {
469
-
defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r)
470
}
471
472
-
return defs
473
}
···
1
package models
2
3
import (
4
+
"context"
5
"crypto/sha1"
6
"encoding/hex"
7
+
"encoding/json"
8
"errors"
9
"fmt"
10
"slices"
11
"time"
12
13
+
"github.com/bluesky-social/indigo/api/atproto"
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
"github.com/bluesky-social/indigo/xrpc"
16
"tangled.org/core/api/tangled"
17
"tangled.org/core/consts"
18
+
"tangled.org/core/idresolver"
19
)
20
21
type ConcreteType string
···
232
}
233
234
var ops []LabelOp
235
+
// deletes first, then additions
236
+
for _, o := range record.Delete {
237
if o != nil {
238
op := mkOp(o)
239
+
op.Operation = LabelOperationDel
240
ops = append(ops, op)
241
}
242
}
243
+
for _, o := range record.Add {
244
if o != nil {
245
op := mkOp(o)
246
+
op.Operation = LabelOperationAdd
247
ops = append(ops, op)
248
}
249
}
···
461
return result
462
}
463
464
+
var (
465
+
LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix")
466
+
LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate")
467
+
LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee")
468
+
LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
469
+
LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation")
470
+
)
471
+
472
func DefaultLabelDefs() []string {
473
+
return []string{
474
+
LabelWontfix,
475
+
LabelDuplicate,
476
+
LabelAssignee,
477
+
LabelGoodFirstIssue,
478
+
LabelDocumentation,
479
}
480
+
}
481
482
+
func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
483
+
resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid)
484
+
if err != nil {
485
+
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err)
486
+
}
487
+
pdsEndpoint := resolved.PDSEndpoint()
488
+
if pdsEndpoint == "" {
489
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid)
490
+
}
491
+
client := &xrpc.Client{
492
+
Host: pdsEndpoint,
493
+
}
494
+
495
+
var labelDefs []LabelDefinition
496
+
497
+
for _, dl := range DefaultLabelDefs() {
498
+
atUri := syntax.ATURI(dl)
499
+
parsedUri, err := syntax.ParseATURI(string(atUri))
500
+
if err != nil {
501
+
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err)
502
+
}
503
+
record, err := atproto.RepoGetRecord(
504
+
context.Background(),
505
+
client,
506
+
"",
507
+
parsedUri.Collection().String(),
508
+
parsedUri.Authority().String(),
509
+
parsedUri.RecordKey().String(),
510
+
)
511
+
if err != nil {
512
+
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
513
+
}
514
+
515
+
if record != nil {
516
+
bytes, err := record.Value.MarshalJSON()
517
+
if err != nil {
518
+
return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err)
519
+
}
520
+
521
+
raw := json.RawMessage(bytes)
522
+
labelRecord := tangled.LabelDefinition{}
523
+
err = json.Unmarshal(raw, &labelRecord)
524
+
if err != nil {
525
+
return nil, fmt.Errorf("invalid record for %s: %w", atUri, err)
526
+
}
527
+
528
+
labelDef, err := LabelDefinitionFromRecord(
529
+
parsedUri.Authority().String(),
530
+
parsedUri.RecordKey().String(),
531
+
labelRecord,
532
+
)
533
+
if err != nil {
534
+
return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err)
535
+
}
536
+
537
+
labelDefs = append(labelDefs, *labelDef)
538
+
}
539
}
540
541
+
return labelDefs, nil
542
}
+82
appview/models/notifications.go
+82
appview/models/notifications.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
)
6
+
7
+
type NotificationType string
8
+
9
+
const (
10
+
NotificationTypeRepoStarred NotificationType = "repo_starred"
11
+
NotificationTypeIssueCreated NotificationType = "issue_created"
12
+
NotificationTypeIssueCommented NotificationType = "issue_commented"
13
+
NotificationTypePullCreated NotificationType = "pull_created"
14
+
NotificationTypePullCommented NotificationType = "pull_commented"
15
+
NotificationTypeFollowed NotificationType = "followed"
16
+
NotificationTypePullMerged NotificationType = "pull_merged"
17
+
NotificationTypeIssueClosed NotificationType = "issue_closed"
18
+
NotificationTypePullClosed NotificationType = "pull_closed"
19
+
)
20
+
21
+
type Notification struct {
22
+
ID int64
23
+
RecipientDid string
24
+
ActorDid string
25
+
Type NotificationType
26
+
EntityType string
27
+
EntityId string
28
+
Read bool
29
+
Created time.Time
30
+
31
+
// foreign key references
32
+
RepoId *int64
33
+
IssueId *int64
34
+
PullId *int64
35
+
}
36
+
37
+
// lucide icon that represents this notification
38
+
func (n *Notification) Icon() string {
39
+
switch n.Type {
40
+
case NotificationTypeRepoStarred:
41
+
return "star"
42
+
case NotificationTypeIssueCreated:
43
+
return "circle-dot"
44
+
case NotificationTypeIssueCommented:
45
+
return "message-square"
46
+
case NotificationTypeIssueClosed:
47
+
return "ban"
48
+
case NotificationTypePullCreated:
49
+
return "git-pull-request-create"
50
+
case NotificationTypePullCommented:
51
+
return "message-square"
52
+
case NotificationTypePullMerged:
53
+
return "git-merge"
54
+
case NotificationTypePullClosed:
55
+
return "git-pull-request-closed"
56
+
case NotificationTypeFollowed:
57
+
return "user-plus"
58
+
default:
59
+
return ""
60
+
}
61
+
}
62
+
63
+
type NotificationWithEntity struct {
64
+
*Notification
65
+
Repo *Repo
66
+
Issue *Issue
67
+
Pull *Pull
68
+
}
69
+
70
+
type NotificationPreferences struct {
71
+
ID int64
72
+
UserDid string
73
+
RepoStarred bool
74
+
IssueCreated bool
75
+
IssueCommented bool
76
+
PullCreated bool
77
+
PullCommented bool
78
+
Followed bool
79
+
PullMerged bool
80
+
IssueClosed bool
81
+
EmailNotifications bool
82
+
}
+46
-4
appview/models/pull.go
+46
-4
appview/models/pull.go
···
77
PullSource *PullSource
78
79
// optionally, populate this when querying for reverse mappings
80
-
Repo *Repo
81
}
82
83
func (p Pull) AsRecord() tangled.RepoPull {
···
125
126
type PullSubmission struct {
127
// ids
128
-
ID int
129
-
PullId int
130
131
// at ids
132
-
RepoAt syntax.ATURI
133
134
// content
135
RoundNumber int
···
207
return p.StackId != ""
208
}
209
210
func (s PullSubmission) IsFormatPatch() bool {
211
return patchutil.IsFormatPatch(s.Patch)
212
}
···
219
}
220
221
return patches
222
}
223
224
type Stack []*Pull
···
77
PullSource *PullSource
78
79
// optionally, populate this when querying for reverse mappings
80
+
Labels LabelState
81
+
Repo *Repo
82
}
83
84
func (p Pull) AsRecord() tangled.RepoPull {
···
126
127
type PullSubmission struct {
128
// ids
129
+
ID int
130
131
// at ids
132
+
PullAt syntax.ATURI
133
134
// content
135
RoundNumber int
···
207
return p.StackId != ""
208
}
209
210
+
func (p *Pull) Participants() []string {
211
+
participantSet := make(map[string]struct{})
212
+
participants := []string{}
213
+
214
+
addParticipant := func(did string) {
215
+
if _, exists := participantSet[did]; !exists {
216
+
participantSet[did] = struct{}{}
217
+
participants = append(participants, did)
218
+
}
219
+
}
220
+
221
+
addParticipant(p.OwnerDid)
222
+
223
+
for _, s := range p.Submissions {
224
+
for _, sp := range s.Participants() {
225
+
addParticipant(sp)
226
+
}
227
+
}
228
+
229
+
return participants
230
+
}
231
+
232
func (s PullSubmission) IsFormatPatch() bool {
233
return patchutil.IsFormatPatch(s.Patch)
234
}
···
241
}
242
243
return patches
244
+
}
245
+
246
+
func (s *PullSubmission) Participants() []string {
247
+
participantSet := make(map[string]struct{})
248
+
participants := []string{}
249
+
250
+
addParticipant := func(did string) {
251
+
if _, exists := participantSet[did]; !exists {
252
+
participantSet[did] = struct{}{}
253
+
participants = append(participants, did)
254
+
}
255
+
}
256
+
257
+
addParticipant(s.PullAt.Authority().String())
258
+
259
+
for _, c := range s.Comments {
260
+
addParticipant(c.OwnerDid)
261
+
}
262
+
263
+
return participants
264
}
265
266
type Stack []*Pull
+6
appview/models/repo.go
+6
appview/models/repo.go
+168
appview/notifications/notifications.go
+168
appview/notifications/notifications.go
···
···
1
+
package notifications
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"strconv"
8
+
9
+
"github.com/go-chi/chi/v5"
10
+
"tangled.org/core/appview/db"
11
+
"tangled.org/core/appview/middleware"
12
+
"tangled.org/core/appview/oauth"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/appview/pagination"
15
+
)
16
+
17
+
type Notifications struct {
18
+
db *db.DB
19
+
oauth *oauth.OAuth
20
+
pages *pages.Pages
21
+
}
22
+
23
+
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications {
24
+
return &Notifications{
25
+
db: database,
26
+
oauth: oauthHandler,
27
+
pages: pagesHandler,
28
+
}
29
+
}
30
+
31
+
func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
32
+
r := chi.NewRouter()
33
+
34
+
r.Use(middleware.AuthMiddleware(n.oauth))
35
+
36
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
37
+
38
+
r.Get("/count", n.getUnreadCount)
39
+
r.Post("/{id}/read", n.markRead)
40
+
r.Post("/read-all", n.markAllRead)
41
+
r.Delete("/{id}", n.deleteNotification)
42
+
43
+
return r
44
+
}
45
+
46
+
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
47
+
userDid := n.oauth.GetDid(r)
48
+
49
+
page, ok := r.Context().Value("page").(pagination.Page)
50
+
if !ok {
51
+
log.Println("failed to get page")
52
+
page = pagination.FirstPage()
53
+
}
54
+
55
+
total, err := db.CountNotifications(
56
+
n.db,
57
+
db.FilterEq("recipient_did", userDid),
58
+
)
59
+
if err != nil {
60
+
log.Println("failed to get total notifications:", err)
61
+
n.pages.Error500(w)
62
+
return
63
+
}
64
+
65
+
notifications, err := db.GetNotificationsWithEntities(
66
+
n.db,
67
+
page,
68
+
db.FilterEq("recipient_did", userDid),
69
+
)
70
+
if err != nil {
71
+
log.Println("failed to get notifications:", err)
72
+
n.pages.Error500(w)
73
+
return
74
+
}
75
+
76
+
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
77
+
if err != nil {
78
+
log.Println("failed to mark notifications as read:", err)
79
+
}
80
+
81
+
unreadCount := 0
82
+
83
+
user := n.oauth.GetUser(r)
84
+
if user == nil {
85
+
http.Error(w, "Failed to get user", http.StatusInternalServerError)
86
+
return
87
+
}
88
+
89
+
fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{
90
+
LoggedInUser: user,
91
+
Notifications: notifications,
92
+
UnreadCount: unreadCount,
93
+
Page: page,
94
+
Total: total,
95
+
}))
96
+
}
97
+
98
+
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
99
+
user := n.oauth.GetUser(r)
100
+
count, err := db.CountNotifications(
101
+
n.db,
102
+
db.FilterEq("recipient_did", user.Did),
103
+
db.FilterEq("read", 0),
104
+
)
105
+
if err != nil {
106
+
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
107
+
return
108
+
}
109
+
110
+
params := pages.NotificationCountParams{
111
+
Count: count,
112
+
}
113
+
err = n.pages.NotificationCount(w, params)
114
+
if err != nil {
115
+
http.Error(w, "Failed to render count", http.StatusInternalServerError)
116
+
return
117
+
}
118
+
}
119
+
120
+
func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) {
121
+
userDid := n.oauth.GetDid(r)
122
+
123
+
idStr := chi.URLParam(r, "id")
124
+
notificationID, err := strconv.ParseInt(idStr, 10, 64)
125
+
if err != nil {
126
+
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
127
+
return
128
+
}
129
+
130
+
err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid)
131
+
if err != nil {
132
+
http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError)
133
+
return
134
+
}
135
+
136
+
w.WriteHeader(http.StatusNoContent)
137
+
}
138
+
139
+
func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
140
+
userDid := n.oauth.GetDid(r)
141
+
142
+
err := n.db.MarkAllNotificationsRead(r.Context(), userDid)
143
+
if err != nil {
144
+
http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
145
+
return
146
+
}
147
+
148
+
http.Redirect(w, r, "/notifications", http.StatusSeeOther)
149
+
}
150
+
151
+
func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) {
152
+
userDid := n.oauth.GetDid(r)
153
+
154
+
idStr := chi.URLParam(r, "id")
155
+
notificationID, err := strconv.ParseInt(idStr, 10, 64)
156
+
if err != nil {
157
+
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
158
+
return
159
+
}
160
+
161
+
err = n.db.DeleteNotification(r.Context(), notificationID, userDid)
162
+
if err != nil {
163
+
http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
164
+
return
165
+
}
166
+
167
+
w.WriteHeader(http.StatusOK)
168
+
}
+429
appview/notify/db/db.go
+429
appview/notify/db/db.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
7
+
"tangled.org/core/appview/db"
8
+
"tangled.org/core/appview/models"
9
+
"tangled.org/core/appview/notify"
10
+
"tangled.org/core/idresolver"
11
+
)
12
+
13
+
type databaseNotifier struct {
14
+
db *db.DB
15
+
res *idresolver.Resolver
16
+
}
17
+
18
+
func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier {
19
+
return &databaseNotifier{
20
+
db: database,
21
+
res: resolver,
22
+
}
23
+
}
24
+
25
+
var _ notify.Notifier = &databaseNotifier{}
26
+
27
+
func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
28
+
// no-op for now
29
+
}
30
+
31
+
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
32
+
var err error
33
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
34
+
if err != nil {
35
+
log.Printf("NewStar: failed to get repos: %v", err)
36
+
return
37
+
}
38
+
39
+
// don't notify yourself
40
+
if repo.Did == star.StarredByDid {
41
+
return
42
+
}
43
+
44
+
// check if user wants these notifications
45
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
46
+
if err != nil {
47
+
log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err)
48
+
return
49
+
}
50
+
if !prefs.RepoStarred {
51
+
return
52
+
}
53
+
54
+
notification := &models.Notification{
55
+
RecipientDid: repo.Did,
56
+
ActorDid: star.StarredByDid,
57
+
Type: models.NotificationTypeRepoStarred,
58
+
EntityType: "repo",
59
+
EntityId: string(star.RepoAt),
60
+
RepoId: &repo.Id,
61
+
}
62
+
err = n.db.CreateNotification(ctx, notification)
63
+
if err != nil {
64
+
log.Printf("NewStar: failed to create notification: %v", err)
65
+
return
66
+
}
67
+
}
68
+
69
+
func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {
70
+
// no-op
71
+
}
72
+
73
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
74
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
75
+
if err != nil {
76
+
log.Printf("NewIssue: failed to get repos: %v", err)
77
+
return
78
+
}
79
+
80
+
if repo.Did == issue.Did {
81
+
return
82
+
}
83
+
84
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
85
+
if err != nil {
86
+
log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err)
87
+
return
88
+
}
89
+
if !prefs.IssueCreated {
90
+
return
91
+
}
92
+
93
+
notification := &models.Notification{
94
+
RecipientDid: repo.Did,
95
+
ActorDid: issue.Did,
96
+
Type: models.NotificationTypeIssueCreated,
97
+
EntityType: "issue",
98
+
EntityId: string(issue.AtUri()),
99
+
RepoId: &repo.Id,
100
+
IssueId: &issue.Id,
101
+
}
102
+
103
+
err = n.db.CreateNotification(ctx, notification)
104
+
if err != nil {
105
+
log.Printf("NewIssue: failed to create notification: %v", err)
106
+
return
107
+
}
108
+
}
109
+
110
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
111
+
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
112
+
if err != nil {
113
+
log.Printf("NewIssueComment: failed to get issues: %v", err)
114
+
return
115
+
}
116
+
if len(issues) == 0 {
117
+
log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt)
118
+
return
119
+
}
120
+
issue := issues[0]
121
+
122
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
123
+
if err != nil {
124
+
log.Printf("NewIssueComment: failed to get repos: %v", err)
125
+
return
126
+
}
127
+
128
+
recipients := make(map[string]bool)
129
+
130
+
// notify issue author (if not the commenter)
131
+
if issue.Did != comment.Did {
132
+
prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did)
133
+
if err == nil && prefs.IssueCommented {
134
+
recipients[issue.Did] = true
135
+
} else if err != nil {
136
+
log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err)
137
+
}
138
+
}
139
+
140
+
// notify repo owner (if not the commenter and not already added)
141
+
if repo.Did != comment.Did && repo.Did != issue.Did {
142
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
143
+
if err == nil && prefs.IssueCommented {
144
+
recipients[repo.Did] = true
145
+
} else if err != nil {
146
+
log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
147
+
}
148
+
}
149
+
150
+
// create notifications for all recipients
151
+
for recipientDid := range recipients {
152
+
notification := &models.Notification{
153
+
RecipientDid: recipientDid,
154
+
ActorDid: comment.Did,
155
+
Type: models.NotificationTypeIssueCommented,
156
+
EntityType: "issue",
157
+
EntityId: string(issue.AtUri()),
158
+
RepoId: &repo.Id,
159
+
IssueId: &issue.Id,
160
+
}
161
+
162
+
err = n.db.CreateNotification(ctx, notification)
163
+
if err != nil {
164
+
log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err)
165
+
}
166
+
}
167
+
}
168
+
169
+
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
170
+
prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid)
171
+
if err != nil {
172
+
log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err)
173
+
return
174
+
}
175
+
if !prefs.Followed {
176
+
return
177
+
}
178
+
179
+
notification := &models.Notification{
180
+
RecipientDid: follow.SubjectDid,
181
+
ActorDid: follow.UserDid,
182
+
Type: models.NotificationTypeFollowed,
183
+
EntityType: "follow",
184
+
EntityId: follow.UserDid,
185
+
}
186
+
187
+
err = n.db.CreateNotification(ctx, notification)
188
+
if err != nil {
189
+
log.Printf("NewFollow: failed to create notification: %v", err)
190
+
return
191
+
}
192
+
}
193
+
194
+
func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
195
+
// no-op
196
+
}
197
+
198
+
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
199
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
200
+
if err != nil {
201
+
log.Printf("NewPull: failed to get repos: %v", err)
202
+
return
203
+
}
204
+
205
+
if repo.Did == pull.OwnerDid {
206
+
return
207
+
}
208
+
209
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
210
+
if err != nil {
211
+
log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err)
212
+
return
213
+
}
214
+
if !prefs.PullCreated {
215
+
return
216
+
}
217
+
218
+
notification := &models.Notification{
219
+
RecipientDid: repo.Did,
220
+
ActorDid: pull.OwnerDid,
221
+
Type: models.NotificationTypePullCreated,
222
+
EntityType: "pull",
223
+
EntityId: string(pull.RepoAt),
224
+
RepoId: &repo.Id,
225
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
226
+
}
227
+
228
+
err = n.db.CreateNotification(ctx, notification)
229
+
if err != nil {
230
+
log.Printf("NewPull: failed to create notification: %v", err)
231
+
return
232
+
}
233
+
}
234
+
235
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
236
+
pulls, err := db.GetPulls(n.db,
237
+
db.FilterEq("repo_at", comment.RepoAt),
238
+
db.FilterEq("pull_id", comment.PullId))
239
+
if err != nil {
240
+
log.Printf("NewPullComment: failed to get pulls: %v", err)
241
+
return
242
+
}
243
+
if len(pulls) == 0 {
244
+
log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId)
245
+
return
246
+
}
247
+
pull := pulls[0]
248
+
249
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
250
+
if err != nil {
251
+
log.Printf("NewPullComment: failed to get repos: %v", err)
252
+
return
253
+
}
254
+
255
+
recipients := make(map[string]bool)
256
+
257
+
// notify pull request author (if not the commenter)
258
+
if pull.OwnerDid != comment.OwnerDid {
259
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
260
+
if err == nil && prefs.PullCommented {
261
+
recipients[pull.OwnerDid] = true
262
+
} else if err != nil {
263
+
log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err)
264
+
}
265
+
}
266
+
267
+
// notify repo owner (if not the commenter and not already added)
268
+
if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid {
269
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
270
+
if err == nil && prefs.PullCommented {
271
+
recipients[repo.Did] = true
272
+
} else if err != nil {
273
+
log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
274
+
}
275
+
}
276
+
277
+
for recipientDid := range recipients {
278
+
notification := &models.Notification{
279
+
RecipientDid: recipientDid,
280
+
ActorDid: comment.OwnerDid,
281
+
Type: models.NotificationTypePullCommented,
282
+
EntityType: "pull",
283
+
EntityId: comment.RepoAt,
284
+
RepoId: &repo.Id,
285
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
286
+
}
287
+
288
+
err = n.db.CreateNotification(ctx, notification)
289
+
if err != nil {
290
+
log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err)
291
+
}
292
+
}
293
+
}
294
+
295
+
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
296
+
// no-op
297
+
}
298
+
299
+
func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) {
300
+
// no-op
301
+
}
302
+
303
+
func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) {
304
+
// no-op
305
+
}
306
+
307
+
func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) {
308
+
// no-op
309
+
}
310
+
311
+
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
312
+
// Get repo details
313
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
314
+
if err != nil {
315
+
log.Printf("NewIssueClosed: failed to get repos: %v", err)
316
+
return
317
+
}
318
+
319
+
// Don't notify yourself
320
+
if repo.Did == issue.Did {
321
+
return
322
+
}
323
+
324
+
// Check if user wants these notifications
325
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
326
+
if err != nil {
327
+
log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err)
328
+
return
329
+
}
330
+
if !prefs.IssueClosed {
331
+
return
332
+
}
333
+
334
+
notification := &models.Notification{
335
+
RecipientDid: repo.Did,
336
+
ActorDid: issue.Did,
337
+
Type: models.NotificationTypeIssueClosed,
338
+
EntityType: "issue",
339
+
EntityId: string(issue.AtUri()),
340
+
RepoId: &repo.Id,
341
+
IssueId: &issue.Id,
342
+
}
343
+
344
+
err = n.db.CreateNotification(ctx, notification)
345
+
if err != nil {
346
+
log.Printf("NewIssueClosed: failed to create notification: %v", err)
347
+
return
348
+
}
349
+
}
350
+
351
+
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
352
+
// Get repo details
353
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
354
+
if err != nil {
355
+
log.Printf("NewPullMerged: failed to get repos: %v", err)
356
+
return
357
+
}
358
+
359
+
// Don't notify yourself
360
+
if repo.Did == pull.OwnerDid {
361
+
return
362
+
}
363
+
364
+
// Check if user wants these notifications
365
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
366
+
if err != nil {
367
+
log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
368
+
return
369
+
}
370
+
if !prefs.PullMerged {
371
+
return
372
+
}
373
+
374
+
notification := &models.Notification{
375
+
RecipientDid: pull.OwnerDid,
376
+
ActorDid: repo.Did,
377
+
Type: models.NotificationTypePullMerged,
378
+
EntityType: "pull",
379
+
EntityId: string(pull.RepoAt),
380
+
RepoId: &repo.Id,
381
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
382
+
}
383
+
384
+
err = n.db.CreateNotification(ctx, notification)
385
+
if err != nil {
386
+
log.Printf("NewPullMerged: failed to create notification: %v", err)
387
+
return
388
+
}
389
+
}
390
+
391
+
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
392
+
// Get repo details
393
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
394
+
if err != nil {
395
+
log.Printf("NewPullClosed: failed to get repos: %v", err)
396
+
return
397
+
}
398
+
399
+
// Don't notify yourself
400
+
if repo.Did == pull.OwnerDid {
401
+
return
402
+
}
403
+
404
+
// Check if user wants these notifications - reuse pull_merged preference for now
405
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
406
+
if err != nil {
407
+
log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
408
+
return
409
+
}
410
+
if !prefs.PullMerged {
411
+
return
412
+
}
413
+
414
+
notification := &models.Notification{
415
+
RecipientDid: pull.OwnerDid,
416
+
ActorDid: repo.Did,
417
+
Type: models.NotificationTypePullClosed,
418
+
EntityType: "pull",
419
+
EntityId: string(pull.RepoAt),
420
+
RepoId: &repo.Id,
421
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
422
+
}
423
+
424
+
err = n.db.CreateNotification(ctx, notification)
425
+
if err != nil {
426
+
log.Printf("NewPullClosed: failed to create notification: %v", err)
427
+
return
428
+
}
429
+
}
+23
appview/notify/merged_notifier.go
+23
appview/notify/merged_notifier.go
···
38
notifier.NewIssue(ctx, issue)
39
}
40
}
41
42
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
43
for _, notifier := range m.notifiers {
···
58
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
59
for _, notifier := range m.notifiers {
60
notifier.NewPullComment(ctx, comment)
61
}
62
}
63
···
38
notifier.NewIssue(ctx, issue)
39
}
40
}
41
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
+
for _, notifier := range m.notifiers {
43
+
notifier.NewIssueComment(ctx, comment)
44
+
}
45
+
}
46
+
47
+
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
+
for _, notifier := range m.notifiers {
49
+
notifier.NewIssueClosed(ctx, issue)
50
+
}
51
+
}
52
53
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
54
for _, notifier := range m.notifiers {
···
69
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
70
for _, notifier := range m.notifiers {
71
notifier.NewPullComment(ctx, comment)
72
+
}
73
+
}
74
+
75
+
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
+
for _, notifier := range m.notifiers {
77
+
notifier.NewPullMerged(ctx, pull)
78
+
}
79
+
}
80
+
81
+
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
+
for _, notifier := range m.notifiers {
83
+
notifier.NewPullClosed(ctx, pull)
84
}
85
}
86
+9
-1
appview/notify/notifier.go
+9
-1
appview/notify/notifier.go
···
13
DeleteStar(ctx context.Context, star *models.Star)
14
15
NewIssue(ctx context.Context, issue *models.Issue)
16
17
NewFollow(ctx context.Context, follow *models.Follow)
18
DeleteFollow(ctx context.Context, follow *models.Follow)
19
20
NewPull(ctx context.Context, pull *models.Pull)
21
NewPullComment(ctx context.Context, comment *models.PullComment)
22
23
UpdateProfile(ctx context.Context, profile *models.Profile)
24
···
37
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
38
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
39
40
-
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
41
42
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
43
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
44
45
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
46
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
47
48
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
49
···
13
DeleteStar(ctx context.Context, star *models.Star)
14
15
NewIssue(ctx context.Context, issue *models.Issue)
16
+
NewIssueComment(ctx context.Context, comment *models.IssueComment)
17
+
NewIssueClosed(ctx context.Context, issue *models.Issue)
18
19
NewFollow(ctx context.Context, follow *models.Follow)
20
DeleteFollow(ctx context.Context, follow *models.Follow)
21
22
NewPull(ctx context.Context, pull *models.Pull)
23
NewPullComment(ctx context.Context, comment *models.PullComment)
24
+
NewPullMerged(ctx context.Context, pull *models.Pull)
25
+
NewPullClosed(ctx context.Context, pull *models.Pull)
26
27
UpdateProfile(ctx context.Context, profile *models.Profile)
28
···
41
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
42
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
43
44
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
45
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {}
46
+
func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {}
47
48
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
49
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
50
51
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
52
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
53
+
func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {}
54
+
func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {}
55
56
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
57
+219
appview/notify/posthog/notifier.go
+219
appview/notify/posthog/notifier.go
···
···
1
+
package posthog
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
7
+
"github.com/posthog/posthog-go"
8
+
"tangled.org/core/appview/models"
9
+
"tangled.org/core/appview/notify"
10
+
)
11
+
12
+
type posthogNotifier struct {
13
+
client posthog.Client
14
+
notify.BaseNotifier
15
+
}
16
+
17
+
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
18
+
return &posthogNotifier{
19
+
client,
20
+
notify.BaseNotifier{},
21
+
}
22
+
}
23
+
24
+
var _ notify.Notifier = &posthogNotifier{}
25
+
26
+
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
27
+
err := n.client.Enqueue(posthog.Capture{
28
+
DistinctId: repo.Did,
29
+
Event: "new_repo",
30
+
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
31
+
})
32
+
if err != nil {
33
+
log.Println("failed to enqueue posthog event:", err)
34
+
}
35
+
}
36
+
37
+
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
38
+
err := n.client.Enqueue(posthog.Capture{
39
+
DistinctId: star.StarredByDid,
40
+
Event: "star",
41
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
42
+
})
43
+
if err != nil {
44
+
log.Println("failed to enqueue posthog event:", err)
45
+
}
46
+
}
47
+
48
+
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
49
+
err := n.client.Enqueue(posthog.Capture{
50
+
DistinctId: star.StarredByDid,
51
+
Event: "unstar",
52
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
53
+
})
54
+
if err != nil {
55
+
log.Println("failed to enqueue posthog event:", err)
56
+
}
57
+
}
58
+
59
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
60
+
err := n.client.Enqueue(posthog.Capture{
61
+
DistinctId: issue.Did,
62
+
Event: "new_issue",
63
+
Properties: posthog.Properties{
64
+
"repo_at": issue.RepoAt.String(),
65
+
"issue_id": issue.IssueId,
66
+
},
67
+
})
68
+
if err != nil {
69
+
log.Println("failed to enqueue posthog event:", err)
70
+
}
71
+
}
72
+
73
+
func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) {
74
+
err := n.client.Enqueue(posthog.Capture{
75
+
DistinctId: pull.OwnerDid,
76
+
Event: "new_pull",
77
+
Properties: posthog.Properties{
78
+
"repo_at": pull.RepoAt,
79
+
"pull_id": pull.PullId,
80
+
},
81
+
})
82
+
if err != nil {
83
+
log.Println("failed to enqueue posthog event:", err)
84
+
}
85
+
}
86
+
87
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
88
+
err := n.client.Enqueue(posthog.Capture{
89
+
DistinctId: comment.OwnerDid,
90
+
Event: "new_pull_comment",
91
+
Properties: posthog.Properties{
92
+
"repo_at": comment.RepoAt,
93
+
"pull_id": comment.PullId,
94
+
},
95
+
})
96
+
if err != nil {
97
+
log.Println("failed to enqueue posthog event:", err)
98
+
}
99
+
}
100
+
101
+
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
102
+
err := n.client.Enqueue(posthog.Capture{
103
+
DistinctId: pull.OwnerDid,
104
+
Event: "pull_closed",
105
+
Properties: posthog.Properties{
106
+
"repo_at": pull.RepoAt,
107
+
"pull_id": pull.PullId,
108
+
},
109
+
})
110
+
if err != nil {
111
+
log.Println("failed to enqueue posthog event:", err)
112
+
}
113
+
}
114
+
115
+
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
116
+
err := n.client.Enqueue(posthog.Capture{
117
+
DistinctId: follow.UserDid,
118
+
Event: "follow",
119
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
120
+
})
121
+
if err != nil {
122
+
log.Println("failed to enqueue posthog event:", err)
123
+
}
124
+
}
125
+
126
+
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
127
+
err := n.client.Enqueue(posthog.Capture{
128
+
DistinctId: follow.UserDid,
129
+
Event: "unfollow",
130
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
131
+
})
132
+
if err != nil {
133
+
log.Println("failed to enqueue posthog event:", err)
134
+
}
135
+
}
136
+
137
+
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
138
+
err := n.client.Enqueue(posthog.Capture{
139
+
DistinctId: profile.Did,
140
+
Event: "edit_profile",
141
+
})
142
+
if err != nil {
143
+
log.Println("failed to enqueue posthog event:", err)
144
+
}
145
+
}
146
+
147
+
func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) {
148
+
err := n.client.Enqueue(posthog.Capture{
149
+
DistinctId: did,
150
+
Event: "delete_string",
151
+
Properties: posthog.Properties{"rkey": rkey},
152
+
})
153
+
if err != nil {
154
+
log.Println("failed to enqueue posthog event:", err)
155
+
}
156
+
}
157
+
158
+
func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) {
159
+
err := n.client.Enqueue(posthog.Capture{
160
+
DistinctId: string.Did.String(),
161
+
Event: "edit_string",
162
+
Properties: posthog.Properties{"rkey": string.Rkey},
163
+
})
164
+
if err != nil {
165
+
log.Println("failed to enqueue posthog event:", err)
166
+
}
167
+
}
168
+
169
+
func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) {
170
+
err := n.client.Enqueue(posthog.Capture{
171
+
DistinctId: string.Did.String(),
172
+
Event: "new_string",
173
+
Properties: posthog.Properties{"rkey": string.Rkey},
174
+
})
175
+
if err != nil {
176
+
log.Println("failed to enqueue posthog event:", err)
177
+
}
178
+
}
179
+
180
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
181
+
err := n.client.Enqueue(posthog.Capture{
182
+
DistinctId: comment.Did,
183
+
Event: "new_issue_comment",
184
+
Properties: posthog.Properties{
185
+
"issue_at": comment.IssueAt,
186
+
},
187
+
})
188
+
if err != nil {
189
+
log.Println("failed to enqueue posthog event:", err)
190
+
}
191
+
}
192
+
193
+
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
194
+
err := n.client.Enqueue(posthog.Capture{
195
+
DistinctId: issue.Did,
196
+
Event: "issue_closed",
197
+
Properties: posthog.Properties{
198
+
"repo_at": issue.RepoAt.String(),
199
+
"issue_id": issue.IssueId,
200
+
},
201
+
})
202
+
if err != nil {
203
+
log.Println("failed to enqueue posthog event:", err)
204
+
}
205
+
}
206
+
207
+
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
208
+
err := n.client.Enqueue(posthog.Capture{
209
+
DistinctId: pull.OwnerDid,
210
+
Event: "pull_merged",
211
+
Properties: posthog.Properties{
212
+
"repo_at": pull.RepoAt,
213
+
"pull_id": pull.PullId,
214
+
},
215
+
})
216
+
if err != nil {
217
+
log.Println("failed to enqueue posthog event:", err)
218
+
}
219
+
}
+15
-15
appview/pages/funcmap.go
+15
-15
appview/pages/funcmap.go
···
141
"relTimeFmt": humanize.Time,
142
"shortRelTimeFmt": func(t time.Time) string {
143
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
144
-
{time.Second, "now", time.Second},
145
-
{2 * time.Second, "1s %s", 1},
146
-
{time.Minute, "%ds %s", time.Second},
147
-
{2 * time.Minute, "1min %s", 1},
148
-
{time.Hour, "%dmin %s", time.Minute},
149
-
{2 * time.Hour, "1hr %s", 1},
150
-
{humanize.Day, "%dhrs %s", time.Hour},
151
-
{2 * humanize.Day, "1d %s", 1},
152
-
{20 * humanize.Day, "%dd %s", humanize.Day},
153
-
{8 * humanize.Week, "%dw %s", humanize.Week},
154
-
{humanize.Year, "%dmo %s", humanize.Month},
155
-
{18 * humanize.Month, "1y %s", 1},
156
-
{2 * humanize.Year, "2y %s", 1},
157
-
{humanize.LongTime, "%dy %s", humanize.Year},
158
-
{math.MaxInt64, "a long while %s", 1},
159
})
160
},
161
"longTimeFmt": func(t time.Time) string {
···
141
"relTimeFmt": humanize.Time,
142
"shortRelTimeFmt": func(t time.Time) string {
143
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
144
+
{D: time.Second, Format: "now", DivBy: time.Second},
145
+
{D: 2 * time.Second, Format: "1s %s", DivBy: 1},
146
+
{D: time.Minute, Format: "%ds %s", DivBy: time.Second},
147
+
{D: 2 * time.Minute, Format: "1min %s", DivBy: 1},
148
+
{D: time.Hour, Format: "%dmin %s", DivBy: time.Minute},
149
+
{D: 2 * time.Hour, Format: "1hr %s", DivBy: 1},
150
+
{D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour},
151
+
{D: 2 * humanize.Day, Format: "1d %s", DivBy: 1},
152
+
{D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day},
153
+
{D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week},
154
+
{D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month},
155
+
{D: 18 * humanize.Month, Format: "1y %s", DivBy: 1},
156
+
{D: 2 * humanize.Year, Format: "2y %s", DivBy: 1},
157
+
{D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year},
158
+
{D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
159
})
160
},
161
"longTimeFmt": func(t time.Time) string {
+30
appview/pages/funcmap_test.go
+30
appview/pages/funcmap_test.go
···
···
1
+
package pages
2
+
3
+
import (
4
+
"html/template"
5
+
"tangled.org/core/appview/config"
6
+
"tangled.org/core/idresolver"
7
+
"testing"
8
+
)
9
+
10
+
func TestPages_funcMap(t *testing.T) {
11
+
tests := []struct {
12
+
name string // description of this test case
13
+
// Named input parameters for receiver constructor.
14
+
config *config.Config
15
+
res *idresolver.Resolver
16
+
want template.FuncMap
17
+
}{
18
+
// TODO: Add test cases.
19
+
}
20
+
for _, tt := range tests {
21
+
t.Run(tt.name, func(t *testing.T) {
22
+
p := NewPages(tt.config, tt.res)
23
+
got := p.funcMap()
24
+
// TODO: update the condition below to compare got with tt.want.
25
+
if true {
26
+
t.Errorf("funcMap() = %v, want %v", got, tt.want)
27
+
}
28
+
})
29
+
}
30
+
}
+156
appview/pages/legal/privacy.md
+156
appview/pages/legal/privacy.md
···
···
1
+
**Last updated:** September 26, 2025
2
+
3
+
This Privacy Policy describes how Tangled ("we," "us," or "our")
4
+
collects, uses, and shares your personal information when you use our
5
+
platform and services (the "Service").
6
+
7
+
## 1. Information We Collect
8
+
9
+
### Account Information
10
+
11
+
When you create an account, we collect:
12
+
13
+
- Your chosen username
14
+
- Email address
15
+
- Profile information you choose to provide
16
+
- Authentication data
17
+
18
+
### Content and Activity
19
+
20
+
We store:
21
+
22
+
- Code repositories and associated metadata
23
+
- Issues, pull requests, and comments
24
+
- Activity logs and usage patterns
25
+
- Public keys for authentication
26
+
27
+
## 2. Data Location and Hosting
28
+
29
+
### EU Data Hosting
30
+
31
+
**All Tangled service data is hosted within the European Union.**
32
+
Specifically:
33
+
34
+
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
35
+
(*.tngl.sh) are located in Finland
36
+
- **Application Data:** All other service data is stored on EU-based
37
+
servers
38
+
- **Data Processing:** All data processing occurs within EU
39
+
jurisdiction
40
+
41
+
### External PDS Notice
42
+
43
+
**Important:** If your account is hosted on Bluesky's PDS or other
44
+
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
45
+
that data. The data protection, storage location, and privacy
46
+
practices for such accounts are governed by the respective PDS
47
+
provider's policies, not this Privacy Policy. We only control data
48
+
processing within our own services and infrastructure.
49
+
50
+
## 3. Third-Party Data Processors
51
+
52
+
We only share your data with the following third-party processors:
53
+
54
+
### Resend (Email Services)
55
+
56
+
- **Purpose:** Sending transactional emails (account verification,
57
+
notifications)
58
+
- **Data Shared:** Email address and necessary message content
59
+
60
+
### Cloudflare (Image Caching)
61
+
62
+
- **Purpose:** Caching and optimizing image delivery
63
+
- **Data Shared:** Public images and associated metadata for caching
64
+
purposes
65
+
66
+
### Posthog (Usage Metrics Tracking)
67
+
68
+
- **Purpose:** Tracking usage and platform metrics
69
+
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
70
+
information
71
+
72
+
## 4. How We Use Your Information
73
+
74
+
We use your information to:
75
+
76
+
- Provide and maintain the Service
77
+
- Process your transactions and requests
78
+
- Send you technical notices and support messages
79
+
- Improve and develop new features
80
+
- Ensure security and prevent fraud
81
+
- Comply with legal obligations
82
+
83
+
## 5. Data Sharing and Disclosure
84
+
85
+
We do not sell, trade, or rent your personal information. We may share
86
+
your information only in the following circumstances:
87
+
88
+
- With the third-party processors listed above
89
+
- When required by law or legal process
90
+
- To protect our rights, property, or safety, or that of our users
91
+
- In connection with a merger, acquisition, or sale of assets (with
92
+
appropriate protections)
93
+
94
+
## 6. Data Security
95
+
96
+
We implement appropriate technical and organizational measures to
97
+
protect your personal information against unauthorized access,
98
+
alteration, disclosure, or destruction. However, no method of
99
+
transmission over the Internet is 100% secure.
100
+
101
+
## 7. Data Retention
102
+
103
+
We retain your personal information for as long as necessary to provide
104
+
the Service and fulfill the purposes outlined in this Privacy Policy,
105
+
unless a longer retention period is required by law.
106
+
107
+
## 8. Your Rights
108
+
109
+
Under applicable data protection laws, you have the right to:
110
+
111
+
- Access your personal information
112
+
- Correct inaccurate information
113
+
- Request deletion of your information
114
+
- Object to processing of your information
115
+
- Data portability
116
+
- Withdraw consent (where applicable)
117
+
118
+
## 9. Cookies and Tracking
119
+
120
+
We use cookies and similar technologies to:
121
+
122
+
- Maintain your login session
123
+
- Remember your preferences
124
+
- Analyze usage patterns to improve the Service
125
+
126
+
You can control cookie settings through your browser preferences.
127
+
128
+
## 10. Children's Privacy
129
+
130
+
The Service is not intended for children under 16 years of age. We do
131
+
not knowingly collect personal information from children under 16. If
132
+
we become aware that we have collected such information, we will take
133
+
steps to delete it.
134
+
135
+
## 11. International Data Transfers
136
+
137
+
While all our primary data processing occurs within the EU, some of our
138
+
third-party processors may process data outside the EU. When this
139
+
occurs, we ensure appropriate safeguards are in place, such as Standard
140
+
Contractual Clauses or adequacy decisions.
141
+
142
+
## 12. Changes to This Privacy Policy
143
+
144
+
We may update this Privacy Policy from time to time. We will notify you
145
+
of any changes by posting the new Privacy Policy on this page and
146
+
updating the "Last updated" date.
147
+
148
+
## 13. Contact Information
149
+
150
+
If you have any questions about this Privacy Policy or wish to exercise
151
+
your rights, please contact us through our platform or via email.
152
+
153
+
---
154
+
155
+
This Privacy Policy complies with the EU General Data Protection
156
+
Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
+107
appview/pages/legal/terms.md
···
···
1
+
**Last updated:** September 26, 2025
2
+
3
+
Welcome to Tangled. These Terms of Service ("Terms") govern your access
4
+
to and use of the Tangled platform and services (the "Service")
5
+
operated by us ("Tangled," "we," "us," or "our").
6
+
7
+
## 1. Acceptance of Terms
8
+
9
+
By accessing or using our Service, you agree to be bound by these Terms.
10
+
If you disagree with any part of these terms, then you may not access
11
+
the Service.
12
+
13
+
## 2. Account Registration
14
+
15
+
To use certain features of the Service, you must register for an
16
+
account. You agree to provide accurate, current, and complete
17
+
information during the registration process and to update such
18
+
information to keep it accurate, current, and complete.
19
+
20
+
## 3. Account Termination
21
+
22
+
> **Important Notice**
23
+
>
24
+
> **We reserve the right to terminate, suspend, or restrict access to
25
+
> your account at any time, for any reason, or for no reason at all, at
26
+
> our sole discretion.** This includes, but is not limited to,
27
+
> termination for violation of these Terms, inappropriate conduct, spam,
28
+
> abuse, or any other behavior we deem harmful to the Service or other
29
+
> users.
30
+
>
31
+
> Account termination may result in the loss of access to your
32
+
> repositories, data, and other content associated with your account. We
33
+
> are not obligated to provide advance notice of termination, though we
34
+
> may do so in our discretion.
35
+
36
+
## 4. Acceptable Use
37
+
38
+
You agree not to use the Service to:
39
+
40
+
- Violate any applicable laws or regulations
41
+
- Infringe upon the rights of others
42
+
- Upload, store, or share content that is illegal, harmful, threatening,
43
+
abusive, harassing, defamatory, vulgar, obscene, or otherwise
44
+
objectionable
45
+
- Engage in spam, phishing, or other deceptive practices
46
+
- Attempt to gain unauthorized access to the Service or other users'
47
+
accounts
48
+
- Interfere with or disrupt the Service or servers connected to the
49
+
Service
50
+
51
+
## 5. Content and Intellectual Property
52
+
53
+
You retain ownership of the content you upload to the Service. By
54
+
uploading content, you grant us a non-exclusive, worldwide, royalty-free
55
+
license to use, reproduce, modify, and distribute your content as
56
+
necessary to provide the Service.
57
+
58
+
## 6. Privacy
59
+
60
+
Your privacy is important to us. Please review our [Privacy
61
+
Policy](/privacy), which also governs your use of the Service.
62
+
63
+
## 7. Disclaimers
64
+
65
+
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
66
+
no warranties, expressed or implied, and hereby disclaim and negate all
67
+
other warranties including without limitation, implied warranties or
68
+
conditions of merchantability, fitness for a particular purpose, or
69
+
non-infringement of intellectual property or other violation of rights.
70
+
71
+
## 8. Limitation of Liability
72
+
73
+
In no event shall Tangled, nor its directors, employees, partners,
74
+
agents, suppliers, or affiliates, be liable for any indirect,
75
+
incidental, special, consequential, or punitive damages, including
76
+
without limitation, loss of profits, data, use, goodwill, or other
77
+
intangible losses, resulting from your use of the Service.
78
+
79
+
## 9. Indemnification
80
+
81
+
You agree to defend, indemnify, and hold harmless Tangled and its
82
+
affiliates, officers, directors, employees, and agents from and against
83
+
any and all claims, damages, obligations, losses, liabilities, costs,
84
+
or debt, and expenses (including attorney's fees).
85
+
86
+
## 10. Governing Law
87
+
88
+
These Terms shall be interpreted and governed by the laws of Finland,
89
+
without regard to its conflict of law provisions.
90
+
91
+
## 11. Changes to Terms
92
+
93
+
We reserve the right to modify or replace these Terms at any time. If a
94
+
revision is material, we will try to provide at least 30 days notice
95
+
prior to any new terms taking effect.
96
+
97
+
## 12. Contact Information
98
+
99
+
If you have any questions about these Terms of Service, please contact
100
+
us through our platform or via email.
101
+
102
+
---
103
+
104
+
These terms are effective as of the last updated date shown above and
105
+
will remain in effect except with respect to any changes in their
106
+
provisions in the future, which will be in effect immediately after
107
+
being posted on this page.
+15
-17
appview/pages/markup/format.go
+15
-17
appview/pages/markup/format.go
···
1
package markup
2
3
-
import "strings"
4
5
type Format string
6
···
10
)
11
12
var FileTypes map[Format][]string = map[Format][]string{
13
-
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
14
}
15
16
-
// ReadmeFilenames contains the list of common README filenames to search for,
17
-
// in order of preference. Only includes well-supported formats.
18
-
var ReadmeFilenames = []string{
19
-
"README.md", "readme.md",
20
-
"README",
21
-
"readme",
22
-
"README.markdown",
23
-
"readme.markdown",
24
-
"README.txt",
25
-
"readme.txt",
26
}
27
28
func GetFormat(filename string) Format {
29
-
for format, extensions := range FileTypes {
30
-
for _, extension := range extensions {
31
-
if strings.HasSuffix(filename, extension) {
32
-
return format
33
-
}
34
}
35
}
36
// default format
···
1
package markup
2
3
+
import (
4
+
"regexp"
5
+
)
6
7
type Format string
8
···
12
)
13
14
var FileTypes map[Format][]string = map[Format][]string{
15
+
FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
16
}
17
18
+
var FileTypePatterns = map[Format]*regexp.Regexp{
19
+
FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`),
20
+
}
21
+
22
+
var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`)
23
+
24
+
func IsReadmeFile(filename string) bool {
25
+
return ReadmePattern.MatchString(filename)
26
}
27
28
func GetFormat(filename string) Format {
29
+
for format, pattern := range FileTypePatterns {
30
+
if pattern.MatchString(filename) {
31
+
return format
32
}
33
}
34
// default format
+109
-29
appview/pages/pages.go
+109
-29
appview/pages/pages.go
···
38
"github.com/go-git/go-git/v5/plumbing/object"
39
)
40
41
-
//go:embed templates/* static
42
var Files embed.FS
43
44
type Pages struct {
···
81
}
82
83
return p
84
-
}
85
-
86
-
func (p *Pages) pathToName(s string) string {
87
-
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
88
}
89
90
// reverse of pathToName
···
230
return p.executePlain("user/login", w, params)
231
}
232
233
-
func (p *Pages) Signup(w io.Writer) error {
234
-
return p.executePlain("user/signup", w, nil)
235
}
236
237
func (p *Pages) CompleteSignup(w io.Writer) error {
···
246
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
247
filename := "terms.md"
248
filePath := filepath.Join("legal", filename)
249
-
markdownBytes, err := os.ReadFile(filePath)
250
if err != nil {
251
return fmt.Errorf("failed to read %s: %w", filename, err)
252
}
···
267
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
268
filename := "privacy.md"
269
filePath := filepath.Join("legal", filename)
270
-
markdownBytes, err := os.ReadFile(filePath)
271
if err != nil {
272
return fmt.Errorf("failed to read %s: %w", filename, err)
273
}
···
280
return p.execute("legal/privacy", w, params)
281
}
282
283
type TimelineParams struct {
284
LoggedInUser *oauth.User
285
Timeline []models.TimelineEvent
286
Repos []models.Repo
287
}
288
289
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
290
return p.execute("timeline/timeline", w, params)
291
}
292
293
type UserProfileSettingsParams struct {
294
LoggedInUser *oauth.User
295
Tabs []map[string]any
···
300
return p.execute("user/settings/profile", w, params)
301
}
302
303
type UserKeysSettingsParams struct {
304
LoggedInUser *oauth.User
305
PubKeys []models.PublicKey
···
322
return p.execute("user/settings/emails", w, params)
323
}
324
325
type UpgradeBannerParams struct {
326
Registrations []models.Registration
327
Spindles []models.Spindle
···
488
489
type FollowCard struct {
490
UserDid string
491
FollowStatus models.FollowStatus
492
FollowersCount int64
493
FollowingCount int64
···
658
}
659
660
type RepoTreeParams struct {
661
-
LoggedInUser *oauth.User
662
-
RepoInfo repoinfo.RepoInfo
663
-
Active string
664
-
BreadCrumbs [][]string
665
-
TreePath string
666
-
Readme string
667
-
ReadmeFileName string
668
-
HTMLReadme template.HTML
669
-
Raw bool
670
types.RepoTreeResponse
671
}
672
···
694
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
695
params.Active = "overview"
696
697
-
if params.ReadmeFileName != "" {
698
-
params.ReadmeFileName = filepath.Base(params.ReadmeFileName)
699
700
ext := filepath.Ext(params.ReadmeFileName)
701
switch ext {
702
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
···
838
}
839
840
type RepoGeneralSettingsParams struct {
841
-
LoggedInUser *oauth.User
842
-
RepoInfo repoinfo.RepoInfo
843
-
Labels []models.LabelDefinition
844
-
DefaultLabels []models.LabelDefinition
845
-
SubscribedLabels map[string]struct{}
846
-
Active string
847
-
Tabs []map[string]any
848
-
Tab string
849
-
Branches []types.Branch
850
}
851
852
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
···
1023
FilteringBy models.PullState
1024
Stacks map[string]models.Stack
1025
Pipelines map[string]models.Pipeline
1026
}
1027
1028
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1062
OrderedReactionKinds []models.ReactionKind
1063
Reactions map[models.ReactionKind]int
1064
UserReacted map[models.ReactionKind]bool
1065
}
1066
1067
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
38
"github.com/go-git/go-git/v5/plumbing/object"
39
)
40
41
+
//go:embed templates/* static legal
42
var Files embed.FS
43
44
type Pages struct {
···
81
}
82
83
return p
84
}
85
86
// reverse of pathToName
···
226
return p.executePlain("user/login", w, params)
227
}
228
229
+
type SignupParams struct {
230
+
CloudflareSiteKey string
231
+
}
232
+
233
+
func (p *Pages) Signup(w io.Writer, params SignupParams) error {
234
+
return p.executePlain("user/signup", w, params)
235
}
236
237
func (p *Pages) CompleteSignup(w io.Writer) error {
···
246
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
247
filename := "terms.md"
248
filePath := filepath.Join("legal", filename)
249
+
250
+
file, err := p.embedFS.Open(filePath)
251
+
if err != nil {
252
+
return fmt.Errorf("failed to read %s: %w", filename, err)
253
+
}
254
+
defer file.Close()
255
+
256
+
markdownBytes, err := io.ReadAll(file)
257
if err != nil {
258
return fmt.Errorf("failed to read %s: %w", filename, err)
259
}
···
274
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
275
filename := "privacy.md"
276
filePath := filepath.Join("legal", filename)
277
+
278
+
file, err := p.embedFS.Open(filePath)
279
+
if err != nil {
280
+
return fmt.Errorf("failed to read %s: %w", filename, err)
281
+
}
282
+
defer file.Close()
283
+
284
+
markdownBytes, err := io.ReadAll(file)
285
if err != nil {
286
return fmt.Errorf("failed to read %s: %w", filename, err)
287
}
···
294
return p.execute("legal/privacy", w, params)
295
}
296
297
+
type BrandParams struct {
298
+
LoggedInUser *oauth.User
299
+
}
300
+
301
+
func (p *Pages) Brand(w io.Writer, params BrandParams) error {
302
+
return p.execute("brand/brand", w, params)
303
+
}
304
+
305
type TimelineParams struct {
306
LoggedInUser *oauth.User
307
Timeline []models.TimelineEvent
308
Repos []models.Repo
309
+
GfiLabel *models.LabelDefinition
310
}
311
312
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
313
return p.execute("timeline/timeline", w, params)
314
}
315
316
+
type GoodFirstIssuesParams struct {
317
+
LoggedInUser *oauth.User
318
+
Issues []models.Issue
319
+
RepoGroups []*models.RepoGroup
320
+
LabelDefs map[string]*models.LabelDefinition
321
+
GfiLabel *models.LabelDefinition
322
+
Page pagination.Page
323
+
}
324
+
325
+
func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
326
+
return p.execute("goodfirstissues/index", w, params)
327
+
}
328
+
329
type UserProfileSettingsParams struct {
330
LoggedInUser *oauth.User
331
Tabs []map[string]any
···
336
return p.execute("user/settings/profile", w, params)
337
}
338
339
+
type NotificationsParams struct {
340
+
LoggedInUser *oauth.User
341
+
Notifications []*models.NotificationWithEntity
342
+
UnreadCount int
343
+
Page pagination.Page
344
+
Total int64
345
+
}
346
+
347
+
func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
348
+
return p.execute("notifications/list", w, params)
349
+
}
350
+
351
+
type NotificationItemParams struct {
352
+
Notification *models.Notification
353
+
}
354
+
355
+
func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error {
356
+
return p.executePlain("notifications/fragments/item", w, params)
357
+
}
358
+
359
+
type NotificationCountParams struct {
360
+
Count int64
361
+
}
362
+
363
+
func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
364
+
return p.executePlain("notifications/fragments/count", w, params)
365
+
}
366
+
367
type UserKeysSettingsParams struct {
368
LoggedInUser *oauth.User
369
PubKeys []models.PublicKey
···
386
return p.execute("user/settings/emails", w, params)
387
}
388
389
+
type UserNotificationSettingsParams struct {
390
+
LoggedInUser *oauth.User
391
+
Preferences *models.NotificationPreferences
392
+
Tabs []map[string]any
393
+
Tab string
394
+
}
395
+
396
+
func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error {
397
+
return p.execute("user/settings/notifications", w, params)
398
+
}
399
+
400
type UpgradeBannerParams struct {
401
Registrations []models.Registration
402
Spindles []models.Spindle
···
563
564
type FollowCard struct {
565
UserDid string
566
+
LoggedInUser *oauth.User
567
FollowStatus models.FollowStatus
568
FollowersCount int64
569
FollowingCount int64
···
734
}
735
736
type RepoTreeParams struct {
737
+
LoggedInUser *oauth.User
738
+
RepoInfo repoinfo.RepoInfo
739
+
Active string
740
+
BreadCrumbs [][]string
741
+
TreePath string
742
+
Raw bool
743
+
HTMLReadme template.HTML
744
types.RepoTreeResponse
745
}
746
···
768
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
769
params.Active = "overview"
770
771
+
p.rctx.RepoInfo = params.RepoInfo
772
+
p.rctx.RepoInfo.Ref = params.Ref
773
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
774
775
+
if params.ReadmeFileName != "" {
776
ext := filepath.Ext(params.ReadmeFileName)
777
switch ext {
778
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
···
914
}
915
916
type RepoGeneralSettingsParams struct {
917
+
LoggedInUser *oauth.User
918
+
RepoInfo repoinfo.RepoInfo
919
+
Labels []models.LabelDefinition
920
+
DefaultLabels []models.LabelDefinition
921
+
SubscribedLabels map[string]struct{}
922
+
ShouldSubscribeAll bool
923
+
Active string
924
+
Tabs []map[string]any
925
+
Tab string
926
+
Branches []types.Branch
927
}
928
929
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
···
1100
FilteringBy models.PullState
1101
Stacks map[string]models.Stack
1102
Pipelines map[string]models.Pipeline
1103
+
LabelDefs map[string]*models.LabelDefinition
1104
}
1105
1106
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1140
OrderedReactionKinds []models.ReactionKind
1141
Reactions map[models.ReactionKind]int
1142
UserReacted map[models.ReactionKind]bool
1143
+
1144
+
LabelDefs map[string]*models.LabelDefinition
1145
}
1146
1147
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
+224
appview/pages/templates/brand/brand.html
+224
appview/pages/templates/brand/brand.html
···
···
1
+
{{ define "title" }}brand{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Assets and guidelines for using Tangled's logo and brand elements.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="space-y-16">
14
+
15
+
<!-- Introduction Section -->
16
+
<section>
17
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
18
+
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
+
follow the below guidelines when using Dolly and the logotype.
20
+
</p>
21
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
22
+
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
+
</p>
24
+
</section>
25
+
26
+
<!-- Black Logotype Section -->
27
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
28
+
<div class="order-2 lg:order-1">
29
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
30
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
31
+
alt="Tangled logo - black version"
32
+
class="w-full max-w-sm mx-auto" />
33
+
</div>
34
+
</div>
35
+
<div class="order-1 lg:order-2">
36
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
38
+
<p class="text-gray-700 dark:text-gray-300">
39
+
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
+
backgrounds and designs.
41
+
</p>
42
+
</div>
43
+
</section>
44
+
45
+
<!-- White Logotype Section -->
46
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
47
+
<div class="order-2 lg:order-1">
48
+
<div class="bg-black p-8 sm:p-16 rounded">
49
+
<img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg"
50
+
alt="Tangled logo - white version"
51
+
class="w-full max-w-sm mx-auto" />
52
+
</div>
53
+
</div>
54
+
<div class="order-1 lg:order-2">
55
+
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
57
+
<p class="text-gray-700 dark:text-gray-300">
58
+
This version features white text and elements, ideal for dark backgrounds
59
+
and inverted designs.
60
+
</p>
61
+
</div>
62
+
</section>
63
+
64
+
<!-- Mark Only Section -->
65
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
66
+
<div class="order-2 lg:order-1">
67
+
<div class="grid grid-cols-2 gap-2">
68
+
<!-- Black mark on light background -->
69
+
<div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded">
70
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
71
+
alt="Dolly face - black version"
72
+
class="w-full max-w-16 mx-auto" />
73
+
</div>
74
+
<!-- White mark on dark background -->
75
+
<div class="bg-black p-8 sm:p-12 rounded">
76
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
77
+
alt="Dolly face - white version"
78
+
class="w-full max-w-16 mx-auto" />
79
+
</div>
80
+
</div>
81
+
</div>
82
+
<div class="order-1 lg:order-2">
83
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
85
+
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
+
</p>
87
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
88
+
<strong class="font-semibold">Note</strong>: for situations where the background
89
+
is unknown, use the black version for ideal contrast in most environments.
90
+
</p>
91
+
</div>
92
+
</section>
93
+
94
+
<!-- Colored Backgrounds Section -->
95
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
96
+
<div class="order-2 lg:order-1">
97
+
<div class="grid grid-cols-2 gap-2">
98
+
<!-- Pastel Green background -->
99
+
<div class="bg-green-500 p-8 sm:p-12 rounded">
100
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
101
+
alt="Tangled logo on pastel green background"
102
+
class="w-full max-w-16 mx-auto" />
103
+
</div>
104
+
<!-- Pastel Blue background -->
105
+
<div class="bg-blue-500 p-8 sm:p-12 rounded">
106
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
107
+
alt="Tangled logo on pastel blue background"
108
+
class="w-full max-w-16 mx-auto" />
109
+
</div>
110
+
<!-- Pastel Yellow background -->
111
+
<div class="bg-yellow-500 p-8 sm:p-12 rounded">
112
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
113
+
alt="Tangled logo on pastel yellow background"
114
+
class="w-full max-w-16 mx-auto" />
115
+
</div>
116
+
<!-- Pastel Red background -->
117
+
<div class="bg-red-500 p-8 sm:p-12 rounded">
118
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
119
+
alt="Tangled logo on pastel red background"
120
+
class="w-full max-w-16 mx-auto" />
121
+
</div>
122
+
</div>
123
+
</div>
124
+
<div class="order-1 lg:order-2">
125
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
127
+
White logo mark on colored backgrounds.
128
+
</p>
129
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
130
+
The white logo mark provides contrast on colored backgrounds.
131
+
Perfect for more fun design contexts.
132
+
</p>
133
+
</div>
134
+
</section>
135
+
136
+
<!-- Black Logo on Pastel Backgrounds Section -->
137
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
138
+
<div class="order-2 lg:order-1">
139
+
<div class="grid grid-cols-2 gap-2">
140
+
<!-- Pastel Green background -->
141
+
<div class="bg-green-200 p-8 sm:p-12 rounded">
142
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
143
+
alt="Tangled logo on pastel green background"
144
+
class="w-full max-w-16 mx-auto" />
145
+
</div>
146
+
<!-- Pastel Blue background -->
147
+
<div class="bg-blue-200 p-8 sm:p-12 rounded">
148
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
149
+
alt="Tangled logo on pastel blue background"
150
+
class="w-full max-w-16 mx-auto" />
151
+
</div>
152
+
<!-- Pastel Yellow background -->
153
+
<div class="bg-yellow-200 p-8 sm:p-12 rounded">
154
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
155
+
alt="Tangled logo on pastel yellow background"
156
+
class="w-full max-w-16 mx-auto" />
157
+
</div>
158
+
<!-- Pastel Pink background -->
159
+
<div class="bg-pink-200 p-8 sm:p-12 rounded">
160
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
161
+
alt="Tangled logo on pastel pink background"
162
+
class="w-full max-w-16 mx-auto" />
163
+
</div>
164
+
</div>
165
+
</div>
166
+
<div class="order-1 lg:order-2">
167
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
169
+
Dark logo mark on lighter, pastel backgrounds.
170
+
</p>
171
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
172
+
The dark logo mark works beautifully on pastel backgrounds,
173
+
providing crisp contrast.
174
+
</p>
175
+
</div>
176
+
</section>
177
+
178
+
<!-- Recoloring Section -->
179
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
180
+
<div class="order-2 lg:order-1">
181
+
<div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded">
182
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
183
+
alt="Recolored Tangled logotype in gray/sand color"
184
+
class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" />
185
+
</div>
186
+
</div>
187
+
<div class="order-1 lg:order-2">
188
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
190
+
Custom coloring of the logotype is permitted.
191
+
</p>
192
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
193
+
Recoloring the logotype is allowed as long as readability is maintained.
194
+
</p>
195
+
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
+
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
+
</p>
198
+
</div>
199
+
</section>
200
+
201
+
<!-- Silhouette Section -->
202
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
203
+
<div class="order-2 lg:order-1">
204
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
205
+
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
206
+
alt="Dolly silhouette"
207
+
class="w-full max-w-32 mx-auto" />
208
+
</div>
209
+
</div>
210
+
<div class="order-1 lg:order-2">
211
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
212
+
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
213
+
<p class="text-gray-700 dark:text-gray-300">
214
+
The silhouette can be used where a subtle brand presence is needed,
215
+
or as a background element. Works on any background color with proper contrast.
216
+
For example, we use this as the site's favicon.
217
+
</p>
218
+
</div>
219
+
</section>
220
+
221
+
</div>
222
+
</main>
223
+
</div>
224
+
{{ end }}
+4
-11
appview/pages/templates/errors/500.html
+4
-11
appview/pages/templates/errors/500.html
···
5
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
<div class="mb-6">
7
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
</div>
10
</div>
11
···
14
500 — internal server error
15
</h1>
16
<p class="text-gray-600 dark:text-gray-300">
17
-
Something went wrong on our end. We've been notified and are working to fix the issue.
18
-
</p>
19
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
20
-
<div class="flex items-center gap-2">
21
-
{{ i "info" "w-4 h-4" }}
22
-
<span class="font-medium">we're on it!</span>
23
-
</div>
24
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
-
</div>
26
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
<button onclick="location.reload()" class="btn-create gap-2">
28
{{ i "refresh-cw" "w-4 h-4" }}
29
try again
30
</button>
31
<a href="/" class="btn no-underline hover:no-underline gap-2">
32
-
{{ i "home" "w-4 h-4" }}
33
back to home
34
</a>
35
</div>
···
5
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
<div class="mb-6">
7
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
+
{{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
</div>
10
</div>
11
···
14
500 — internal server error
15
</h1>
16
<p class="text-gray-600 dark:text-gray-300">
17
+
We encountered an error while processing your request. Please try again later.
18
+
</p>
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
<button onclick="location.reload()" class="btn-create gap-2">
21
{{ i "refresh-cw" "w-4 h-4" }}
22
try again
23
</button>
24
<a href="/" class="btn no-underline hover:no-underline gap-2">
25
+
{{ i "arrow-left" "w-4 h-4" }}
26
back to home
27
</a>
28
</div>
+167
appview/pages/templates/goodfirstissues/index.html
+167
appview/pages/templates/goodfirstissues/index.html
···
···
1
+
{{ define "title" }}good first issues{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="good first issues · tangled" />
5
+
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.org/goodfirstissues" />
7
+
<meta property="og:description" content="Find good first issues to contribute to open source projects" />
8
+
{{ end }}
9
+
10
+
{{ define "content" }}
11
+
<div class="grid grid-cols-10">
12
+
<header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8">
13
+
<h1 class="scale-150 dark:text-white mb-4">
14
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
15
+
</h1>
16
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
17
+
Find beginner-friendly issues across all repositories to get started with open source contributions.
18
+
</p>
19
+
</header>
20
+
21
+
<div class="col-span-full md:col-span-10 space-y-6">
22
+
{{ if eq (len .RepoGroups) 0 }}
23
+
<div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
24
+
<div class="text-center py-16">
25
+
<div class="text-gray-500 dark:text-gray-400 mb-4">
26
+
{{ i "circle-dot" "w-16 h-16 mx-auto" }}
27
+
</div>
28
+
<h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3>
29
+
<p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto">
30
+
There are currently no open issues labeled as "good-first-issue" across all repositories.
31
+
</p>
32
+
<p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto">
33
+
Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started.
34
+
</p>
35
+
</div>
36
+
</div>
37
+
{{ else }}
38
+
{{ range .RepoGroups }}
39
+
<div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800">
40
+
<div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap">
41
+
<div class="font-medium dark:text-white flex items-center justify-between">
42
+
<div class="flex items-center min-w-0 flex-1 mr-2">
43
+
{{ if .Repo.Source }}
44
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
45
+
{{ else }}
46
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
47
+
{{ end }}
48
+
{{ $repoOwner := resolve .Repo.Did }}
49
+
<a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a>
50
+
</div>
51
+
</div>
52
+
53
+
54
+
{{ if .Repo.RepoStats }}
55
+
<div class="text-gray-400 text-sm font-mono inline-flex gap-4">
56
+
{{ with .Repo.RepoStats.Language }}
57
+
<div class="flex gap-2 items-center text-sm">
58
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
59
+
<span>{{ . }}</span>
60
+
</div>
61
+
{{ end }}
62
+
{{ with .Repo.RepoStats.StarCount }}
63
+
<div class="flex gap-1 items-center text-sm">
64
+
{{ i "star" "w-3 h-3 fill-current" }}
65
+
<span>{{ . }}</span>
66
+
</div>
67
+
{{ end }}
68
+
{{ with .Repo.RepoStats.IssueCount.Open }}
69
+
<div class="flex gap-1 items-center text-sm">
70
+
{{ i "circle-dot" "w-3 h-3" }}
71
+
<span>{{ . }}</span>
72
+
</div>
73
+
{{ end }}
74
+
{{ with .Repo.RepoStats.PullCount.Open }}
75
+
<div class="flex gap-1 items-center text-sm">
76
+
{{ i "git-pull-request" "w-3 h-3" }}
77
+
<span>{{ . }}</span>
78
+
</div>
79
+
{{ end }}
80
+
</div>
81
+
{{ end }}
82
+
</div>
83
+
84
+
{{ with .Repo.Description }}
85
+
<div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
86
+
{{ . | description }}
87
+
</div>
88
+
{{ end }}
89
+
90
+
{{ if gt (len .Issues) 0 }}
91
+
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
92
+
{{ range .Issues }}
93
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
94
+
<div class="py-2 px-6">
95
+
<div class="flex-grow min-w-0 w-full">
96
+
<div class="flex text-sm items-center justify-between w-full">
97
+
<div class="flex items-center gap-2 min-w-0 flex-1 pr-2">
98
+
<span class="truncate text-sm text-gray-800 dark:text-gray-200">
99
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
100
+
{{ .Title | description }}
101
+
</span>
102
+
</div>
103
+
<div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400">
104
+
<span>
105
+
<div class="inline-flex items-center gap-1">
106
+
{{ i "message-square" "w-3 h-3" }}
107
+
{{ len .Comments }}
108
+
</div>
109
+
</span>
110
+
<span class="before:content-['·'] before:select-none"></span>
111
+
<span class="text-sm">
112
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
113
+
</span>
114
+
<div class="hidden md:inline-flex md:gap-1">
115
+
{{ $labelState := .Labels }}
116
+
{{ range $k, $d := $.LabelDefs }}
117
+
{{ range $v, $s := $labelState.GetValSet $d.AtUri.String }}
118
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
119
+
{{ end }}
120
+
{{ end }}
121
+
</div>
122
+
</div>
123
+
</div>
124
+
</div>
125
+
</div>
126
+
</a>
127
+
{{ end }}
128
+
</div>
129
+
{{ end }}
130
+
</div>
131
+
{{ end }}
132
+
133
+
{{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }}
134
+
<div class="flex justify-center mt-8">
135
+
<div class="flex gap-2">
136
+
{{ if gt .Page.Offset 0 }}
137
+
{{ $prev := .Page.Previous }}
138
+
<a
139
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
140
+
hx-boost="true"
141
+
href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
142
+
>
143
+
{{ i "chevron-left" "w-4 h-4" }}
144
+
previous
145
+
</a>
146
+
{{ else }}
147
+
<div></div>
148
+
{{ end }}
149
+
150
+
{{ if eq (len .RepoGroups) .Page.Limit }}
151
+
{{ $next := .Page.Next }}
152
+
<a
153
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
154
+
hx-boost="true"
155
+
href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
156
+
>
157
+
next
158
+
{{ i "chevron-right" "w-4 h-4" }}
159
+
</a>
160
+
{{ end }}
161
+
</div>
162
+
</div>
163
+
{{ end }}
164
+
{{ end }}
165
+
</div>
166
+
</div>
167
+
{{ end }}
+1
-1
appview/pages/templates/labels/fragments/label.html
+1
-1
appview/pages/templates/labels/fragments/label.html
···
2
{{ $d := .def }}
3
{{ $v := .val }}
4
{{ $withPrefix := .withPrefix }}
5
-
<span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
8
{{ $lhs := printf "%s" $d.Name }}
···
2
{{ $d := .def }}
3
{{ $v := .val }}
4
{{ $withPrefix := .withPrefix }}
5
+
<span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
8
{{ $lhs := printf "%s" $d.Name }}
+16
-12
appview/pages/templates/layouts/base.html
+16
-12
appview/pages/templates/layouts/base.html
···
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
<link rel="preconnect" href="https://camo.tangled.sh" />
16
17
<!-- preload main font -->
18
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
19
···
21
<title>{{ block "title" . }}{{ end }} · tangled</title>
22
{{ block "extrameta" . }}{{ end }}
23
</head>
24
-
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"
25
-
style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);">
26
{{ block "topbarLayout" . }}
27
-
<header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
28
29
{{ if .LoggedInUser }}
30
<div id="upgrade-banner"
···
38
{{ end }}
39
40
{{ block "mainLayout" . }}
41
-
<div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4">
42
-
{{ block "contentLayout" . }}
43
-
<main class="col-span-1 md:col-span-8">
44
{{ block "content" . }}{{ end }}
45
</main>
46
-
{{ end }}
47
-
48
-
{{ block "contentAfterLayout" . }}
49
-
<main class="col-span-1 md:col-span-8">
50
{{ block "contentAfter" . }}{{ end }}
51
</main>
52
-
{{ end }}
53
</div>
54
{{ end }}
55
56
{{ block "footerLayout" . }}
57
-
<footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12">
58
{{ template "layouts/fragments/footer" . }}
59
</footer>
60
{{ end }}
···
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
<link rel="preconnect" href="https://camo.tangled.sh" />
16
17
+
<!-- pwa manifest -->
18
+
<link rel="manifest" href="/pwa-manifest.json" />
19
+
20
<!-- preload main font -->
21
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
22
···
24
<title>{{ block "title" . }}{{ end }} · tangled</title>
25
{{ block "extrameta" . }}{{ end }}
26
</head>
27
+
<body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
28
{{ block "topbarLayout" . }}
29
+
<header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
30
31
{{ if .LoggedInUser }}
32
<div id="upgrade-banner"
···
40
{{ end }}
41
42
{{ block "mainLayout" . }}
43
+
<div class="flex-grow">
44
+
<div class="max-w-screen-lg mx-auto flex flex-col gap-4">
45
+
{{ block "contentLayout" . }}
46
+
<main>
47
{{ block "content" . }}{{ end }}
48
</main>
49
+
{{ end }}
50
+
51
+
{{ block "contentAfterLayout" . }}
52
+
<main>
53
{{ block "contentAfter" . }}{{ end }}
54
</main>
55
+
{{ end }}
56
+
</div>
57
</div>
58
{{ end }}
59
60
{{ block "footerLayout" . }}
61
+
<footer class="bg-white dark:bg-gray-800 mt-12">
62
{{ template "layouts/fragments/footer" . }}
63
</footer>
64
{{ end }}
+17
-7
appview/pages/templates/layouts/fragments/topbar.html
+17
-7
appview/pages/templates/layouts/fragments/topbar.html
···
1
{{ define "layouts/fragments/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
-
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline">
6
-
{{ template "fragments/logotypeSmall" }}
7
</a>
8
</div>
9
10
-
<div id="right-items" class="flex items-center gap-2">
11
{{ with .LoggedInUser }}
12
{{ block "newButton" . }} {{ end }}
13
{{ block "dropDown" . }} {{ end }}
14
{{ else }}
15
<a href="/login">login</a>
···
26
{{ define "newButton" }}
27
<details class="relative inline-block text-left nav-dropdown">
28
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
-
{{ i "plus" "w-4 h-4" }} new
30
</summary>
31
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
32
<a href="/repo/new" class="flex items-center gap-2">
···
44
{{ define "dropDown" }}
45
<details class="relative inline-block text-left nav-dropdown">
46
<summary
47
-
class="cursor-pointer list-none flex items-center"
48
>
49
{{ $user := didOrHandle .Did .Handle }}
50
-
{{ template "user/fragments/picHandle" $user }}
51
</summary>
52
<div
53
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
···
1
{{ define "layouts/fragments/topbar" }}
2
+
<nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm">
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
+
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
6
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
7
+
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
8
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
9
+
alpha
10
+
</span>
11
</a>
12
</div>
13
14
+
<div id="right-items" class="flex items-center gap-4">
15
{{ with .LoggedInUser }}
16
{{ block "newButton" . }} {{ end }}
17
+
{{ template "notifications/fragments/bell" }}
18
{{ block "dropDown" . }} {{ end }}
19
{{ else }}
20
<a href="/login">login</a>
···
31
{{ define "newButton" }}
32
<details class="relative inline-block text-left nav-dropdown">
33
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
34
+
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
35
</summary>
36
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
37
<a href="/repo/new" class="flex items-center gap-2">
···
49
{{ define "dropDown" }}
50
<details class="relative inline-block text-left nav-dropdown">
51
<summary
52
+
class="cursor-pointer list-none flex items-center gap-1"
53
>
54
{{ $user := didOrHandle .Did .Handle }}
55
+
<img
56
+
src="{{ tinyAvatar $user }}"
57
+
alt=""
58
+
class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700"
59
+
/>
60
+
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
61
</summary>
62
<div
63
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+13
-6
appview/pages/templates/legal/privacy.html
+13
-6
appview/pages/templates/legal/privacy.html
···
1
{{ define "title" }}privacy policy{{ end }}
2
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
9
</div>
10
</div>
11
-
{{ end }}
···
1
{{ define "title" }}privacy policy{{ end }}
2
3
{{ define "content" }}
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Learn how we collect, use, and protect your personal information.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
15
</div>
16
+
</main>
17
</div>
18
+
{{ end }}
+13
-6
appview/pages/templates/legal/terms.html
+13
-6
appview/pages/templates/legal/terms.html
···
1
{{ define "title" }}terms of service{{ end }}
2
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
9
</div>
10
</div>
11
-
{{ end }}
···
1
{{ define "title" }}terms of service{{ end }}
2
3
{{ define "content" }}
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
A few things you should know.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
15
</div>
16
+
</main>
17
</div>
18
+
{{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
+11
appview/pages/templates/notifications/fragments/bell.html
···
···
1
+
{{define "notifications/fragments/bell"}}
2
+
<div class="relative"
3
+
hx-get="/notifications/count"
4
+
hx-target="#notification-count"
5
+
hx-trigger="load, every 30s">
6
+
<a href="/notifications" class="text-gray-500 dark:text-gray-400 flex gap-1 items-end group">
7
+
{{ i "bell" "w-5 h-5" }}
8
+
<span id="notification-count"></span>
9
+
</a>
10
+
</div>
11
+
{{end}}
+7
appview/pages/templates/notifications/fragments/count.html
+7
appview/pages/templates/notifications/fragments/count.html
···
···
1
+
{{define "notifications/fragments/count"}}
2
+
{{if and .Count (gt .Count 0)}}
3
+
<span class="absolute -top-1.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center">
4
+
{{if gt .Count 99}}99+{{else}}{{.Count}}{{end}}
5
+
</span>
6
+
{{end}}
7
+
{{end}}
+81
appview/pages/templates/notifications/fragments/item.html
+81
appview/pages/templates/notifications/fragments/item.html
···
···
1
+
{{define "notifications/fragments/item"}}
2
+
<a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline">
3
+
<div
4
+
class="
5
+
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
6
+
{{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}}
7
+
flex gap-2 items-center
8
+
">
9
+
{{ template "notificationIcon" . }}
10
+
<div class="flex-1 w-full flex flex-col gap-1">
11
+
<span>{{ template "notificationHeader" . }}</span>
12
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
13
+
</div>
14
+
15
+
</div>
16
+
</a>
17
+
{{end}}
18
+
19
+
{{ define "notificationIcon" }}
20
+
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
21
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" />
22
+
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10">
23
+
{{ i .Icon "size-3 text-black dark:text-white" }}
24
+
</div>
25
+
</div>
26
+
{{ end }}
27
+
28
+
{{ define "notificationHeader" }}
29
+
{{ $actor := resolve .ActorDid }}
30
+
31
+
<span class="text-black dark:text-white w-fit">{{ $actor }}</span>
32
+
{{ if eq .Type "repo_starred" }}
33
+
starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span>
34
+
{{ else if eq .Type "issue_created" }}
35
+
opened an issue
36
+
{{ else if eq .Type "issue_commented" }}
37
+
commented on an issue
38
+
{{ else if eq .Type "issue_closed" }}
39
+
closed an issue
40
+
{{ else if eq .Type "pull_created" }}
41
+
created a pull request
42
+
{{ else if eq .Type "pull_commented" }}
43
+
commented on a pull request
44
+
{{ else if eq .Type "pull_merged" }}
45
+
merged a pull request
46
+
{{ else if eq .Type "pull_closed" }}
47
+
closed a pull request
48
+
{{ else if eq .Type "followed" }}
49
+
followed you
50
+
{{ else }}
51
+
{{ end }}
52
+
{{ end }}
53
+
54
+
{{ define "notificationSummary" }}
55
+
{{ if eq .Type "repo_starred" }}
56
+
<!-- no summary -->
57
+
{{ else if .Issue }}
58
+
#{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
59
+
{{ else if .Pull }}
60
+
#{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
61
+
{{ else if eq .Type "followed" }}
62
+
<!-- no summary -->
63
+
{{ else }}
64
+
{{ end }}
65
+
{{ end }}
66
+
67
+
{{ define "notificationUrl" }}
68
+
{{ $url := "" }}
69
+
{{ if eq .Type "repo_starred" }}
70
+
{{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
71
+
{{ else if .Issue }}
72
+
{{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
73
+
{{ else if .Pull }}
74
+
{{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
75
+
{{ else if eq .Type "followed" }}
76
+
{{$url = printf "/%s" (resolve .ActorDid)}}
77
+
{{ else }}
78
+
{{ end }}
79
+
80
+
{{ $url }}
81
+
{{ end }}
+65
appview/pages/templates/notifications/list.html
+65
appview/pages/templates/notifications/list.html
···
···
1
+
{{ define "title" }}notifications{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="px-6 py-4">
5
+
<div class="flex items-center justify-between">
6
+
<p class="text-xl font-bold dark:text-white">Notifications</p>
7
+
<a href="/settings/notifications" class="flex items-center gap-2">
8
+
{{ i "settings" "w-4 h-4" }}
9
+
preferences
10
+
</a>
11
+
</div>
12
+
</div>
13
+
14
+
{{if .Notifications}}
15
+
<div class="flex flex-col gap-2" id="notifications-list">
16
+
{{range .Notifications}}
17
+
{{template "notifications/fragments/item" .}}
18
+
{{end}}
19
+
</div>
20
+
21
+
{{else}}
22
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
23
+
<div class="text-center py-12">
24
+
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
25
+
{{ i "bell-off" "w-16 h-16" }}
26
+
</div>
27
+
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
28
+
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
29
+
</div>
30
+
</div>
31
+
{{end}}
32
+
33
+
{{ template "pagination" . }}
34
+
{{ end }}
35
+
36
+
{{ define "pagination" }}
37
+
<div class="flex justify-end mt-4 gap-2">
38
+
{{ if gt .Page.Offset 0 }}
39
+
{{ $prev := .Page.Previous }}
40
+
<a
41
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
42
+
hx-boost="true"
43
+
href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
44
+
>
45
+
{{ i "chevron-left" "w-4 h-4" }}
46
+
previous
47
+
</a>
48
+
{{ else }}
49
+
<div></div>
50
+
{{ end }}
51
+
52
+
{{ $next := .Page.Next }}
53
+
{{ if lt $next.Offset .Total }}
54
+
{{ $next := .Page.Next }}
55
+
<a
56
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
57
+
hx-boost="true"
58
+
href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
59
+
>
60
+
next
61
+
{{ i "chevron-right" "w-4 h-4" }}
62
+
</a>
63
+
{{ end }}
64
+
</div>
65
+
{{ end }}
+7
appview/pages/templates/repo/fork.html
+7
appview/pages/templates/repo/fork.html
···
6
</div>
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
<fieldset class="space-y-3">
10
<legend class="dark:text-white">Select a knot to fork into</legend>
11
<div class="space-y-2">
···
6
</div>
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
+
10
+
<fieldset class="space-y-3">
11
+
<legend for="repo_name" class="dark:text-white">Repository name</legend>
12
+
<input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}"
13
+
class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" />
14
+
</fieldset>
15
+
16
<fieldset class="space-y-3">
17
<legend class="dark:text-white">Select a knot to fork into</legend>
18
<div class="space-y-2">
+1
-1
appview/pages/templates/repo/fragments/cloneDropdown.html
+1
-1
appview/pages/templates/repo/fragments/cloneDropdown.html
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+26
appview/pages/templates/repo/fragments/participants.html
+26
appview/pages/templates/repo/fragments/participants.html
···
···
1
+
{{ define "repo/fragments/participants" }}
2
+
{{ $all := . }}
3
+
{{ $ps := take $all 5 }}
4
+
<div class="px-6 md:px-0">
5
+
<div class="py-1 flex items-center text-sm">
6
+
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
7
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
8
+
</div>
9
+
<div class="flex items-center -space-x-3 mt-2">
10
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
11
+
{{ range $i, $p := $ps }}
12
+
<img
13
+
src="{{ tinyAvatar . }}"
14
+
alt=""
15
+
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
16
+
/>
17
+
{{ end }}
18
+
19
+
{{ if gt (len $all) 5 }}
20
+
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
21
+
+{{ sub (len $all) 5 }}
22
+
</span>
23
+
{{ end }}
24
+
</div>
25
+
</div>
26
+
{{ end }}
+2
-2
appview/pages/templates/repo/fragments/readme.html
+2
-2
appview/pages/templates/repo/fragments/readme.html
···
1
{{ define "repo/fragments/readme" }}
2
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
3
{{- if .ReadmeFileName -}}
4
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
5
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
6
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
7
</div>
8
{{- end -}}
9
<section
10
-
class="p-6 overflow-auto {{ if not .Raw }}
11
prose dark:prose-invert dark:[&_pre]:bg-gray-900
12
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
13
dark:[&_pre]:border dark:[&_pre]:border-gray-700
···
1
{{ define "repo/fragments/readme" }}
2
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
3
{{- if .ReadmeFileName -}}
4
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
5
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
6
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
7
</div>
8
{{- end -}}
9
<section
10
+
class="px-6 pb-6 overflow-auto {{ if not .Raw }}
11
prose dark:prose-invert dark:[&_pre]:bg-gray-900
12
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
13
dark:[&_pre]:border dark:[&_pre]:border-gray-700
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
···
···
1
+
{{ define "repo/issues/fragments/globalIssueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2 mb-3">
6
+
<div class="flex items-center gap-3 mb-2">
7
+
<a
8
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}"
9
+
class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm"
10
+
>
11
+
{{ resolve .Repo.Did }}/{{ .Repo.Name }}
12
+
</a>
13
+
</div>
14
+
<a
15
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}"
16
+
class="no-underline hover:underline"
17
+
>
18
+
{{ .Title | description }}
19
+
<span class="text-gray-500">#{{ .IssueId }}</span>
20
+
</a>
21
+
</div>
22
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
23
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
24
+
{{ $icon := "ban" }}
25
+
{{ $state := "closed" }}
26
+
{{ if .Open }}
27
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
28
+
{{ $icon = "circle-dot" }}
29
+
{{ $state = "open" }}
30
+
{{ end }}
31
+
32
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
33
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
34
+
<span class="text-white dark:text-white">{{ $state }}</span>
35
+
</span>
36
+
37
+
<span class="ml-1">
38
+
{{ template "user/fragments/picHandleLink" .Did }}
39
+
</span>
40
+
41
+
<span class="before:content-['·']">
42
+
{{ template "repo/fragments/time" .Created }}
43
+
</span>
44
+
45
+
<span class="before:content-['·']">
46
+
{{ $s := "s" }}
47
+
{{ if eq (len .Comments) 1 }}
48
+
{{ $s = "" }}
49
+
{{ end }}
50
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
51
+
</span>
52
+
53
+
{{ $state := .Labels }}
54
+
{{ range $k, $d := $.LabelDefs }}
55
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
56
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
57
+
{{ end }}
58
+
{{ end }}
59
+
</div>
60
+
</div>
61
+
{{ end }}
62
+
</div>
63
+
{{ end }}
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
···
···
1
+
{{ define "repo/issues/fragments/issueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2">
6
+
<a
7
+
href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}"
8
+
class="no-underline hover:underline"
9
+
>
10
+
{{ .Title | description }}
11
+
<span class="text-gray-500">#{{ .IssueId }}</span>
12
+
</a>
13
+
</div>
14
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
15
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
16
+
{{ $icon := "ban" }}
17
+
{{ $state := "closed" }}
18
+
{{ if .Open }}
19
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
20
+
{{ $icon = "circle-dot" }}
21
+
{{ $state = "open" }}
22
+
{{ end }}
23
+
24
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
25
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
26
+
<span class="text-white dark:text-white">{{ $state }}</span>
27
+
</span>
28
+
29
+
<span class="ml-1">
30
+
{{ template "user/fragments/picHandleLink" .Did }}
31
+
</span>
32
+
33
+
<span class="before:content-['·']">
34
+
{{ template "repo/fragments/time" .Created }}
35
+
</span>
36
+
37
+
<span class="before:content-['·']">
38
+
{{ $s := "s" }}
39
+
{{ if eq (len .Comments) 1 }}
40
+
{{ $s = "" }}
41
+
{{ end }}
42
+
<a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
43
+
</span>
44
+
45
+
{{ $state := .Labels }}
46
+
{{ range $k, $d := $.LabelDefs }}
47
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
48
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
49
+
{{ end }}
50
+
{{ end }}
51
+
</div>
52
+
</div>
53
+
{{ end }}
54
+
</div>
55
+
{{ end }}
+1
-27
appview/pages/templates/repo/issues/issue.html
+1
-27
appview/pages/templates/repo/issues/issue.html
···
22
"Defs" $.LabelDefs
23
"Subject" $.Issue.AtUri
24
"State" $.Issue.Labels) }}
25
-
{{ template "issueParticipants" . }}
26
</div>
27
</div>
28
{{ end }}
···
122
</div>
123
{{ end }}
124
125
-
{{ define "issueParticipants" }}
126
-
{{ $all := .Issue.Participants }}
127
-
{{ $ps := take $all 5 }}
128
-
<div>
129
-
<div class="py-1 flex items-center text-sm">
130
-
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
131
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
132
-
</div>
133
-
<div class="flex items-center -space-x-3 mt-2">
134
-
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
135
-
{{ range $i, $p := $ps }}
136
-
<img
137
-
src="{{ tinyAvatar . }}"
138
-
alt=""
139
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
140
-
/>
141
-
{{ end }}
142
-
143
-
{{ if gt (len $all) 5 }}
144
-
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
145
-
+{{ sub (len $all) 5 }}
146
-
</span>
147
-
{{ end }}
148
-
</div>
149
-
</div>
150
-
{{ end }}
151
152
{{ define "repoAfter" }}
153
<div class="flex flex-col gap-4 mt-4">
+2
-52
appview/pages/templates/repo/issues/issues.html
+2
-52
appview/pages/templates/repo/issues/issues.html
···
37
{{ end }}
38
39
{{ define "repoAfter" }}
40
-
<div class="flex flex-col gap-2 mt-2">
41
-
{{ range .Issues }}
42
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
-
<div class="pb-2">
44
-
<a
45
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
-
class="no-underline hover:underline"
47
-
>
48
-
{{ .Title | description }}
49
-
<span class="text-gray-500">#{{ .IssueId }}</span>
50
-
</a>
51
-
</div>
52
-
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
-
{{ $icon := "ban" }}
55
-
{{ $state := "closed" }}
56
-
{{ if .Open }}
57
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
58
-
{{ $icon = "circle-dot" }}
59
-
{{ $state = "open" }}
60
-
{{ end }}
61
-
62
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
63
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
64
-
<span class="text-white dark:text-white">{{ $state }}</span>
65
-
</span>
66
-
67
-
<span class="ml-1">
68
-
{{ template "user/fragments/picHandleLink" .Did }}
69
-
</span>
70
-
71
-
<span class="before:content-['·']">
72
-
{{ template "repo/fragments/time" .Created }}
73
-
</span>
74
-
75
-
<span class="before:content-['·']">
76
-
{{ $s := "s" }}
77
-
{{ if eq (len .Comments) 1 }}
78
-
{{ $s = "" }}
79
-
{{ end }}
80
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
81
-
</span>
82
-
83
-
{{ $state := .Labels }}
84
-
{{ range $k, $d := $.LabelDefs }}
85
-
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
86
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
87
-
{{ end }}
88
-
{{ end }}
89
-
</div>
90
-
</div>
91
-
{{ end }}
92
</div>
93
{{ block "pagination" . }} {{ end }}
94
{{ end }}
+163
-61
appview/pages/templates/repo/new.html
+163
-61
appview/pages/templates/repo/new.html
···
1
{{ define "title" }}new repo{{ end }}
2
3
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Create a new repository</p>
6
</div>
7
-
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
-
<form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
-
<div class="space-y-2">
10
-
<label for="name" class="-mb-1 dark:text-white">Repository name</label>
11
-
<input
12
-
type="text"
13
-
id="name"
14
-
name="name"
15
-
required
16
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
17
-
/>
18
-
<p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p>
19
20
-
<label for="branch" class="dark:text-white">Default branch</label>
21
-
<input
22
-
type="text"
23
-
id="branch"
24
-
name="branch"
25
-
value="main"
26
-
required
27
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
28
-
/>
29
30
-
<label for="description" class="dark:text-white">Description</label>
31
-
<input
32
-
type="text"
33
-
id="description"
34
-
name="description"
35
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
36
-
/>
37
</div>
38
39
-
<fieldset class="space-y-3">
40
-
<legend class="dark:text-white">Select a knot</legend>
41
<div class="space-y-2">
42
-
<div class="flex flex-col">
43
-
{{ range .Knots }}
44
-
<div class="flex items-center">
45
-
<input
46
-
type="radio"
47
-
name="domain"
48
-
value="{{ . }}"
49
-
class="mr-2"
50
-
id="domain-{{ . }}"
51
-
/>
52
-
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
53
-
</div>
54
-
{{ else }}
55
-
<p class="dark:text-white">No knots available.</p>
56
-
{{ end }}
57
-
</div>
58
</div>
59
-
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
60
-
</fieldset>
61
62
-
<div class="space-y-2">
63
-
<button type="submit" class="btn-create flex items-center gap-2">
64
-
{{ i "book-plus" "w-4 h-4" }}
65
-
create repo
66
-
<span id="spinner" class="group">
67
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
68
-
</span>
69
-
</button>
70
-
<div id="repo" class="error"></div>
71
</div>
72
-
</form>
73
-
</div>
74
{{ end }}
···
1
{{ define "title" }}new repo{{ end }}
2
3
{{ define "content" }}
4
+
<div class="grid grid-cols-12">
5
+
<div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4">
6
+
<h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Repositories contain a project's files and version history. All
9
+
repositories are publicly accessible.
10
+
</p>
11
+
</div>
12
+
{{ template "newRepoPanel" . }}
13
</div>
14
+
{{ end }}
15
16
+
{{ define "newRepoPanel" }}
17
+
<div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
18
+
{{ template "newRepoForm" . }}
19
+
</div>
20
+
{{ end }}
21
22
+
{{ define "newRepoForm" }}
23
+
<form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner">
24
+
{{ template "step-1" . }}
25
+
{{ template "step-2" . }}
26
+
27
+
<div class="mt-8 flex justify-end">
28
+
<button type="submit" class="btn-create flex items-center gap-2">
29
+
{{ i "book-plus" "w-4 h-4" }}
30
+
create repo
31
+
<span id="spinner" class="group">
32
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
33
+
</span>
34
+
</button>
35
</div>
36
+
<div id="repo" class="error mt-2"></div>
37
38
+
</form>
39
+
{{ end }}
40
+
41
+
{{ define "step-1" }}
42
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
43
+
<div class="absolute -left-3 -top-0">
44
+
{{ template "numberCircle" 1 }}
45
+
</div>
46
+
47
+
<!-- Content column -->
48
+
<div class="flex-1 pb-12">
49
+
<h2 class="text-lg font-semibold dark:text-white">General</h2>
50
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div>
51
+
52
<div class="space-y-2">
53
+
{{ template "name" . }}
54
+
{{ template "description" . }}
55
</div>
56
+
</div>
57
+
</div>
58
+
{{ end }}
59
60
+
{{ define "step-2" }}
61
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
62
+
<div class="absolute -left-3 -top-0">
63
+
{{ template "numberCircle" 2 }}
64
</div>
65
+
66
+
<div class="flex-1">
67
+
<h2 class="text-lg font-semibold dark:text-white">Configuration</h2>
68
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div>
69
+
70
+
<div class="space-y-2">
71
+
{{ template "defaultBranch" . }}
72
+
{{ template "knot" . }}
73
+
</div>
74
+
</div>
75
+
</div>
76
+
{{ end }}
77
+
78
+
{{ define "name" }}
79
+
<!-- Repository Name with Owner -->
80
+
<div>
81
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
82
+
Repository name
83
+
</label>
84
+
<div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full">
85
+
<div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700">
86
+
{{ template "user/fragments/picHandle" .LoggedInUser.Did }}
87
+
</div>
88
+
<input
89
+
type="text"
90
+
id="name"
91
+
name="name"
92
+
required
93
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2"
94
+
placeholder="repository-name"
95
+
/>
96
+
</div>
97
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
98
+
Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens.
99
+
</p>
100
+
</div>
101
+
{{ end }}
102
+
103
+
{{ define "description" }}
104
+
<!-- Description -->
105
+
<div>
106
+
<label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1">
107
+
Description
108
+
</label>
109
+
<input
110
+
type="text"
111
+
id="description"
112
+
name="description"
113
+
class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
114
+
placeholder="A brief description of your project..."
115
+
/>
116
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
117
+
Optional. A short description to help others understand what your project does.
118
+
</p>
119
+
</div>
120
+
{{ end }}
121
+
122
+
{{ define "defaultBranch" }}
123
+
<!-- Default Branch -->
124
+
<div>
125
+
<label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1">
126
+
Default branch
127
+
</label>
128
+
<input
129
+
type="text"
130
+
id="branch"
131
+
name="branch"
132
+
value="main"
133
+
required
134
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
135
+
/>
136
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
137
+
The primary branch where development happens. Common choices are "main" or "master".
138
+
</p>
139
+
</div>
140
+
{{ end }}
141
+
142
+
{{ define "knot" }}
143
+
<!-- Knot Selection -->
144
+
<div>
145
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
146
+
Select a knot
147
+
</label>
148
+
<div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2">
149
+
{{ range .Knots }}
150
+
<div class="flex items-center">
151
+
<input
152
+
type="radio"
153
+
name="domain"
154
+
value="{{ . }}"
155
+
class="mr-2"
156
+
id="domain-{{ . }}"
157
+
required
158
+
/>
159
+
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
160
+
</div>
161
+
{{ else }}
162
+
<p class="dark:text-white">no knots available.</p>
163
+
{{ end }}
164
+
</div>
165
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
166
+
A knot hosts repository data and handles Git operations.
167
+
You can also <a href="/knots" class="underline">register your own knot</a>.
168
+
</p>
169
+
</div>
170
+
{{ end }}
171
+
172
+
{{ define "numberCircle" }}
173
+
<div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1">
174
+
{{.}}
175
+
</div>
176
{{ end }}
+30
-12
appview/pages/templates/repo/pulls/pull.html
+30
-12
appview/pages/templates/repo/pulls/pull.html
···
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
{{ end }}
11
12
13
{{ define "repoContent" }}
14
{{ template "repo/pulls/fragments/pullHeader" . }}
···
39
{{ with $item }}
40
<details {{ if eq $idx $lastIdx }}open{{ end }}>
41
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
42
-
<div class="flex flex-wrap gap-2 items-center">
43
<!-- round number -->
44
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
45
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
46
</div>
47
<!-- round summary -->
48
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
<span class="gap-1 flex items-center">
50
{{ $owner := resolve $.Pull.OwnerDid }}
51
{{ $re := "re" }}
···
72
<span class="hidden md:inline">diff</span>
73
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
</a>
75
-
{{ if not (eq .RoundNumber 0) }}
76
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
77
-
hx-boost="true"
78
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
79
-
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
80
-
<span class="hidden md:inline">interdiff</span>
81
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
-
</a>
83
-
<span id="interdiff-error-{{.RoundNumber}}"></span>
84
{{ end }}
85
</div>
86
</summary>
87
···
146
147
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
148
{{ range $cidx, $c := .Comments }}
149
-
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
150
{{ if gt $cidx 0 }}
151
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
{{ end }}
···
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
{{ end }}
11
12
+
{{ define "repoContentLayout" }}
13
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
14
+
<div class="col-span-1 md:col-span-8">
15
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
16
+
{{ block "repoContent" . }}{{ end }}
17
+
</section>
18
+
{{ block "repoAfter" . }}{{ end }}
19
+
</div>
20
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
21
+
{{ template "repo/fragments/labelPanel"
22
+
(dict "RepoInfo" $.RepoInfo
23
+
"Defs" $.LabelDefs
24
+
"Subject" $.Pull.PullAt
25
+
"State" $.Pull.Labels) }}
26
+
{{ template "repo/fragments/participants" $.Pull.Participants }}
27
+
</div>
28
+
</div>
29
+
{{ end }}
30
31
{{ define "repoContent" }}
32
{{ template "repo/pulls/fragments/pullHeader" . }}
···
57
{{ with $item }}
58
<details {{ if eq $idx $lastIdx }}open{{ end }}>
59
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
60
+
<div class="flex flex-wrap gap-2 items-stretch">
61
<!-- round number -->
62
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
63
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
64
</div>
65
<!-- round summary -->
66
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
67
<span class="gap-1 flex items-center">
68
{{ $owner := resolve $.Pull.OwnerDid }}
69
{{ $re := "re" }}
···
90
<span class="hidden md:inline">diff</span>
91
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
92
</a>
93
+
{{ if ne $idx 0 }}
94
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
95
+
hx-boost="true"
96
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
97
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
98
+
<span class="hidden md:inline">interdiff</span>
99
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
100
+
</a>
101
{{ end }}
102
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
103
</div>
104
</summary>
105
···
164
165
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
166
{{ range $cidx, $c := .Comments }}
167
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
168
{{ if gt $cidx 0 }}
169
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
170
{{ end }}
+7
appview/pages/templates/repo/pulls/pulls.html
+7
appview/pages/templates/repo/pulls/pulls.html
···
108
<span class="before:content-['·']"></span>
109
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
110
{{ end }}
111
+
112
+
{{ $state := .Labels }}
113
+
{{ range $k, $d := $.LabelDefs }}
114
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
115
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
116
+
{{ end }}
117
+
{{ end }}
118
</div>
119
</div>
120
{{ if .StackId }}
+36
-6
appview/pages/templates/repo/settings/general.html
+36
-6
appview/pages/templates/repo/settings/general.html
···
46
47
{{ define "defaultLabelSettings" }}
48
<div class="flex flex-col gap-2">
49
-
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
50
-
<p class="text-gray-500 dark:text-gray-400">
51
-
Manage your issues and pulls by creating labels to categorize them. Only
52
-
repository owners may configure labels. You may choose to subscribe to
53
-
default labels, or create entirely custom labels.
54
-
</p>
55
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
56
{{ range .DefaultLabels }}
57
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
···
46
47
{{ define "defaultLabelSettings" }}
48
<div class="flex flex-col gap-2">
49
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
50
+
<div class="col-span-1 md:col-span-2">
51
+
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
52
+
<p class="text-gray-500 dark:text-gray-400">
53
+
Manage your issues and pulls by creating labels to categorize them. Only
54
+
repository owners may configure labels. You may choose to subscribe to
55
+
default labels, or create entirely custom labels.
56
+
<p>
57
+
</div>
58
+
<form class="col-span-1 md:col-span-1 md:justify-self-end">
59
+
{{ $title := "Unubscribe from all labels" }}
60
+
{{ $icon := "x" }}
61
+
{{ $text := "unsubscribe all" }}
62
+
{{ $action := "unsubscribe" }}
63
+
{{ if $.ShouldSubscribeAll }}
64
+
{{ $title = "Subscribe to all labels" }}
65
+
{{ $icon = "check-check" }}
66
+
{{ $text = "subscribe all" }}
67
+
{{ $action = "subscribe" }}
68
+
{{ end }}
69
+
{{ range .DefaultLabels }}
70
+
<input type="hidden" name="label" value="{{ .AtUri.String }}">
71
+
{{ end }}
72
+
<button
73
+
type="submit"
74
+
title="{{$title}}"
75
+
class="btn flex items-center gap-2 group"
76
+
hx-swap="none"
77
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}"
78
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}>
79
+
{{ i $icon "size-4" }}
80
+
{{ $text }}
81
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
+
</button>
83
+
</form>
84
+
</div>
85
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
86
{{ range .DefaultLabels }}
87
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+1
-1
appview/pages/templates/repo/tree.html
+1
-1
appview/pages/templates/repo/tree.html
+2
-2
appview/pages/templates/strings/put.html
+2
-2
appview/pages/templates/strings/put.html
···
3
{{ define "content" }}
4
<div class="px-6 py-2 mb-4">
5
{{ if eq .Action "new" }}
6
-
<p class="text-xl font-bold dark:text-white">Create a new string</p>
7
-
<p class="">Store and share code snippets with ease.</p>
8
{{ else }}
9
<p class="text-xl font-bold dark:text-white">Edit string</p>
10
{{ end }}
···
3
{{ define "content" }}
4
<div class="px-6 py-2 mb-4">
5
{{ if eq .Action "new" }}
6
+
<p class="text-xl font-bold dark:text-white mb-1">Create a new string</p>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p>
8
{{ else }}
9
<p class="text-xl font-bold dark:text-white">Edit string</p>
10
{{ end }}
+5
-7
appview/pages/templates/strings/timeline.html
+5
-7
appview/pages/templates/strings/timeline.html
···
26
{{ end }}
27
28
{{ define "stringCard" }}
29
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
30
-
<div class="font-medium dark:text-white flex gap-2 items-center">
31
-
<a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a>
32
</div>
33
{{ with .Description }}
34
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
42
43
{{ define "stringCardInfo" }}
44
{{ $stat := .Stats }}
45
-
{{ $resolved := resolve .Did.String }}
46
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
47
-
<a href="/strings/{{ $resolved }}" class="flex items-center">
48
-
{{ template "user/fragments/picHandle" $resolved }}
49
-
</a>
50
-
<span class="select-none [&:before]:content-['·']"></span>
51
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
52
<span class="select-none [&:before]:content-['·']"></span>
53
{{ with .Edited }}
···
26
{{ end }}
27
28
{{ define "stringCard" }}
29
+
{{ $resolved := resolve .Did.String }}
30
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
31
+
<div class="font-medium dark:text-white flex flex-wrap gap-1 items-center">
32
+
<a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a>
33
+
<span class="select-none">/</span>
34
+
<a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a>
35
</div>
36
{{ with .Description }}
37
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
45
46
{{ define "stringCardInfo" }}
47
{{ $stat := .Stats }}
48
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
49
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
50
<span class="select-none [&:before]:content-['·']"></span>
51
{{ with .Edited }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
···
1
+
{{ define "timeline/fragments/goodfirstissues" }}
2
+
{{ if .GfiLabel }}
3
+
<a href="/goodfirstissues" class="no-underline hover:no-underline">
4
+
<div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 ">
5
+
<div class="flex-1 flex flex-col gap-2">
6
+
<div class="text-purple-500 dark:text-purple-400">Oct 2025</div>
7
+
<p>
8
+
Make your first contribution to an open-source project this October.
9
+
<em>good-first-issue</em> helps new contributors find easy ways to
10
+
start contributing to open-source projects.
11
+
</p>
12
+
<span class="flex items-center gap-2 text-purple-500 dark:text-purple-400">
13
+
Browse issues {{ i "arrow-right" "size-4" }}
14
+
</span>
15
+
</div>
16
+
<div class="hidden md:block relative px-16 scale-150">
17
+
<div class="relative opacity-60">
18
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
19
+
</div>
20
+
<div class="relative -mt-4 ml-2 opacity-80">
21
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
22
+
</div>
23
+
<div class="relative -mt-4 ml-4">
24
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
25
+
</div>
26
+
</div>
27
+
</div>
28
+
</a>
29
+
{{ end }}
30
+
{{ end }}
+10
-33
appview/pages/templates/timeline/fragments/timeline.html
+10
-33
appview/pages/templates/timeline/fragments/timeline.html
···
82
{{ $event := index . 1 }}
83
{{ $follow := $event.Follow }}
84
{{ $profile := $event.Profile }}
85
-
{{ $stat := $event.FollowStats }}
86
87
{{ $userHandle := resolve $follow.UserDid }}
88
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
92
{{ template "user/fragments/picHandleLink" $subjectHandle }}
93
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
94
</div>
95
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4">
96
-
<div class="flex items-center gap-4 flex-1">
97
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
98
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
99
-
</div>
100
-
101
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
102
-
<a href="/{{ $subjectHandle }}">
103
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
104
-
</a>
105
-
{{ with $profile }}
106
-
{{ with .Description }}
107
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
108
-
{{ end }}
109
-
{{ end }}
110
-
{{ with $stat }}
111
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
112
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
113
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
114
-
<span class="select-none after:content-['·']"></span>
115
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
116
-
</div>
117
-
{{ end }}
118
-
</div>
119
-
</div>
120
-
121
-
{{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }}
122
-
<div class="flex-shrink-0 w-fit ml-auto">
123
-
{{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }}
124
-
</div>
125
-
{{ end }}
126
-
</div>
127
{{ end }}
···
82
{{ $event := index . 1 }}
83
{{ $follow := $event.Follow }}
84
{{ $profile := $event.Profile }}
85
+
{{ $followStats := $event.FollowStats }}
86
+
{{ $followStatus := $event.FollowStatus }}
87
88
{{ $userHandle := resolve $follow.UserDid }}
89
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
93
{{ template "user/fragments/picHandleLink" $subjectHandle }}
94
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
95
</div>
96
+
{{ template "user/fragments/followCard"
97
+
(dict
98
+
"LoggedInUser" $root.LoggedInUser
99
+
"UserDid" $follow.SubjectDid
100
+
"Profile" $profile
101
+
"FollowStatus" $followStatus
102
+
"FollowersCount" $followStats.Followers
103
+
"FollowingCount" $followStats.Following) }}
104
{{ end }}
+1
appview/pages/templates/timeline/home.html
+1
appview/pages/templates/timeline/home.html
···
12
<div class="flex flex-col gap-4">
13
{{ template "timeline/fragments/hero" . }}
14
{{ template "features" . }}
15
+
{{ template "timeline/fragments/goodfirstissues" . }}
16
{{ template "timeline/fragments/trending" . }}
17
{{ template "timeline/fragments/timeline" . }}
18
<div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/user/completeSignup.html
+1
appview/pages/templates/user/completeSignup.html
+8
-1
appview/pages/templates/user/followers.html
+8
-1
appview/pages/templates/user/followers.html
···
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
11
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Followers }}
13
-
{{ template "user/fragments/followCard" . }}
14
{{ else }}
15
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
16
{{ end }}
···
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
11
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Followers }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
21
{{ else }}
22
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
23
{{ end }}
+8
-1
appview/pages/templates/user/following.html
+8
-1
appview/pages/templates/user/following.html
···
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
11
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Following }}
13
-
{{ template "user/fragments/followCard" . }}
14
{{ else }}
15
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
16
{{ end }}
···
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
11
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Following }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
21
{{ else }}
22
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
23
{{ end }}
+6
-2
appview/pages/templates/user/fragments/follow.html
+6
-2
appview/pages/templates/user/fragments/follow.html
···
1
{{ define "user/fragments/follow" }}
2
<button id="{{ normalizeForHtmlId .UserDid }}"
3
-
class="btn mt-2 flex gap-2 items-center group"
4
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
hx-post="/follow?subject={{.UserDid}}"
···
12
hx-target="#{{ normalizeForHtmlId .UserDid }}"
13
hx-swap="outerHTML"
14
>
15
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }}
16
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
17
</button>
18
{{ end }}
···
1
{{ define "user/fragments/follow" }}
2
<button id="{{ normalizeForHtmlId .UserDid }}"
3
+
class="btn w-full flex gap-2 items-center group"
4
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
hx-post="/follow?subject={{.UserDid}}"
···
12
hx-target="#{{ normalizeForHtmlId .UserDid }}"
13
hx-swap="outerHTML"
14
>
15
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
16
+
{{ i "user-round-plus" "w-4 h-4" }} follow
17
+
{{ else }}
18
+
{{ i "user-round-minus" "w-4 h-4" }} unfollow
19
+
{{ end }}
20
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
21
</button>
22
{{ end }}
+20
-17
appview/pages/templates/user/fragments/followCard.html
+20
-17
appview/pages/templates/user/fragments/followCard.html
···
1
{{ define "user/fragments/followCard" }}
2
{{ $userIdent := resolve .UserDid }}
3
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
</div>
8
9
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
10
-
<a href="/{{ $userIdent }}">
11
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
12
-
</a>
13
-
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
14
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
15
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
16
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
17
-
<span class="select-none after:content-['·']"></span>
18
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
19
</div>
20
-
</div>
21
-
22
-
{{ if ne .FollowStatus.String "IsSelf" }}
23
-
<div class="max-w-24">
24
{{ template "user/fragments/follow" . }}
25
</div>
26
-
{{ end }}
27
</div>
28
</div>
29
-
{{ end }}
···
1
{{ define "user/fragments/followCard" }}
2
{{ $userIdent := resolve .UserDid }}
3
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
</div>
8
9
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
10
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
+
<a href="/{{ $userIdent }}">
12
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
+
</a>
14
+
{{ with .Profile }}
15
+
<p class="text-sm pb-2 md:pb-2">{{.Description}}</p>
16
+
{{ end }}
17
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
19
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
20
+
<span class="select-none after:content-['·']"></span>
21
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
22
+
</div>
23
</div>
24
+
{{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }}
25
+
<div class="w-full md:w-auto md:max-w-24 order-last md:order-none">
26
{{ template "user/fragments/follow" . }}
27
</div>
28
+
{{ end }}
29
+
</div>
30
</div>
31
</div>
32
+
{{ end }}
+2
-2
appview/pages/templates/user/fragments/picHandle.html
+2
-2
appview/pages/templates/user/fragments/picHandle.html
+2
-3
appview/pages/templates/user/fragments/picHandleLink.html
+2
-3
appview/pages/templates/user/fragments/picHandleLink.html
+10
-10
appview/pages/templates/user/fragments/repoCard.html
+10
-10
appview/pages/templates/user/fragments/repoCard.html
···
14
{{ with $repo }}
15
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
16
<div class="font-medium dark:text-white flex items-center justify-between">
17
-
<div class="flex items-center">
18
-
{{ if .Source }}
19
-
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
20
-
{{ else }}
21
-
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
22
-
{{ end }}
23
-
24
{{ $repoOwner := resolve .Did }}
25
{{- if $fullName -}}
26
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
27
{{- else -}}
28
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
29
{{- end -}}
30
</div>
31
-
32
{{ if and $starButton $root.LoggedInUser }}
33
{{ template "repo/fragments/repoStar" $starData }}
34
{{ end }}
35
</div>
36
{{ with .Description }}
···
14
{{ with $repo }}
15
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
16
<div class="font-medium dark:text-white flex items-center justify-between">
17
+
<div class="flex items-center min-w-0 flex-1 mr-2">
18
+
{{ if .Source }}
19
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
20
+
{{ else }}
21
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
22
+
{{ end }}
23
{{ $repoOwner := resolve .Did }}
24
{{- if $fullName -}}
25
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a>
26
{{- else -}}
27
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a>
28
{{- end -}}
29
</div>
30
{{ if and $starButton $root.LoggedInUser }}
31
+
<div class="shrink-0">
32
{{ template "repo/fragments/repoStar" $starData }}
33
+
</div>
34
{{ end }}
35
</div>
36
{{ with .Description }}
+2
-1
appview/pages/templates/user/login.html
+2
-1
appview/pages/templates/user/login.html
···
8
<meta property="og:url" content="https://tangled.org/login" />
9
<meta property="og:description" content="login to for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
<title>login · tangled</title>
13
</head>
···
36
placeholder="akshay.tngl.sh"
37
/>
38
<span class="text-sm text-gray-500 mt-1">
39
-
Use your <a href="https://atproto.com">ATProto</a>
40
handle to log in. If you're unsure, this is likely
41
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
42
</span>
···
8
<meta property="og:url" content="https://tangled.org/login" />
9
<meta property="og:description" content="login to for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
<title>login · tangled</title>
14
</head>
···
37
placeholder="akshay.tngl.sh"
38
/>
39
<span class="text-sm text-gray-500 mt-1">
40
+
Use your <a href="https://atproto.com">AT Protocol</a>
41
handle to log in. If you're unsure, this is likely
42
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
43
</span>
+173
appview/pages/templates/user/settings/notifications.html
+173
appview/pages/templates/user/settings/notifications.html
···
···
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "notificationSettings" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "notificationSettings" }}
20
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
+
<div class="col-span-1 md:col-span-2">
22
+
<h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2>
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
Choose which notifications you want to receive when activity happens on your repositories and profile.
25
+
</p>
26
+
</div>
27
+
</div>
28
+
29
+
<form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6">
30
+
31
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
32
+
<div class="flex items-center justify-between p-2">
33
+
<div class="flex items-center gap-2">
34
+
<div class="flex flex-col gap-1">
35
+
<span class="font-bold">Repository starred</span>
36
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
37
+
<span>When someone stars your repository.</span>
38
+
</div>
39
+
</div>
40
+
</div>
41
+
<label class="flex items-center gap-2">
42
+
<input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}>
43
+
</label>
44
+
</div>
45
+
46
+
<div class="flex items-center justify-between p-2">
47
+
<div class="flex items-center gap-2">
48
+
<div class="flex flex-col gap-1">
49
+
<span class="font-bold">New issues</span>
50
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
51
+
<span>When someone creates an issue on your repository.</span>
52
+
</div>
53
+
</div>
54
+
</div>
55
+
<label class="flex items-center gap-2">
56
+
<input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}>
57
+
</label>
58
+
</div>
59
+
60
+
<div class="flex items-center justify-between p-2">
61
+
<div class="flex items-center gap-2">
62
+
<div class="flex flex-col gap-1">
63
+
<span class="font-bold">Issue comments</span>
64
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
65
+
<span>When someone comments on an issue you're involved with.</span>
66
+
</div>
67
+
</div>
68
+
</div>
69
+
<label class="flex items-center gap-2">
70
+
<input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}>
71
+
</label>
72
+
</div>
73
+
74
+
<div class="flex items-center justify-between p-2">
75
+
<div class="flex items-center gap-2">
76
+
<div class="flex flex-col gap-1">
77
+
<span class="font-bold">Issue closed</span>
78
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
79
+
<span>When an issue on your repository is closed.</span>
80
+
</div>
81
+
</div>
82
+
</div>
83
+
<label class="flex items-center gap-2">
84
+
<input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}>
85
+
</label>
86
+
</div>
87
+
88
+
<div class="flex items-center justify-between p-2">
89
+
<div class="flex items-center gap-2">
90
+
<div class="flex flex-col gap-1">
91
+
<span class="font-bold">New pull requests</span>
92
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
93
+
<span>When someone creates a pull request on your repository.</span>
94
+
</div>
95
+
</div>
96
+
</div>
97
+
<label class="flex items-center gap-2">
98
+
<input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}>
99
+
</label>
100
+
</div>
101
+
102
+
<div class="flex items-center justify-between p-2">
103
+
<div class="flex items-center gap-2">
104
+
<div class="flex flex-col gap-1">
105
+
<span class="font-bold">Pull request comments</span>
106
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
107
+
<span>When someone comments on a pull request you're involved with.</span>
108
+
</div>
109
+
</div>
110
+
</div>
111
+
<label class="flex items-center gap-2">
112
+
<input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}>
113
+
</label>
114
+
</div>
115
+
116
+
<div class="flex items-center justify-between p-2">
117
+
<div class="flex items-center gap-2">
118
+
<div class="flex flex-col gap-1">
119
+
<span class="font-bold">Pull request merged</span>
120
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
121
+
<span>When your pull request is merged.</span>
122
+
</div>
123
+
</div>
124
+
</div>
125
+
<label class="flex items-center gap-2">
126
+
<input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}>
127
+
</label>
128
+
</div>
129
+
130
+
<div class="flex items-center justify-between p-2">
131
+
<div class="flex items-center gap-2">
132
+
<div class="flex flex-col gap-1">
133
+
<span class="font-bold">New followers</span>
134
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
135
+
<span>When someone follows you.</span>
136
+
</div>
137
+
</div>
138
+
</div>
139
+
<label class="flex items-center gap-2">
140
+
<input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}>
141
+
</label>
142
+
</div>
143
+
144
+
<div class="flex items-center justify-between p-2">
145
+
<div class="flex items-center gap-2">
146
+
<div class="flex flex-col gap-1">
147
+
<span class="font-bold">Email notifications</span>
148
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
+
<span>Receive notifications via email in addition to in-app notifications.</span>
150
+
</div>
151
+
</div>
152
+
</div>
153
+
<label class="flex items-center gap-2">
154
+
<input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}>
155
+
</label>
156
+
</div>
157
+
</div>
158
+
159
+
<div class="flex justify-end pt-2">
160
+
<button
161
+
type="submit"
162
+
class="btn-create flex items-center gap-2 group"
163
+
>
164
+
{{ i "save" "w-4 h-4" }}
165
+
save
166
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
167
+
</button>
168
+
</div>
169
+
<div id="settings-notifications-success"></div>
170
+
171
+
<div id="settings-notifications-error" class="error"></div>
172
+
</form>
173
+
{{ end }}
+7
-1
appview/pages/templates/user/signup.html
+7
-1
appview/pages/templates/user/signup.html
···
8
<meta property="og:url" content="https://tangled.org/signup" />
9
<meta property="og:description" content="sign up for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
<title>sign up · tangled</title>
13
</head>
14
<body class="flex items-center justify-center min-h-screen">
15
<main class="max-w-md px-6 -mt-4">
···
39
invite code, desired username, and password in the next
40
page to complete your registration.
41
</span>
42
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
43
<span>join now</span>
44
</button>
45
</form>
46
<p class="text-sm text-gray-500">
47
-
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
48
</p>
49
50
<p id="signup-msg" class="error w-full"></p>
···
8
<meta property="og:url" content="https://tangled.org/signup" />
9
<meta property="og:description" content="sign up for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
<title>sign up · tangled</title>
14
+
15
+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
16
</head>
17
<body class="flex items-center justify-center min-h-screen">
18
<main class="max-w-md px-6 -mt-4">
···
42
invite code, desired username, and password in the next
43
page to complete your registration.
44
</span>
45
+
<div class="w-full mt-4 text-center">
46
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
47
+
</div>
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
49
<span>join now</span>
50
</button>
51
</form>
52
<p class="text-sm text-gray-500">
53
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
54
</p>
55
56
<p id="signup-msg" class="error w-full"></p>
+1
-1
appview/pagination/page.go
+1
-1
appview/pagination/page.go
-164
appview/posthog/notifier.go
-164
appview/posthog/notifier.go
···
1
-
package posthog_service
2
-
3
-
import (
4
-
"context"
5
-
"log"
6
-
7
-
"github.com/posthog/posthog-go"
8
-
"tangled.org/core/appview/models"
9
-
"tangled.org/core/appview/notify"
10
-
)
11
-
12
-
type posthogNotifier struct {
13
-
client posthog.Client
14
-
notify.BaseNotifier
15
-
}
16
-
17
-
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
18
-
return &posthogNotifier{
19
-
client,
20
-
notify.BaseNotifier{},
21
-
}
22
-
}
23
-
24
-
var _ notify.Notifier = &posthogNotifier{}
25
-
26
-
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
27
-
err := n.client.Enqueue(posthog.Capture{
28
-
DistinctId: repo.Did,
29
-
Event: "new_repo",
30
-
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
31
-
})
32
-
if err != nil {
33
-
log.Println("failed to enqueue posthog event:", err)
34
-
}
35
-
}
36
-
37
-
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
38
-
err := n.client.Enqueue(posthog.Capture{
39
-
DistinctId: star.StarredByDid,
40
-
Event: "star",
41
-
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
42
-
})
43
-
if err != nil {
44
-
log.Println("failed to enqueue posthog event:", err)
45
-
}
46
-
}
47
-
48
-
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
49
-
err := n.client.Enqueue(posthog.Capture{
50
-
DistinctId: star.StarredByDid,
51
-
Event: "unstar",
52
-
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
53
-
})
54
-
if err != nil {
55
-
log.Println("failed to enqueue posthog event:", err)
56
-
}
57
-
}
58
-
59
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
60
-
err := n.client.Enqueue(posthog.Capture{
61
-
DistinctId: issue.Did,
62
-
Event: "new_issue",
63
-
Properties: posthog.Properties{
64
-
"repo_at": issue.RepoAt.String(),
65
-
"issue_id": issue.IssueId,
66
-
},
67
-
})
68
-
if err != nil {
69
-
log.Println("failed to enqueue posthog event:", err)
70
-
}
71
-
}
72
-
73
-
func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) {
74
-
err := n.client.Enqueue(posthog.Capture{
75
-
DistinctId: pull.OwnerDid,
76
-
Event: "new_pull",
77
-
Properties: posthog.Properties{
78
-
"repo_at": pull.RepoAt,
79
-
"pull_id": pull.PullId,
80
-
},
81
-
})
82
-
if err != nil {
83
-
log.Println("failed to enqueue posthog event:", err)
84
-
}
85
-
}
86
-
87
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
88
-
err := n.client.Enqueue(posthog.Capture{
89
-
DistinctId: comment.OwnerDid,
90
-
Event: "new_pull_comment",
91
-
Properties: posthog.Properties{
92
-
"repo_at": comment.RepoAt,
93
-
"pull_id": comment.PullId,
94
-
},
95
-
})
96
-
if err != nil {
97
-
log.Println("failed to enqueue posthog event:", err)
98
-
}
99
-
}
100
-
101
-
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
102
-
err := n.client.Enqueue(posthog.Capture{
103
-
DistinctId: follow.UserDid,
104
-
Event: "follow",
105
-
Properties: posthog.Properties{"subject": follow.SubjectDid},
106
-
})
107
-
if err != nil {
108
-
log.Println("failed to enqueue posthog event:", err)
109
-
}
110
-
}
111
-
112
-
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
113
-
err := n.client.Enqueue(posthog.Capture{
114
-
DistinctId: follow.UserDid,
115
-
Event: "unfollow",
116
-
Properties: posthog.Properties{"subject": follow.SubjectDid},
117
-
})
118
-
if err != nil {
119
-
log.Println("failed to enqueue posthog event:", err)
120
-
}
121
-
}
122
-
123
-
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
124
-
err := n.client.Enqueue(posthog.Capture{
125
-
DistinctId: profile.Did,
126
-
Event: "edit_profile",
127
-
})
128
-
if err != nil {
129
-
log.Println("failed to enqueue posthog event:", err)
130
-
}
131
-
}
132
-
133
-
func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) {
134
-
err := n.client.Enqueue(posthog.Capture{
135
-
DistinctId: did,
136
-
Event: "delete_string",
137
-
Properties: posthog.Properties{"rkey": rkey},
138
-
})
139
-
if err != nil {
140
-
log.Println("failed to enqueue posthog event:", err)
141
-
}
142
-
}
143
-
144
-
func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) {
145
-
err := n.client.Enqueue(posthog.Capture{
146
-
DistinctId: string.Did.String(),
147
-
Event: "edit_string",
148
-
Properties: posthog.Properties{"rkey": string.Rkey},
149
-
})
150
-
if err != nil {
151
-
log.Println("failed to enqueue posthog event:", err)
152
-
}
153
-
}
154
-
155
-
func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) {
156
-
err := n.client.Enqueue(posthog.Capture{
157
-
DistinctId: string.Did.String(),
158
-
Event: "create_string",
159
-
Properties: posthog.Properties{"rkey": string.Rkey},
160
-
})
161
-
if err != nil {
162
-
log.Println("failed to enqueue posthog event:", err)
163
-
}
164
-
}
···
+51
-3
appview/pulls/pulls.go
+51
-3
appview/pulls/pulls.go
···
200
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
201
}
202
203
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
204
LoggedInUser: user,
205
RepoInfo: repoInfo,
···
213
OrderedReactionKinds: models.OrderedReactionKinds,
214
Reactions: reactionCountMap,
215
UserReacted: userReactions,
216
})
217
}
218
···
557
m[p.Sha] = p
558
}
559
560
s.pages.RepoPulls(w, pages.RepoPullsParams{
561
LoggedInUser: s.oauth.GetUser(r),
562
RepoInfo: f.RepoInfo(user),
563
Pulls: pulls,
564
FilteringBy: state,
565
Stacks: stacks,
566
Pipelines: m,
···
1058
1059
// We've already checked earlier if it's diff-based and title is empty,
1060
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1061
-
if title == "" {
1062
formatPatches, err := patchutil.ExtractPatches(patch)
1063
if err != nil {
1064
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1069
return
1070
}
1071
1072
-
title = formatPatches[0].Title
1073
-
body = formatPatches[0].Body
1074
}
1075
1076
rkey := tid.TID()
···
2147
return
2148
}
2149
2150
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2151
}
2152
···
2212
log.Println("failed to commit transaction", err)
2213
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2214
return
2215
}
2216
2217
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
200
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
201
}
202
203
+
labelDefs, err := db.GetLabelDefinitions(
204
+
s.db,
205
+
db.FilterIn("at_uri", f.Repo.Labels),
206
+
db.FilterContains("scope", tangled.RepoPullNSID),
207
+
)
208
+
if err != nil {
209
+
log.Println("failed to fetch labels", err)
210
+
s.pages.Error503(w)
211
+
return
212
+
}
213
+
214
+
defs := make(map[string]*models.LabelDefinition)
215
+
for _, l := range labelDefs {
216
+
defs[l.AtUri().String()] = &l
217
+
}
218
+
219
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
220
LoggedInUser: user,
221
RepoInfo: repoInfo,
···
229
OrderedReactionKinds: models.OrderedReactionKinds,
230
Reactions: reactionCountMap,
231
UserReacted: userReactions,
232
+
233
+
LabelDefs: defs,
234
})
235
}
236
···
575
m[p.Sha] = p
576
}
577
578
+
labelDefs, err := db.GetLabelDefinitions(
579
+
s.db,
580
+
db.FilterIn("at_uri", f.Repo.Labels),
581
+
db.FilterContains("scope", tangled.RepoPullNSID),
582
+
)
583
+
if err != nil {
584
+
log.Println("failed to fetch labels", err)
585
+
s.pages.Error503(w)
586
+
return
587
+
}
588
+
589
+
defs := make(map[string]*models.LabelDefinition)
590
+
for _, l := range labelDefs {
591
+
defs[l.AtUri().String()] = &l
592
+
}
593
+
594
s.pages.RepoPulls(w, pages.RepoPullsParams{
595
LoggedInUser: s.oauth.GetUser(r),
596
RepoInfo: f.RepoInfo(user),
597
Pulls: pulls,
598
+
LabelDefs: defs,
599
FilteringBy: state,
600
Stacks: stacks,
601
Pipelines: m,
···
1093
1094
// We've already checked earlier if it's diff-based and title is empty,
1095
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1096
+
if title == "" || body == "" {
1097
formatPatches, err := patchutil.ExtractPatches(patch)
1098
if err != nil {
1099
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1104
return
1105
}
1106
1107
+
if title == "" {
1108
+
title = formatPatches[0].Title
1109
+
}
1110
+
if body == "" {
1111
+
body = formatPatches[0].Body
1112
+
}
1113
}
1114
1115
rkey := tid.TID()
···
2186
return
2187
}
2188
2189
+
// notify about the pull merge
2190
+
for _, p := range pullsToMerge {
2191
+
s.notifier.NewPullMerged(r.Context(), p)
2192
+
}
2193
+
2194
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2195
}
2196
···
2256
log.Println("failed to commit transaction", err)
2257
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2258
return
2259
+
}
2260
+
2261
+
for _, p := range pullsToClose {
2262
+
s.notifier.NewPullClosed(r.Context(), p)
2263
}
2264
2265
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+40
-14
appview/repo/artifact.go
+40
-14
appview/repo/artifact.go
···
4
"context"
5
"encoding/json"
6
"fmt"
7
"log"
8
"net/http"
9
"net/url"
···
134
})
135
}
136
137
-
// TODO: proper statuses here on early exit
138
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
139
-
tagParam := chi.URLParam(r, "tag")
140
-
filename := chi.URLParam(r, "file")
141
f, err := rp.repoResolver.Resolve(r)
142
if err != nil {
143
log.Println("failed to get repo and knot", err)
144
return
145
}
146
147
tag, err := rp.resolveTag(r.Context(), f, tagParam)
148
if err != nil {
···
151
return
152
}
153
154
-
client, err := rp.oauth.AuthorizedClient(r)
155
-
if err != nil {
156
-
log.Println("failed to get authorized client", err)
157
-
return
158
-
}
159
-
160
artifacts, err := db.GetArtifact(
161
rp.db,
162
db.FilterEq("repo_at", f.RepoAt()),
···
165
)
166
if err != nil {
167
log.Println("failed to get artifacts", err)
168
return
169
}
170
if len(artifacts) != 1 {
171
-
log.Printf("too many or too little artifacts found")
172
return
173
}
174
175
artifact := artifacts[0]
176
177
-
getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did)
178
if err != nil {
179
-
log.Println("failed to get blob from pds", err)
180
return
181
}
182
183
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
184
-
w.Write(getBlobResp)
185
}
186
187
// TODO: proper statuses here on early exit
···
4
"context"
5
"encoding/json"
6
"fmt"
7
+
"io"
8
"log"
9
"net/http"
10
"net/url"
···
135
})
136
}
137
138
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
139
f, err := rp.repoResolver.Resolve(r)
140
if err != nil {
141
log.Println("failed to get repo and knot", err)
142
+
http.Error(w, "failed to resolve repo", http.StatusInternalServerError)
143
return
144
}
145
+
146
+
tagParam := chi.URLParam(r, "tag")
147
+
filename := chi.URLParam(r, "file")
148
149
tag, err := rp.resolveTag(r.Context(), f, tagParam)
150
if err != nil {
···
153
return
154
}
155
156
artifacts, err := db.GetArtifact(
157
rp.db,
158
db.FilterEq("repo_at", f.RepoAt()),
···
161
)
162
if err != nil {
163
log.Println("failed to get artifacts", err)
164
+
http.Error(w, "failed to get artifact", http.StatusInternalServerError)
165
return
166
}
167
+
168
if len(artifacts) != 1 {
169
+
log.Printf("too many or too few artifacts found")
170
+
http.Error(w, "artifact not found", http.StatusNotFound)
171
return
172
}
173
174
artifact := artifacts[0]
175
176
+
ownerPds := f.OwnerId.PDSEndpoint()
177
+
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
178
+
q := url.Query()
179
+
q.Set("cid", artifact.BlobCid.String())
180
+
q.Set("did", artifact.Did)
181
+
url.RawQuery = q.Encode()
182
+
183
+
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
184
+
if err != nil {
185
+
log.Println("failed to create request", err)
186
+
http.Error(w, "failed to create request", http.StatusInternalServerError)
187
+
return
188
+
}
189
+
req.Header.Set("Content-Type", "application/json")
190
+
191
+
resp, err := http.DefaultClient.Do(req)
192
if err != nil {
193
+
log.Println("failed to make request", err)
194
+
http.Error(w, "failed to make request to PDS", http.StatusInternalServerError)
195
return
196
}
197
+
defer resp.Body.Close()
198
199
+
// copy status code and relevant headers from upstream response
200
+
w.WriteHeader(resp.StatusCode)
201
+
for key, values := range resp.Header {
202
+
for _, v := range values {
203
+
w.Header().Add(key, v)
204
+
}
205
+
}
206
+
207
+
// stream the body directly to the client
208
+
if _, err := io.Copy(w, resp.Body); err != nil {
209
+
log.Println("error streaming response to client:", err)
210
+
}
211
}
212
213
// TODO: proper statuses here on early exit
+17
-22
appview/repo/index.go
+17
-22
appview/repo/index.go
···
22
"tangled.org/core/appview/db"
23
"tangled.org/core/appview/models"
24
"tangled.org/core/appview/pages"
25
-
"tangled.org/core/appview/pages/markup"
26
"tangled.org/core/appview/reporesolver"
27
"tangled.org/core/appview/xrpcclient"
28
"tangled.org/core/types"
···
201
})
202
}
203
204
// update appview's cache
205
-
err = db.InsertRepoLanguages(rp.db, langs)
206
if err != nil {
207
// non-fatal
208
log.Println("failed to cache lang results", err)
209
}
210
}
211
···
328
}
329
}()
330
331
-
// readme content
332
-
wg.Add(1)
333
-
go func() {
334
-
defer wg.Done()
335
-
for _, filename := range markup.ReadmeFilenames {
336
-
blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
337
-
if err != nil {
338
-
continue
339
-
}
340
-
341
-
if blobResp == nil {
342
-
continue
343
-
}
344
-
345
-
readmeContent = blobResp.Content
346
-
readmeFileName = filename
347
-
break
348
-
}
349
-
}()
350
-
351
wg.Wait()
352
353
if errs != nil {
···
374
}
375
files = append(files, niceFile)
376
}
377
}
378
379
result := &types.RepoIndexResponse{
···
22
"tangled.org/core/appview/db"
23
"tangled.org/core/appview/models"
24
"tangled.org/core/appview/pages"
25
"tangled.org/core/appview/reporesolver"
26
"tangled.org/core/appview/xrpcclient"
27
"tangled.org/core/types"
···
200
})
201
}
202
203
+
tx, err := rp.db.Begin()
204
+
if err != nil {
205
+
return nil, err
206
+
}
207
+
defer tx.Rollback()
208
+
209
// update appview's cache
210
+
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
211
if err != nil {
212
// non-fatal
213
log.Println("failed to cache lang results", err)
214
+
}
215
+
216
+
err = tx.Commit()
217
+
if err != nil {
218
+
return nil, err
219
}
220
}
221
···
338
}
339
}()
340
341
wg.Wait()
342
343
if errs != nil {
···
364
}
365
files = append(files, niceFile)
366
}
367
+
}
368
+
369
+
if treeResp != nil && treeResp.Readme != nil {
370
+
readmeFileName = treeResp.Readme.Filename
371
+
readmeContent = treeResp.Readme.Contents
372
}
373
374
result := &types.RepoIndexResponse{
+77
-51
appview/repo/repo.go
+77
-51
appview/repo/repo.go
···
449
return
450
}
451
452
-
// readme content
453
-
var (
454
-
readmeContent string
455
-
readmeFileName string
456
-
)
457
-
458
-
for _, filename := range markup.ReadmeFilenames {
459
-
path := fmt.Sprintf("%s/%s", treePath, filename)
460
-
blobResp, err := tangled.RepoBlob(r.Context(), xrpcc, path, false, ref, repo)
461
-
if err != nil {
462
-
continue
463
-
}
464
-
465
-
if blobResp == nil {
466
-
continue
467
-
}
468
-
469
-
readmeContent = blobResp.Content
470
-
readmeFileName = path
471
-
break
472
-
}
473
-
474
// Convert XRPC response to internal types.RepoTreeResponse
475
files := make([]types.NiceTree, len(xrpcResp.Files))
476
for i, xrpcFile := range xrpcResp.Files {
···
506
if xrpcResp.Dotdot != nil {
507
result.DotDot = *xrpcResp.Dotdot
508
}
509
510
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
511
// so we can safely redirect to the "parent" (which is the same file).
···
532
BreadCrumbs: breadcrumbs,
533
TreePath: treePath,
534
RepoInfo: f.RepoInfo(user),
535
-
Readme: readmeContent,
536
-
ReadmeFileName: readmeFileName,
537
RepoTreeResponse: result,
538
})
539
}
···
1248
return
1249
}
1250
1251
errorId := "default-label-operation"
1252
fail := func(msg string, err error) {
1253
l.Error(msg, "err", err)
1254
rp.pages.Notice(w, errorId, msg)
1255
}
1256
1257
-
labelAt := r.FormValue("label")
1258
-
_, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt))
1259
if err != nil {
1260
fail("Failed to subscribe to label.", err)
1261
return
1262
}
1263
1264
newRepo := f.Repo
1265
-
newRepo.Labels = append(newRepo.Labels, labelAt)
1266
repoRecord := newRepo.AsRecord()
1267
1268
client, err := rp.oauth.AuthorizedClient(r)
···
1286
},
1287
})
1288
1289
-
err = db.SubscribeLabel(rp.db, &models.RepoLabel{
1290
-
RepoAt: f.RepoAt(),
1291
-
LabelAt: syntax.ATURI(labelAt),
1292
-
})
1293
if err != nil {
1294
fail("Failed to subscribe to label.", err)
1295
return
1296
}
1297
1298
// everything succeeded
1299
rp.pages.HxRefresh(w)
···
1311
return
1312
}
1313
1314
errorId := "default-label-operation"
1315
fail := func(msg string, err error) {
1316
l.Error(msg, "err", err)
1317
rp.pages.Notice(w, errorId, msg)
1318
}
1319
1320
-
labelAt := r.FormValue("label")
1321
-
_, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt))
1322
if err != nil {
1323
fail("Failed to unsubscribe to label.", err)
1324
return
···
1328
newRepo := f.Repo
1329
var updated []string
1330
for _, l := range newRepo.Labels {
1331
-
if l != labelAt {
1332
updated = append(updated, l)
1333
}
1334
}
···
1359
err = db.UnsubscribeLabel(
1360
rp.db,
1361
db.FilterEq("repo_at", f.RepoAt()),
1362
-
db.FilterEq("label_at", labelAt),
1363
)
1364
if err != nil {
1365
fail("Failed to unsubscribe label.", err)
···
1927
subscribedLabels[l] = struct{}{}
1928
}
1929
1930
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1931
-
LoggedInUser: user,
1932
-
RepoInfo: f.RepoInfo(user),
1933
-
Branches: result.Branches,
1934
-
Labels: labels,
1935
-
DefaultLabels: defaultLabels,
1936
-
SubscribedLabels: subscribedLabels,
1937
-
Tabs: settingsTabs,
1938
-
Tab: "general",
1939
})
1940
}
1941
···
2108
}
2109
2110
// choose a name for a fork
2111
-
forkName := f.Name
2112
// this check is *only* to see if the forked repo name already exists
2113
// in the user's account.
2114
existingRepo, err := db.GetRepo(
2115
rp.db,
2116
db.FilterEq("did", user.Did),
2117
-
db.FilterEq("name", f.Name),
2118
)
2119
if err != nil {
2120
-
if errors.Is(err, sql.ErrNoRows) {
2121
-
// no existing repo with this name found, we can use the name as is
2122
-
} else {
2123
log.Println("error fetching existing repo from db", "err", err)
2124
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2125
return
2126
}
2127
} else if existingRepo != nil {
2128
-
// repo with this name already exists, append random string
2129
-
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
2130
}
2131
l = l.With("forkName", forkName)
2132
···
2148
Knot: targetKnot,
2149
Rkey: rkey,
2150
Source: sourceAt,
2151
-
Description: existingRepo.Description,
2152
Created: time.Now(),
2153
}
2154
record := repo.AsRecord()
2155
···
449
return
450
}
451
452
// Convert XRPC response to internal types.RepoTreeResponse
453
files := make([]types.NiceTree, len(xrpcResp.Files))
454
for i, xrpcFile := range xrpcResp.Files {
···
484
if xrpcResp.Dotdot != nil {
485
result.DotDot = *xrpcResp.Dotdot
486
}
487
+
if xrpcResp.Readme != nil {
488
+
result.ReadmeFileName = xrpcResp.Readme.Filename
489
+
result.Readme = xrpcResp.Readme.Contents
490
+
}
491
492
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
493
// so we can safely redirect to the "parent" (which is the same file).
···
514
BreadCrumbs: breadcrumbs,
515
TreePath: treePath,
516
RepoInfo: f.RepoInfo(user),
517
RepoTreeResponse: result,
518
})
519
}
···
1228
return
1229
}
1230
1231
+
if err := r.ParseForm(); err != nil {
1232
+
l.Error("invalid form", "err", err)
1233
+
return
1234
+
}
1235
+
1236
errorId := "default-label-operation"
1237
fail := func(msg string, err error) {
1238
l.Error(msg, "err", err)
1239
rp.pages.Notice(w, errorId, msg)
1240
}
1241
1242
+
labelAts := r.Form["label"]
1243
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1244
if err != nil {
1245
fail("Failed to subscribe to label.", err)
1246
return
1247
}
1248
1249
newRepo := f.Repo
1250
+
newRepo.Labels = append(newRepo.Labels, labelAts...)
1251
+
1252
+
// dedup
1253
+
slices.Sort(newRepo.Labels)
1254
+
newRepo.Labels = slices.Compact(newRepo.Labels)
1255
+
1256
repoRecord := newRepo.AsRecord()
1257
1258
client, err := rp.oauth.AuthorizedClient(r)
···
1276
},
1277
})
1278
1279
+
tx, err := rp.db.Begin()
1280
if err != nil {
1281
fail("Failed to subscribe to label.", err)
1282
return
1283
}
1284
+
defer tx.Rollback()
1285
+
1286
+
for _, l := range labelAts {
1287
+
err = db.SubscribeLabel(tx, &models.RepoLabel{
1288
+
RepoAt: f.RepoAt(),
1289
+
LabelAt: syntax.ATURI(l),
1290
+
})
1291
+
if err != nil {
1292
+
fail("Failed to subscribe to label.", err)
1293
+
return
1294
+
}
1295
+
}
1296
+
1297
+
if err := tx.Commit(); err != nil {
1298
+
fail("Failed to subscribe to label.", err)
1299
+
return
1300
+
}
1301
1302
// everything succeeded
1303
rp.pages.HxRefresh(w)
···
1315
return
1316
}
1317
1318
+
if err := r.ParseForm(); err != nil {
1319
+
l.Error("invalid form", "err", err)
1320
+
return
1321
+
}
1322
+
1323
errorId := "default-label-operation"
1324
fail := func(msg string, err error) {
1325
l.Error(msg, "err", err)
1326
rp.pages.Notice(w, errorId, msg)
1327
}
1328
1329
+
labelAts := r.Form["label"]
1330
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1331
if err != nil {
1332
fail("Failed to unsubscribe to label.", err)
1333
return
···
1337
newRepo := f.Repo
1338
var updated []string
1339
for _, l := range newRepo.Labels {
1340
+
if !slices.Contains(labelAts, l) {
1341
updated = append(updated, l)
1342
}
1343
}
···
1368
err = db.UnsubscribeLabel(
1369
rp.db,
1370
db.FilterEq("repo_at", f.RepoAt()),
1371
+
db.FilterIn("label_at", labelAts),
1372
)
1373
if err != nil {
1374
fail("Failed to unsubscribe label.", err)
···
1936
subscribedLabels[l] = struct{}{}
1937
}
1938
1939
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
1940
+
// if all default labels are subbed, show the "unsubscribe all" button
1941
+
shouldSubscribeAll := false
1942
+
for _, dl := range defaultLabels {
1943
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
1944
+
// one of the default labels is not subscribed to
1945
+
shouldSubscribeAll = true
1946
+
break
1947
+
}
1948
+
}
1949
+
1950
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1951
+
LoggedInUser: user,
1952
+
RepoInfo: f.RepoInfo(user),
1953
+
Branches: result.Branches,
1954
+
Labels: labels,
1955
+
DefaultLabels: defaultLabels,
1956
+
SubscribedLabels: subscribedLabels,
1957
+
ShouldSubscribeAll: shouldSubscribeAll,
1958
+
Tabs: settingsTabs,
1959
+
Tab: "general",
1960
})
1961
}
1962
···
2129
}
2130
2131
// choose a name for a fork
2132
+
forkName := r.FormValue("repo_name")
2133
+
if forkName == "" {
2134
+
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
2135
+
return
2136
+
}
2137
+
2138
// this check is *only* to see if the forked repo name already exists
2139
// in the user's account.
2140
existingRepo, err := db.GetRepo(
2141
rp.db,
2142
db.FilterEq("did", user.Did),
2143
+
db.FilterEq("name", forkName),
2144
)
2145
if err != nil {
2146
+
if !errors.Is(err, sql.ErrNoRows) {
2147
log.Println("error fetching existing repo from db", "err", err)
2148
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2149
return
2150
}
2151
} else if existingRepo != nil {
2152
+
// repo with this name already exists
2153
+
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
2154
+
return
2155
}
2156
l = l.With("forkName", forkName)
2157
···
2173
Knot: targetKnot,
2174
Rkey: rkey,
2175
Source: sourceAt,
2176
+
Description: f.Repo.Description,
2177
Created: time.Now(),
2178
+
Labels: models.DefaultLabelDefs(),
2179
}
2180
record := repo.AsRecord()
2181
+2
-3
appview/repo/router.go
+2
-3
appview/repo/router.go
···
21
r.Route("/tags", func(r chi.Router) {
22
r.Get("/", rp.RepoTags)
23
r.Route("/{tag}", func(r chi.Router) {
24
-
r.Use(middleware.AuthMiddleware(rp.oauth))
25
-
// require auth to download for now
26
r.Get("/download/{file}", rp.DownloadArtifact)
27
28
// require repo:push to upload or delete artifacts
···
30
// additionally: only the uploader can truly delete an artifact
31
// (record+blob will live on their pds)
32
r.Group(func(r chi.Router) {
33
-
r.With(mw.RepoPermissionMiddleware("repo:push"))
34
r.Post("/upload", rp.AttachArtifact)
35
r.Delete("/{file}", rp.DeleteArtifact)
36
})
···
21
r.Route("/tags", func(r chi.Router) {
22
r.Get("/", rp.RepoTags)
23
r.Route("/{tag}", func(r chi.Router) {
24
r.Get("/download/{file}", rp.DownloadArtifact)
25
26
// require repo:push to upload or delete artifacts
···
28
// additionally: only the uploader can truly delete an artifact
29
// (record+blob will live on their pds)
30
r.Group(func(r chi.Router) {
31
+
r.Use(middleware.AuthMiddleware(rp.oauth))
32
+
r.Use(mw.RepoPermissionMiddleware("repo:push"))
33
r.Post("/upload", rp.AttachArtifact)
34
r.Delete("/{file}", rp.DeleteArtifact)
35
})
+1
-1
appview/reporesolver/resolver.go
+1
-1
appview/reporesolver/resolver.go
+51
appview/settings/settings.go
+51
appview/settings/settings.go
···
41
{"Name": "profile", "Icon": "user"},
42
{"Name": "keys", "Icon": "key"},
43
{"Name": "emails", "Icon": "mail"},
44
}
45
)
46
···
68
r.Post("/primary", s.emailsPrimary)
69
})
70
71
return r
72
}
73
···
79
Tabs: settingsTabs,
80
Tab: "profile",
81
})
82
}
83
84
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
···
41
{"Name": "profile", "Icon": "user"},
42
{"Name": "keys", "Icon": "key"},
43
{"Name": "emails", "Icon": "mail"},
44
+
{"Name": "notifications", "Icon": "bell"},
45
}
46
)
47
···
69
r.Post("/primary", s.emailsPrimary)
70
})
71
72
+
r.Route("/notifications", func(r chi.Router) {
73
+
r.Get("/", s.notificationsSettings)
74
+
r.Put("/", s.updateNotificationPreferences)
75
+
})
76
+
77
return r
78
}
79
···
85
Tabs: settingsTabs,
86
Tab: "profile",
87
})
88
+
}
89
+
90
+
func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) {
91
+
user := s.OAuth.GetUser(r)
92
+
did := s.OAuth.GetDid(r)
93
+
94
+
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
95
+
if err != nil {
96
+
log.Printf("failed to get notification preferences: %s", err)
97
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
98
+
return
99
+
}
100
+
101
+
s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{
102
+
LoggedInUser: user,
103
+
Preferences: prefs,
104
+
Tabs: settingsTabs,
105
+
Tab: "notifications",
106
+
})
107
+
}
108
+
109
+
func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
110
+
did := s.OAuth.GetDid(r)
111
+
112
+
prefs := &models.NotificationPreferences{
113
+
UserDid: did,
114
+
RepoStarred: r.FormValue("repo_starred") == "on",
115
+
IssueCreated: r.FormValue("issue_created") == "on",
116
+
IssueCommented: r.FormValue("issue_commented") == "on",
117
+
IssueClosed: r.FormValue("issue_closed") == "on",
118
+
PullCreated: r.FormValue("pull_created") == "on",
119
+
PullCommented: r.FormValue("pull_commented") == "on",
120
+
PullMerged: r.FormValue("pull_merged") == "on",
121
+
Followed: r.FormValue("followed") == "on",
122
+
EmailNotifications: r.FormValue("email_notifications") == "on",
123
+
}
124
+
125
+
err := s.Db.UpdateNotificationPreferences(r.Context(), prefs)
126
+
if err != nil {
127
+
log.Printf("failed to update notification preferences: %s", err)
128
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.")
129
+
return
130
+
}
131
+
132
+
s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.")
133
}
134
135
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
+65
-1
appview/signup/signup.go
+65
-1
appview/signup/signup.go
···
2
3
import (
4
"bufio"
5
"fmt"
6
"log/slog"
7
"net/http"
8
"os"
9
"strings"
10
···
116
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
117
switch r.Method {
118
case http.MethodGet:
119
-
s.pages.Signup(w)
120
case http.MethodPost:
121
if s.cf == nil {
122
http.Error(w, "signup is disabled", http.StatusFailedDependency)
123
}
124
emailId := r.FormValue("email")
125
126
noticeId := "signup-msg"
127
if !email.IsValidEmail(emailId) {
128
s.pages.Notice(w, noticeId, "Invalid email address.")
129
return
···
255
return
256
}
257
}
···
2
3
import (
4
"bufio"
5
+
"encoding/json"
6
+
"errors"
7
"fmt"
8
"log/slog"
9
"net/http"
10
+
"net/url"
11
"os"
12
"strings"
13
···
119
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
120
switch r.Method {
121
case http.MethodGet:
122
+
s.pages.Signup(w, pages.SignupParams{
123
+
CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey,
124
+
})
125
case http.MethodPost:
126
if s.cf == nil {
127
http.Error(w, "signup is disabled", http.StatusFailedDependency)
128
+
return
129
}
130
emailId := r.FormValue("email")
131
+
cfToken := r.FormValue("cf-turnstile-response")
132
133
noticeId := "signup-msg"
134
+
135
+
if err := s.validateCaptcha(cfToken, r); err != nil {
136
+
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
137
+
s.pages.Notice(w, noticeId, "Captcha validation failed.")
138
+
return
139
+
}
140
+
141
if !email.IsValidEmail(emailId) {
142
s.pages.Notice(w, noticeId, "Invalid email address.")
143
return
···
269
return
270
}
271
}
272
+
273
+
type turnstileResponse struct {
274
+
Success bool `json:"success"`
275
+
ErrorCodes []string `json:"error-codes,omitempty"`
276
+
ChallengeTs string `json:"challenge_ts,omitempty"`
277
+
Hostname string `json:"hostname,omitempty"`
278
+
}
279
+
280
+
func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
281
+
if cfToken == "" {
282
+
return errors.New("captcha token is empty")
283
+
}
284
+
285
+
if s.config.Cloudflare.TurnstileSecretKey == "" {
286
+
return errors.New("turnstile secret key not configured")
287
+
}
288
+
289
+
data := url.Values{}
290
+
data.Set("secret", s.config.Cloudflare.TurnstileSecretKey)
291
+
data.Set("response", cfToken)
292
+
293
+
// include the client IP if we have it
294
+
if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
295
+
data.Set("remoteip", remoteIP)
296
+
} else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
297
+
if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
298
+
data.Set("remoteip", strings.TrimSpace(ips[0]))
299
+
}
300
+
} else {
301
+
data.Set("remoteip", r.RemoteAddr)
302
+
}
303
+
304
+
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
305
+
if err != nil {
306
+
return fmt.Errorf("failed to verify turnstile token: %w", err)
307
+
}
308
+
defer resp.Body.Close()
309
+
310
+
var turnstileResp turnstileResponse
311
+
if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
312
+
return fmt.Errorf("failed to decode turnstile response: %w", err)
313
+
}
314
+
315
+
if !turnstileResp.Success {
316
+
s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
317
+
return errors.New("turnstile validation failed")
318
+
}
319
+
320
+
return nil
321
+
}
+151
appview/state/gfi.go
+151
appview/state/gfi.go
···
···
1
+
package state
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"sort"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/appview/pagination"
15
+
"tangled.org/core/consts"
16
+
)
17
+
18
+
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
19
+
user := s.oauth.GetUser(r)
20
+
21
+
page, ok := r.Context().Value("page").(pagination.Page)
22
+
if !ok {
23
+
page = pagination.FirstPage()
24
+
}
25
+
26
+
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
27
+
28
+
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
29
+
if err != nil {
30
+
log.Println("failed to get repo labels", err)
31
+
s.pages.Error503(w)
32
+
return
33
+
}
34
+
35
+
if len(repoLabels) == 0 {
36
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
37
+
LoggedInUser: user,
38
+
RepoGroups: []*models.RepoGroup{},
39
+
LabelDefs: make(map[string]*models.LabelDefinition),
40
+
Page: page,
41
+
})
42
+
return
43
+
}
44
+
45
+
repoUris := make([]string, 0, len(repoLabels))
46
+
for _, rl := range repoLabels {
47
+
repoUris = append(repoUris, rl.RepoAt.String())
48
+
}
49
+
50
+
allIssues, err := db.GetIssuesPaginated(
51
+
s.db,
52
+
pagination.Page{
53
+
Limit: 500,
54
+
},
55
+
db.FilterIn("repo_at", repoUris),
56
+
db.FilterEq("open", 1),
57
+
)
58
+
if err != nil {
59
+
log.Println("failed to get issues", err)
60
+
s.pages.Error503(w)
61
+
return
62
+
}
63
+
64
+
var goodFirstIssues []models.Issue
65
+
for _, issue := range allIssues {
66
+
if issue.Labels.ContainsLabel(goodFirstIssueLabel) {
67
+
goodFirstIssues = append(goodFirstIssues, issue)
68
+
}
69
+
}
70
+
71
+
repoGroups := make(map[syntax.ATURI]*models.RepoGroup)
72
+
for _, issue := range goodFirstIssues {
73
+
if group, exists := repoGroups[issue.Repo.RepoAt()]; exists {
74
+
group.Issues = append(group.Issues, issue)
75
+
} else {
76
+
repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{
77
+
Repo: issue.Repo,
78
+
Issues: []models.Issue{issue},
79
+
}
80
+
}
81
+
}
82
+
83
+
var sortedGroups []*models.RepoGroup
84
+
for _, group := range repoGroups {
85
+
sortedGroups = append(sortedGroups, group)
86
+
}
87
+
88
+
sort.Slice(sortedGroups, func(i, j int) bool {
89
+
iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid
90
+
jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid
91
+
92
+
// If one is tangled and the other isn't, non-tangled comes first
93
+
if iIsTangled != jIsTangled {
94
+
return jIsTangled // true if j is tangled (i should come first)
95
+
}
96
+
97
+
// Both tangled or both not tangled: sort by name
98
+
return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name
99
+
})
100
+
101
+
groupStart := page.Offset
102
+
groupEnd := page.Offset + page.Limit
103
+
if groupStart > len(sortedGroups) {
104
+
groupStart = len(sortedGroups)
105
+
}
106
+
if groupEnd > len(sortedGroups) {
107
+
groupEnd = len(sortedGroups)
108
+
}
109
+
110
+
paginatedGroups := sortedGroups[groupStart:groupEnd]
111
+
112
+
var allIssuesFromGroups []models.Issue
113
+
for _, group := range paginatedGroups {
114
+
allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...)
115
+
}
116
+
117
+
var allLabelDefs []models.LabelDefinition
118
+
if len(allIssuesFromGroups) > 0 {
119
+
labelDefUris := make(map[string]bool)
120
+
for _, issue := range allIssuesFromGroups {
121
+
for labelDefUri := range issue.Labels.Inner() {
122
+
labelDefUris[labelDefUri] = true
123
+
}
124
+
}
125
+
126
+
uriList := make([]string, 0, len(labelDefUris))
127
+
for uri := range labelDefUris {
128
+
uriList = append(uriList, uri)
129
+
}
130
+
131
+
if len(uriList) > 0 {
132
+
allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList))
133
+
if err != nil {
134
+
log.Println("failed to fetch labels", err)
135
+
}
136
+
}
137
+
}
138
+
139
+
labelDefsMap := make(map[string]*models.LabelDefinition)
140
+
for i := range allLabelDefs {
141
+
labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i]
142
+
}
143
+
144
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
145
+
LoggedInUser: user,
146
+
RepoGroups: paginatedGroups,
147
+
LabelDefs: labelDefsMap,
148
+
Page: page,
149
+
GfiLabel: labelDefsMap[goodFirstIssueLabel],
150
+
})
151
+
}
+14
-1
appview/state/knotstream.go
+14
-1
appview/state/knotstream.go
···
172
})
173
}
174
175
+
tx, err := d.Begin()
176
+
if err != nil {
177
+
return err
178
+
}
179
+
defer tx.Rollback()
180
+
181
+
// update appview's cache
182
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
183
+
if err != nil {
184
+
fmt.Printf("failed; %s\n", err)
185
+
// non-fatal
186
+
}
187
+
188
+
return tx.Commit()
189
}
190
191
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+5
-13
appview/state/profile.go
+5
-13
appview/state/profile.go
···
217
s.pages.Error500(w)
218
return
219
}
220
-
var repoAts []string
221
for _, s := range stars {
222
-
repoAts = append(repoAts, string(s.RepoAt))
223
-
}
224
-
225
-
repos, err := db.GetRepos(
226
-
s.db,
227
-
0,
228
-
db.FilterIn("at_uri", repoAts),
229
-
)
230
-
if err != nil {
231
-
l.Error("failed to get repos", "err", err)
232
-
s.pages.Error500(w)
233
-
return
234
}
235
236
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
345
profile.Did = did
346
}
347
followCards[i] = pages.FollowCard{
348
UserDid: did,
349
FollowStatus: followStatus,
350
FollowersCount: followStats.Followers,
···
217
s.pages.Error500(w)
218
return
219
}
220
+
var repos []models.Repo
221
for _, s := range stars {
222
+
if s.Repo != nil {
223
+
repos = append(repos, *s.Repo)
224
+
}
225
}
226
227
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
336
profile.Did = did
337
}
338
followCards[i] = pages.FollowCard{
339
+
LoggedInUser: loggedInUser,
340
UserDid: did,
341
FollowStatus: followStatus,
342
FollowersCount: followStats.Followers,
+16
-3
appview/state/router.go
+16
-3
appview/state/router.go
···
10
"tangled.org/core/appview/knots"
11
"tangled.org/core/appview/labels"
12
"tangled.org/core/appview/middleware"
13
oauthhandler "tangled.org/core/appview/oauth/handler"
14
"tangled.org/core/appview/pipelines"
15
"tangled.org/core/appview/pulls"
···
33
s.pages,
34
)
35
36
router.Get("/favicon.svg", s.Favicon)
37
router.Get("/favicon.ico", s.Favicon)
38
39
userRouter := s.UserRouter(&middleware)
40
standardRouter := s.StandardRouter(&middleware)
···
115
116
r.Get("/", s.HomeOrTimeline)
117
r.Get("/timeline", s.Timeline)
118
-
r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner)
119
120
// special-case handler for serving tangled.org/core
121
r.Get("/core", s.Core())
···
128
})
129
// r.Post("/import", s.ImportRepo)
130
})
131
132
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
133
r.Post("/", s.Follow)
···
156
r.Mount("/strings", s.StringsRouter(mw))
157
r.Mount("/knots", s.KnotsRouter())
158
r.Mount("/spindles", s.SpindlesRouter())
159
r.Mount("/signup", s.SignupRouter())
160
r.Mount("/", s.OAuthRouter())
161
162
r.Get("/keys/{user}", s.Keys)
163
r.Get("/terms", s.TermsOfService)
164
r.Get("/privacy", s.PrivacyPolicy)
165
166
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
167
s.pages.Error404(w)
···
175
return func(w http.ResponseWriter, r *http.Request) {
176
if r.URL.Query().Get("go-get") == "1" {
177
w.Header().Set("Content-Type", "text/html")
178
-
w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/tangled.org/core">`))
179
return
180
}
181
···
270
}
271
272
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
273
-
ls := labels.New(s.oauth, s.pages, s.db, s.validator)
274
return ls.Router(mw)
275
}
276
277
func (s *State) SignupRouter() http.Handler {
···
10
"tangled.org/core/appview/knots"
11
"tangled.org/core/appview/labels"
12
"tangled.org/core/appview/middleware"
13
+
"tangled.org/core/appview/notifications"
14
oauthhandler "tangled.org/core/appview/oauth/handler"
15
"tangled.org/core/appview/pipelines"
16
"tangled.org/core/appview/pulls"
···
34
s.pages,
35
)
36
37
+
router.Use(middleware.TryRefreshSession())
38
router.Get("/favicon.svg", s.Favicon)
39
router.Get("/favicon.ico", s.Favicon)
40
+
router.Get("/pwa-manifest.json", s.PWAManifest)
41
42
userRouter := s.UserRouter(&middleware)
43
standardRouter := s.StandardRouter(&middleware)
···
118
119
r.Get("/", s.HomeOrTimeline)
120
r.Get("/timeline", s.Timeline)
121
+
r.Get("/upgradeBanner", s.UpgradeBanner)
122
123
// special-case handler for serving tangled.org/core
124
r.Get("/core", s.Core())
···
131
})
132
// r.Post("/import", s.ImportRepo)
133
})
134
+
135
+
r.Get("/goodfirstissues", s.GoodFirstIssues)
136
137
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
138
r.Post("/", s.Follow)
···
161
r.Mount("/strings", s.StringsRouter(mw))
162
r.Mount("/knots", s.KnotsRouter())
163
r.Mount("/spindles", s.SpindlesRouter())
164
+
r.Mount("/notifications", s.NotificationsRouter(mw))
165
+
166
r.Mount("/signup", s.SignupRouter())
167
r.Mount("/", s.OAuthRouter())
168
169
r.Get("/keys/{user}", s.Keys)
170
r.Get("/terms", s.TermsOfService)
171
r.Get("/privacy", s.PrivacyPolicy)
172
+
r.Get("/brand", s.Brand)
173
174
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
175
s.pages.Error404(w)
···
183
return func(w http.ResponseWriter, r *http.Request) {
184
if r.URL.Query().Get("go-get") == "1" {
185
w.Header().Set("Content-Type", "text/html")
186
+
w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`))
187
return
188
}
189
···
278
}
279
280
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
281
+
ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer)
282
return ls.Router(mw)
283
+
}
284
+
285
+
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
286
+
notifs := notifications.New(s.db, s.oauth, s.pages)
287
+
return notifs.Router(mw)
288
}
289
290
func (s *State) SignupRouter() http.Handler {
+85
-6
appview/state/state.go
+85
-6
appview/state/state.go
···
25
"tangled.org/core/appview/db"
26
"tangled.org/core/appview/models"
27
"tangled.org/core/appview/notify"
28
"tangled.org/core/appview/oauth"
29
"tangled.org/core/appview/pages"
30
-
posthogService "tangled.org/core/appview/posthog"
31
"tangled.org/core/appview/reporesolver"
32
"tangled.org/core/appview/validator"
33
xrpcclient "tangled.org/core/appview/xrpcclient"
···
78
cache := cache.New(config.Redis.Addr)
79
sess := session.New(cache)
80
oauth := oauth.NewOAuth(config, sess)
81
-
validator := validator.New(d, res)
82
83
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
84
if err != nil {
···
87
88
repoResolver := reporesolver.New(config, enforcer, res, d)
89
90
-
wrapper := db.DbWrapper{d}
91
jc, err := jetstream.NewJetstreamClient(
92
config.Jetstream.Endpoint,
93
"appview",
···
103
tangled.RepoIssueNSID,
104
tangled.RepoIssueCommentNSID,
105
tangled.LabelDefinitionNSID,
106
},
107
nil,
108
slog.Default(),
···
115
)
116
if err != nil {
117
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
118
}
119
120
ingester := appview.Ingester{
···
143
spindlestream.Start(ctx)
144
145
var notifiers []notify.Notifier
146
if !config.Core.Dev {
147
-
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
148
}
149
notifier := notify.NewMergedNotifier(notifiers...)
150
···
187
s.pages.Favicon(w)
188
}
189
190
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
191
user := s.oauth.GetUser(r)
192
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
201
})
202
}
203
204
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
205
if s.oauth.GetUser(r) != nil {
206
s.Timeline(w, r)
···
229
return
230
}
231
232
-
s.pages.Timeline(w, pages.TimelineParams{
233
LoggedInUser: user,
234
Timeline: timeline,
235
Repos: repos,
236
-
})
237
}
238
239
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
240
user := s.oauth.GetUser(r)
241
l := s.logger.With("handler", "UpgradeBanner")
242
l = l.With("did", user.Did)
243
l = l.With("handle", user.Handle)
···
440
Rkey: rkey,
441
Description: description,
442
Created: time.Now(),
443
}
444
record := repo.AsRecord()
445
···
580
})
581
return err
582
}
···
25
"tangled.org/core/appview/db"
26
"tangled.org/core/appview/models"
27
"tangled.org/core/appview/notify"
28
+
dbnotify "tangled.org/core/appview/notify/db"
29
+
phnotify "tangled.org/core/appview/notify/posthog"
30
"tangled.org/core/appview/oauth"
31
"tangled.org/core/appview/pages"
32
"tangled.org/core/appview/reporesolver"
33
"tangled.org/core/appview/validator"
34
xrpcclient "tangled.org/core/appview/xrpcclient"
···
79
cache := cache.New(config.Redis.Addr)
80
sess := session.New(cache)
81
oauth := oauth.NewOAuth(config, sess)
82
+
validator := validator.New(d, res, enforcer)
83
84
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
85
if err != nil {
···
88
89
repoResolver := reporesolver.New(config, enforcer, res, d)
90
91
+
wrapper := db.DbWrapper{Execer: d}
92
jc, err := jetstream.NewJetstreamClient(
93
config.Jetstream.Endpoint,
94
"appview",
···
104
tangled.RepoIssueNSID,
105
tangled.RepoIssueCommentNSID,
106
tangled.LabelDefinitionNSID,
107
+
tangled.LabelOpNSID,
108
},
109
nil,
110
slog.Default(),
···
117
)
118
if err != nil {
119
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
120
+
}
121
+
122
+
if err := BackfillDefaultDefs(d, res); err != nil {
123
+
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
124
}
125
126
ingester := appview.Ingester{
···
149
spindlestream.Start(ctx)
150
151
var notifiers []notify.Notifier
152
+
153
+
// Always add the database notifier
154
+
notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res))
155
+
156
+
// Add other notifiers in production only
157
if !config.Core.Dev {
158
+
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
159
}
160
notifier := notify.NewMergedNotifier(notifiers...)
161
···
198
s.pages.Favicon(w)
199
}
200
201
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
202
+
const manifestJson = `{
203
+
"name": "tangled",
204
+
"description": "tightly-knit social coding.",
205
+
"icons": [
206
+
{
207
+
"src": "/favicon.svg",
208
+
"sizes": "144x144"
209
+
}
210
+
],
211
+
"start_url": "/",
212
+
"id": "org.tangled",
213
+
214
+
"display": "standalone",
215
+
"background_color": "#111827",
216
+
"theme_color": "#111827"
217
+
}`
218
+
219
+
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
220
+
w.Header().Set("Content-Type", "application/json")
221
+
w.Write([]byte(manifestJson))
222
+
}
223
+
224
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
225
user := s.oauth.GetUser(r)
226
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
235
})
236
}
237
238
+
func (s *State) Brand(w http.ResponseWriter, r *http.Request) {
239
+
user := s.oauth.GetUser(r)
240
+
s.pages.Brand(w, pages.BrandParams{
241
+
LoggedInUser: user,
242
+
})
243
+
}
244
+
245
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
246
if s.oauth.GetUser(r) != nil {
247
s.Timeline(w, r)
···
270
return
271
}
272
273
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
274
+
if err != nil {
275
+
// non-fatal
276
+
}
277
+
278
+
fmt.Println(s.pages.Timeline(w, pages.TimelineParams{
279
LoggedInUser: user,
280
Timeline: timeline,
281
Repos: repos,
282
+
GfiLabel: gfiLabel,
283
+
}))
284
}
285
286
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
287
user := s.oauth.GetUser(r)
288
+
if user == nil {
289
+
return
290
+
}
291
+
292
l := s.logger.With("handler", "UpgradeBanner")
293
l = l.With("did", user.Did)
294
l = l.With("handle", user.Handle)
···
491
Rkey: rkey,
492
Description: description,
493
Created: time.Now(),
494
+
Labels: models.DefaultLabelDefs(),
495
}
496
record := repo.AsRecord()
497
···
632
})
633
return err
634
}
635
+
636
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
637
+
defaults := models.DefaultLabelDefs()
638
+
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
639
+
if err != nil {
640
+
return err
641
+
}
642
+
// already present
643
+
if len(defaultLabels) == len(defaults) {
644
+
return nil
645
+
}
646
+
647
+
labelDefs, err := models.FetchDefaultDefs(r)
648
+
if err != nil {
649
+
return err
650
+
}
651
+
652
+
// Insert each label definition to the database
653
+
for _, labelDef := range labelDefs {
654
+
_, err = db.AddLabelDefinition(e, &labelDef)
655
+
if err != nil {
656
+
return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err)
657
+
}
658
+
}
659
+
660
+
return nil
661
+
}
+15
-1
appview/validator/label.go
+15
-1
appview/validator/label.go
···
95
return nil
96
}
97
98
-
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error {
99
if labelDef == nil {
100
return fmt.Errorf("label definition is required")
101
}
102
if labelOp == nil {
103
return fmt.Errorf("label operation is required")
104
}
105
106
expectedKey := labelDef.AtUri().String()
···
95
return nil
96
}
97
98
+
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error {
99
if labelDef == nil {
100
return fmt.Errorf("label definition is required")
101
}
102
+
if repo == nil {
103
+
return fmt.Errorf("repo is required")
104
+
}
105
if labelOp == nil {
106
return fmt.Errorf("label operation is required")
107
+
}
108
+
109
+
// validate permissions: only collaborators can apply labels currently
110
+
//
111
+
// TODO: introduce a repo:triage permission
112
+
ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo())
113
+
if err != nil {
114
+
return fmt.Errorf("failed to enforce permissions: %w", err)
115
+
}
116
+
if !ok {
117
+
return fmt.Errorf("unauhtorized label operation")
118
}
119
120
expectedKey := labelDef.AtUri().String()
+4
-1
appview/validator/validator.go
+4
-1
appview/validator/validator.go
···
4
"tangled.org/core/appview/db"
5
"tangled.org/core/appview/pages/markup"
6
"tangled.org/core/idresolver"
7
)
8
9
type Validator struct {
10
db *db.DB
11
sanitizer markup.Sanitizer
12
resolver *idresolver.Resolver
13
}
14
15
-
func New(db *db.DB, res *idresolver.Resolver) *Validator {
16
return &Validator{
17
db: db,
18
sanitizer: markup.NewSanitizer(),
19
resolver: res,
20
}
21
}
···
4
"tangled.org/core/appview/db"
5
"tangled.org/core/appview/pages/markup"
6
"tangled.org/core/idresolver"
7
+
"tangled.org/core/rbac"
8
)
9
10
type Validator struct {
11
db *db.DB
12
sanitizer markup.Sanitizer
13
resolver *idresolver.Resolver
14
+
enforcer *rbac.Enforcer
15
}
16
17
+
func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator {
18
return &Validator{
19
db: db,
20
sanitizer: markup.NewSanitizer(),
21
resolver: res,
22
+
enforcer: enforcer,
23
}
24
}
+1
-1
docs/spindle/pipeline.md
+1
-1
docs/spindle/pipeline.md
···
21
- `manual`: The workflow can be triggered manually.
22
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
24
-
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
26
```yaml
27
when:
···
21
- `manual`: The workflow can be triggered manually.
22
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
24
+
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
26
```yaml
27
when:
+1
-1
go.mod
+1
-1
go.mod
···
43
github.com/yuin/goldmark v1.7.12
44
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
golang.org/x/crypto v0.40.0
46
golang.org/x/net v0.42.0
47
golang.org/x/sync v0.16.0
48
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
···
168
go.uber.org/atomic v1.11.0 // indirect
169
go.uber.org/multierr v1.11.0 // indirect
170
go.uber.org/zap v1.27.0 // indirect
171
-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
172
golang.org/x/sys v0.34.0 // indirect
173
golang.org/x/text v0.27.0 // indirect
174
golang.org/x/time v0.12.0 // indirect
···
43
github.com/yuin/goldmark v1.7.12
44
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
golang.org/x/crypto v0.40.0
46
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
47
golang.org/x/net v0.42.0
48
golang.org/x/sync v0.16.0
49
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
···
169
go.uber.org/atomic v1.11.0 // indirect
170
go.uber.org/multierr v1.11.0 // indirect
171
go.uber.org/zap v1.27.0 // indirect
172
golang.org/x/sys v0.34.0 // indirect
173
golang.org/x/text v0.27.0 // indirect
174
golang.org/x/time v0.12.0 // indirect
+1
-1
knotserver/config/config.go
+1
-1
knotserver/config/config.go
-103
knotserver/git/git.go
-103
knotserver/git/git.go
···
27
h plumbing.Hash
28
}
29
30
-
type TagList struct {
31
-
refs []*TagReference
32
-
r *git.Repository
33
-
}
34
-
35
-
// TagReference is used to list both tag and non-annotated tags.
36
-
// Non-annotated tags should only contains a reference.
37
-
// Annotated tags should contain its reference and its tag information.
38
-
type TagReference struct {
39
-
ref *plumbing.Reference
40
-
tag *object.Tag
41
-
}
42
-
43
// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
44
// to tar WriteHeader
45
type infoWrapper struct {
···
48
mode fs.FileMode
49
modTime time.Time
50
isDir bool
51
-
}
52
-
53
-
func (self *TagList) Len() int {
54
-
return len(self.refs)
55
-
}
56
-
57
-
func (self *TagList) Swap(i, j int) {
58
-
self.refs[i], self.refs[j] = self.refs[j], self.refs[i]
59
-
}
60
-
61
-
// sorting tags in reverse chronological order
62
-
func (self *TagList) Less(i, j int) bool {
63
-
var dateI time.Time
64
-
var dateJ time.Time
65
-
66
-
if self.refs[i].tag != nil {
67
-
dateI = self.refs[i].tag.Tagger.When
68
-
} else {
69
-
c, err := self.r.CommitObject(self.refs[i].ref.Hash())
70
-
if err != nil {
71
-
dateI = time.Now()
72
-
} else {
73
-
dateI = c.Committer.When
74
-
}
75
-
}
76
-
77
-
if self.refs[j].tag != nil {
78
-
dateJ = self.refs[j].tag.Tagger.When
79
-
} else {
80
-
c, err := self.r.CommitObject(self.refs[j].ref.Hash())
81
-
if err != nil {
82
-
dateJ = time.Now()
83
-
} else {
84
-
dateJ = c.Committer.When
85
-
}
86
-
}
87
-
88
-
return dateI.After(dateJ)
89
}
90
91
func Open(path string, ref string) (*GitRepo, error) {
···
171
return g.r.CommitObject(h)
172
}
173
174
-
func (g *GitRepo) LastCommit() (*object.Commit, error) {
175
-
c, err := g.r.CommitObject(g.h)
176
-
if err != nil {
177
-
return nil, fmt.Errorf("last commit: %w", err)
178
-
}
179
-
return c, nil
180
-
}
181
-
182
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
183
c, err := g.r.CommitObject(g.h)
184
if err != nil {
···
211
}
212
213
return buf.Bytes(), nil
214
-
}
215
-
216
-
func (g *GitRepo) FileContent(path string) (string, error) {
217
-
c, err := g.r.CommitObject(g.h)
218
-
if err != nil {
219
-
return "", fmt.Errorf("commit object: %w", err)
220
-
}
221
-
222
-
tree, err := c.Tree()
223
-
if err != nil {
224
-
return "", fmt.Errorf("file tree: %w", err)
225
-
}
226
-
227
-
file, err := tree.File(path)
228
-
if err != nil {
229
-
return "", err
230
-
}
231
-
232
-
isbin, _ := file.IsBinary()
233
-
234
-
if !isbin {
235
-
return file.Contents()
236
-
} else {
237
-
return "", ErrBinaryFile
238
-
}
239
}
240
241
func (g *GitRepo) RawContent(path string) ([]byte, error) {
···
410
func (i *infoWrapper) Sys() any {
411
return nil
412
}
413
-
414
-
func (t *TagReference) Name() string {
415
-
return t.ref.Name().Short()
416
-
}
417
-
418
-
func (t *TagReference) Message() string {
419
-
if t.tag != nil {
420
-
return t.tag.Message
421
-
}
422
-
return ""
423
-
}
424
-
425
-
func (t *TagReference) TagObject() *object.Tag {
426
-
return t.tag
427
-
}
428
-
429
-
func (t *TagReference) Hash() plumbing.Hash {
430
-
return t.ref.Hash()
431
-
}
···
27
h plumbing.Hash
28
}
29
30
// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
31
// to tar WriteHeader
32
type infoWrapper struct {
···
35
mode fs.FileMode
36
modTime time.Time
37
isDir bool
38
}
39
40
func Open(path string, ref string) (*GitRepo, error) {
···
120
return g.r.CommitObject(h)
121
}
122
123
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
124
c, err := g.r.CommitObject(g.h)
125
if err != nil {
···
152
}
153
154
return buf.Bytes(), nil
155
}
156
157
func (g *GitRepo) RawContent(path string) ([]byte, error) {
···
326
func (i *infoWrapper) Sys() any {
327
return nil
328
}
+1
-3
knotserver/git/tag.go
+1
-3
knotserver/git/tag.go
···
2
3
import (
4
"fmt"
5
-
"slices"
6
"strconv"
7
"strings"
8
"time"
···
35
outFormat.WriteString("")
36
outFormat.WriteString(recordSeparator)
37
38
-
output, err := g.forEachRef(outFormat.String(), "refs/tags")
39
if err != nil {
40
return nil, fmt.Errorf("failed to get tags: %w", err)
41
}
···
94
tags = append(tags, tag)
95
}
96
97
-
slices.Reverse(tags)
98
return tags, nil
99
}
···
2
3
import (
4
"fmt"
5
"strconv"
6
"strings"
7
"time"
···
34
outFormat.WriteString("")
35
outFormat.WriteString(recordSeparator)
36
37
+
output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags")
38
if err != nil {
39
return nil, fmt.Errorf("failed to get tags: %w", err)
40
}
···
93
tags = append(tags, tag)
94
}
95
96
return tags, nil
97
}
-4
knotserver/http_util.go
-4
knotserver/http_util.go
+1
-1
knotserver/xrpc/repo_blob.go
+1
-1
knotserver/xrpc/repo_blob.go
+24
knotserver/xrpc/repo_tree.go
+24
knotserver/xrpc/repo_tree.go
···
4
"net/http"
5
"path/filepath"
6
"time"
7
8
"tangled.org/core/api/tangled"
9
"tangled.org/core/knotserver/git"
10
xrpcerr "tangled.org/core/xrpc/errors"
11
)
···
43
return
44
}
45
46
// convert NiceTree -> tangled.RepoTree_TreeEntry
47
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
48
for i, file := range files {
···
83
Parent: parentPtr,
84
Dotdot: dotdotPtr,
85
Files: treeEntries,
86
}
87
88
writeJson(w, response)
···
4
"net/http"
5
"path/filepath"
6
"time"
7
+
"unicode/utf8"
8
9
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/appview/pages/markup"
11
"tangled.org/core/knotserver/git"
12
xrpcerr "tangled.org/core/xrpc/errors"
13
)
···
45
return
46
}
47
48
+
// if any of these files are a readme candidate, pass along its blob contents too
49
+
var readmeFileName string
50
+
var readmeContents string
51
+
for _, file := range files {
52
+
if markup.IsReadmeFile(file.Name) {
53
+
contents, err := gr.RawContent(filepath.Join(path, file.Name))
54
+
if err != nil {
55
+
x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name)
56
+
}
57
+
58
+
if utf8.Valid(contents) {
59
+
readmeFileName = file.Name
60
+
readmeContents = string(contents)
61
+
break
62
+
}
63
+
}
64
+
}
65
+
66
// convert NiceTree -> tangled.RepoTree_TreeEntry
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
68
for i, file := range files {
···
103
Parent: parentPtr,
104
Dotdot: dotdotPtr,
105
Files: treeEntries,
106
+
Readme: &tangled.RepoTree_Readme{
107
+
Filename: readmeFileName,
108
+
Contents: readmeContents,
109
+
},
110
}
111
112
writeJson(w, response)
-158
legal/privacy.md
-158
legal/privacy.md
···
1
-
# Privacy Policy
2
-
3
-
**Last updated:** January 15, 2025
4
-
5
-
This Privacy Policy describes how Tangled ("we," "us," or "our")
6
-
collects, uses, and shares your personal information when you use our
7
-
platform and services (the "Service").
8
-
9
-
## 1. Information We Collect
10
-
11
-
### Account Information
12
-
13
-
When you create an account, we collect:
14
-
15
-
- Your chosen username
16
-
- Email address
17
-
- Profile information you choose to provide
18
-
- Authentication data
19
-
20
-
### Content and Activity
21
-
22
-
We store:
23
-
24
-
- Code repositories and associated metadata
25
-
- Issues, pull requests, and comments
26
-
- Activity logs and usage patterns
27
-
- Public keys for authentication
28
-
29
-
## 2. Data Location and Hosting
30
-
31
-
### EU Data Hosting
32
-
33
-
**All Tangled service data is hosted within the European Union.**
34
-
Specifically:
35
-
36
-
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
37
-
(*.tngl.sh) are located in Finland
38
-
- **Application Data:** All other service data is stored on EU-based
39
-
servers
40
-
- **Data Processing:** All data processing occurs within EU
41
-
jurisdiction
42
-
43
-
### External PDS Notice
44
-
45
-
**Important:** If your account is hosted on Bluesky's PDS or other
46
-
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
47
-
that data. The data protection, storage location, and privacy
48
-
practices for such accounts are governed by the respective PDS
49
-
provider's policies, not this Privacy Policy. We only control data
50
-
processing within our own services and infrastructure.
51
-
52
-
## 3. Third-Party Data Processors
53
-
54
-
We only share your data with the following third-party processors:
55
-
56
-
### Resend (Email Services)
57
-
58
-
- **Purpose:** Sending transactional emails (account verification,
59
-
notifications)
60
-
- **Data Shared:** Email address and necessary message content
61
-
62
-
### Cloudflare (Image Caching)
63
-
64
-
- **Purpose:** Caching and optimizing image delivery
65
-
- **Data Shared:** Public images and associated metadata for caching
66
-
purposes
67
-
68
-
### Posthog (Usage Metrics Tracking)
69
-
70
-
- **Purpose:** Tracking usage and platform metrics
71
-
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
72
-
information
73
-
74
-
## 4. How We Use Your Information
75
-
76
-
We use your information to:
77
-
78
-
- Provide and maintain the Service
79
-
- Process your transactions and requests
80
-
- Send you technical notices and support messages
81
-
- Improve and develop new features
82
-
- Ensure security and prevent fraud
83
-
- Comply with legal obligations
84
-
85
-
## 5. Data Sharing and Disclosure
86
-
87
-
We do not sell, trade, or rent your personal information. We may share
88
-
your information only in the following circumstances:
89
-
90
-
- With the third-party processors listed above
91
-
- When required by law or legal process
92
-
- To protect our rights, property, or safety, or that of our users
93
-
- In connection with a merger, acquisition, or sale of assets (with
94
-
appropriate protections)
95
-
96
-
## 6. Data Security
97
-
98
-
We implement appropriate technical and organizational measures to
99
-
protect your personal information against unauthorized access,
100
-
alteration, disclosure, or destruction. However, no method of
101
-
transmission over the Internet is 100% secure.
102
-
103
-
## 7. Data Retention
104
-
105
-
We retain your personal information for as long as necessary to provide
106
-
the Service and fulfill the purposes outlined in this Privacy Policy,
107
-
unless a longer retention period is required by law.
108
-
109
-
## 8. Your Rights
110
-
111
-
Under applicable data protection laws, you have the right to:
112
-
113
-
- Access your personal information
114
-
- Correct inaccurate information
115
-
- Request deletion of your information
116
-
- Object to processing of your information
117
-
- Data portability
118
-
- Withdraw consent (where applicable)
119
-
120
-
## 9. Cookies and Tracking
121
-
122
-
We use cookies and similar technologies to:
123
-
124
-
- Maintain your login session
125
-
- Remember your preferences
126
-
- Analyze usage patterns to improve the Service
127
-
128
-
You can control cookie settings through your browser preferences.
129
-
130
-
## 10. Children's Privacy
131
-
132
-
The Service is not intended for children under 16 years of age. We do
133
-
not knowingly collect personal information from children under 16. If
134
-
we become aware that we have collected such information, we will take
135
-
steps to delete it.
136
-
137
-
## 11. International Data Transfers
138
-
139
-
While all our primary data processing occurs within the EU, some of our
140
-
third-party processors may process data outside the EU. When this
141
-
occurs, we ensure appropriate safeguards are in place, such as Standard
142
-
Contractual Clauses or adequacy decisions.
143
-
144
-
## 12. Changes to This Privacy Policy
145
-
146
-
We may update this Privacy Policy from time to time. We will notify you
147
-
of any changes by posting the new Privacy Policy on this page and
148
-
updating the "Last updated" date.
149
-
150
-
## 13. Contact Information
151
-
152
-
If you have any questions about this Privacy Policy or wish to exercise
153
-
your rights, please contact us through our platform or via email.
154
-
155
-
---
156
-
157
-
This Privacy Policy complies with the EU General Data Protection
158
-
Regulation (GDPR) and other applicable data protection laws.
···
-109
legal/terms.md
-109
legal/terms.md
···
1
-
# Terms of Service
2
-
3
-
**Last updated:** January 15, 2025
4
-
5
-
Welcome to Tangled. These Terms of Service ("Terms") govern your access
6
-
to and use of the Tangled platform and services (the "Service")
7
-
operated by us ("Tangled," "we," "us," or "our").
8
-
9
-
## 1. Acceptance of Terms
10
-
11
-
By accessing or using our Service, you agree to be bound by these Terms.
12
-
If you disagree with any part of these terms, then you may not access
13
-
the Service.
14
-
15
-
## 2. Account Registration
16
-
17
-
To use certain features of the Service, you must register for an
18
-
account. You agree to provide accurate, current, and complete
19
-
information during the registration process and to update such
20
-
information to keep it accurate, current, and complete.
21
-
22
-
## 3. Account Termination
23
-
24
-
> **Important Notice**
25
-
>
26
-
> **We reserve the right to terminate, suspend, or restrict access to
27
-
> your account at any time, for any reason, or for no reason at all, at
28
-
> our sole discretion.** This includes, but is not limited to,
29
-
> termination for violation of these Terms, inappropriate conduct, spam,
30
-
> abuse, or any other behavior we deem harmful to the Service or other
31
-
> users.
32
-
>
33
-
> Account termination may result in the loss of access to your
34
-
> repositories, data, and other content associated with your account. We
35
-
> are not obligated to provide advance notice of termination, though we
36
-
> may do so in our discretion.
37
-
38
-
## 4. Acceptable Use
39
-
40
-
You agree not to use the Service to:
41
-
42
-
- Violate any applicable laws or regulations
43
-
- Infringe upon the rights of others
44
-
- Upload, store, or share content that is illegal, harmful, threatening,
45
-
abusive, harassing, defamatory, vulgar, obscene, or otherwise
46
-
objectionable
47
-
- Engage in spam, phishing, or other deceptive practices
48
-
- Attempt to gain unauthorized access to the Service or other users'
49
-
accounts
50
-
- Interfere with or disrupt the Service or servers connected to the
51
-
Service
52
-
53
-
## 5. Content and Intellectual Property
54
-
55
-
You retain ownership of the content you upload to the Service. By
56
-
uploading content, you grant us a non-exclusive, worldwide, royalty-free
57
-
license to use, reproduce, modify, and distribute your content as
58
-
necessary to provide the Service.
59
-
60
-
## 6. Privacy
61
-
62
-
Your privacy is important to us. Please review our [Privacy
63
-
Policy](/privacy), which also governs your use of the Service.
64
-
65
-
## 7. Disclaimers
66
-
67
-
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
68
-
no warranties, expressed or implied, and hereby disclaim and negate all
69
-
other warranties including without limitation, implied warranties or
70
-
conditions of merchantability, fitness for a particular purpose, or
71
-
non-infringement of intellectual property or other violation of rights.
72
-
73
-
## 8. Limitation of Liability
74
-
75
-
In no event shall Tangled, nor its directors, employees, partners,
76
-
agents, suppliers, or affiliates, be liable for any indirect,
77
-
incidental, special, consequential, or punitive damages, including
78
-
without limitation, loss of profits, data, use, goodwill, or other
79
-
intangible losses, resulting from your use of the Service.
80
-
81
-
## 9. Indemnification
82
-
83
-
You agree to defend, indemnify, and hold harmless Tangled and its
84
-
affiliates, officers, directors, employees, and agents from and against
85
-
any and all claims, damages, obligations, losses, liabilities, costs,
86
-
or debt, and expenses (including attorney's fees).
87
-
88
-
## 10. Governing Law
89
-
90
-
These Terms shall be interpreted and governed by the laws of Finland,
91
-
without regard to its conflict of law provisions.
92
-
93
-
## 11. Changes to Terms
94
-
95
-
We reserve the right to modify or replace these Terms at any time. If a
96
-
revision is material, we will try to provide at least 30 days notice
97
-
prior to any new terms taking effect.
98
-
99
-
## 12. Contact Information
100
-
101
-
If you have any questions about these Terms of Service, please contact
102
-
us through our platform or via email.
103
-
104
-
---
105
-
106
-
These terms are effective as of the last updated date shown above and
107
-
will remain in effect except with respect to any changes in their
108
-
provisions in the future, which will be in effect immediately after
109
-
being posted on this page.
···
+19
lexicons/repo/tree.json
+19
lexicons/repo/tree.json
···
41
"type": "string",
42
"description": "Parent directory path"
43
},
44
+
"readme": {
45
+
"type": "ref",
46
+
"ref": "#readme",
47
+
"description": "Readme for this file tree"
48
+
},
49
"files": {
50
"type": "array",
51
"items": {
···
74
"description": "Invalid request parameters"
75
}
76
]
77
+
},
78
+
"readme": {
79
+
"type": "object",
80
+
"required": ["filename", "contents"],
81
+
"properties": {
82
+
"filename": {
83
+
"type": "string",
84
+
"description": "Name of the readme file"
85
+
},
86
+
"contents": {
87
+
"type": "string",
88
+
"description": "Contents of the readme file"
89
+
}
90
+
}
91
},
92
"treeEntry": {
93
"type": "object",
+1
-1
nix/pkgs/knot-unwrapped.nix
+1
-1
nix/pkgs/knot-unwrapped.nix
+7
-5
types/repo.go
+7
-5
types/repo.go
···
41
}
42
43
type RepoTreeResponse struct {
44
-
Ref string `json:"ref,omitempty"`
45
-
Parent string `json:"parent,omitempty"`
46
-
Description string `json:"description,omitempty"`
47
-
DotDot string `json:"dotdot,omitempty"`
48
-
Files []NiceTree `json:"files,omitempty"`
49
}
50
51
type TagReference struct {
···
41
}
42
43
type RepoTreeResponse struct {
44
+
Ref string `json:"ref,omitempty"`
45
+
Parent string `json:"parent,omitempty"`
46
+
Description string `json:"description,omitempty"`
47
+
DotDot string `json:"dotdot,omitempty"`
48
+
Files []NiceTree `json:"files,omitempty"`
49
+
ReadmeFileName string `json:"readme_filename,omitempty"`
50
+
Readme string `json:"readme_contents,omitempty"`
51
}
52
53
type TagReference struct {