Monorepo for Tangled
tangled.org
1package models
2
3import (
4 "fmt"
5 "sort"
6 "strings"
7 "time"
8
9 comatproto "github.com/bluesky-social/indigo/api/atproto"
10 "github.com/bluesky-social/indigo/atproto/syntax"
11 typegen "github.com/whyrusleeping/cbor-gen"
12 "tangled.org/core/api/tangled"
13)
14
15type Comment struct {
16 Id int64
17
18 Did syntax.DID
19 Collection syntax.NSID
20 Rkey syntax.RecordKey
21 Cid syntax.CID
22
23 // record content
24 Subject comatproto.RepoStrongRef
25 Body tangled.MarkupMarkdown // markup body type. only markdown is supported right now
26 Created time.Time
27 ReplyTo *comatproto.RepoStrongRef // (optional) parent comment
28 PullRoundIdx *int // (optional) pull round number used when subject is sh.tangled.repo.pull
29
30 // store on db, but not on PDS
31 Edited *time.Time
32 Deleted *time.Time
33}
34
35func (c Comment) AtUri() syntax.ATURI {
36 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, c.Collection, c.Rkey))
37}
38
39// force-return the feed.comment NSID
40func (c Comment) FeedCommentAtUri() syntax.ATURI {
41 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, tangled.FeedCommentNSID, c.Rkey))
42}
43
44func (c Comment) StrongRef() comatproto.RepoStrongRef {
45 return comatproto.RepoStrongRef{
46 Uri: c.AtUri().String(),
47 Cid: c.Cid.String(),
48 }
49}
50
51func (c Comment) AsRecord() typegen.CBORMarshaler {
52 var pullRoundIdx *int64
53 if c.PullRoundIdx != nil {
54 pullRoundIdx = new(int64)
55 *pullRoundIdx = int64(*c.PullRoundIdx)
56 }
57 return &tangled.FeedComment{
58 Subject: &c.Subject,
59 Body: &tangled.FeedComment_Body{MarkupMarkdown: &c.Body},
60 CreatedAt: c.Created.Format(time.RFC3339),
61 ReplyTo: c.ReplyTo,
62 PullRoundIdx: pullRoundIdx,
63 }
64}
65
66func (c Comment) EditableBody() string {
67 if c.Body.Original != nil {
68 return *c.Body.Original
69 }
70 return c.Body.Text
71}
72
73func (c Comment) IsLegacy() bool {
74 return c.Collection != tangled.FeedCommentNSID
75}
76
77func (c *Comment) IsTopLevel() bool {
78 return c.ReplyTo == nil
79}
80
81func (c *Comment) IsReply() bool {
82 return c.ReplyTo != nil
83}
84
85func (c *Comment) Validate() error {
86 // TODO: sanitize the body and then trim space
87 if sb := strings.TrimSpace(c.Body.Text); sb == "" {
88 return fmt.Errorf("body is empty after HTML sanitization")
89 }
90
91 // if it's for PR, PullSubmissionId should not be nil
92 subjectAt, err := syntax.ParseATURI(c.Subject.Uri)
93 if err != nil {
94 return fmt.Errorf("subject.uri is not valid at-uri: %w", err)
95 }
96 if subjectAt.Collection().String() == tangled.RepoPullNSID {
97 if c.PullRoundIdx == nil {
98 return fmt.Errorf("pullSubmissionId should not be nil when subject is sh.tangled.repo.pull")
99 }
100 }
101 return nil
102}
103
104func CommentFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.FeedComment) (*Comment, error) {
105 created, err := time.Parse(time.RFC3339, record.CreatedAt)
106 if err != nil {
107 created = time.Now()
108 }
109
110 if record.Subject == nil {
111 return nil, fmt.Errorf("subject can't be nil")
112 }
113 subjectAt, err := syntax.ParseATURI(record.Subject.Uri)
114 if err != nil {
115 return nil, fmt.Errorf("invalid subject uri: %w", err)
116 }
117 if _, err = syntax.ParseCID(record.Subject.Cid); err != nil {
118 return nil, fmt.Errorf("invalid subject cid: %w", err)
119 }
120
121 if subjectAt.Collection() == tangled.RepoPullNSID {
122 if record.PullRoundIdx == nil {
123 return nil, fmt.Errorf("pullRoundIdx can't be nil when subject is sh.tangled.repo.pull")
124 }
125 }
126
127 if record.Body == nil {
128 return nil, fmt.Errorf("body can't be nil")
129 }
130 if record.Body.MarkupMarkdown == nil {
131 return nil, fmt.Errorf("body should be markdown type")
132 }
133
134 if record.ReplyTo != nil {
135 if _, err = syntax.ParseATURI(record.ReplyTo.Uri); err != nil {
136 return nil, fmt.Errorf("invalid replyTo uri: %w", err)
137 }
138 if _, err = syntax.ParseCID(record.ReplyTo.Cid); err != nil {
139 return nil, fmt.Errorf("invalid replyTo cid: %w", err)
140 }
141 }
142
143 var pullRoundIdx *int
144 if record.PullRoundIdx != nil {
145 pullRoundIdx = new(int)
146 *pullRoundIdx = int(*record.PullRoundIdx)
147 }
148
149 return &Comment{
150 Did: did,
151 Collection: tangled.FeedCommentNSID,
152 Rkey: rkey,
153 Cid: cid,
154
155 Subject: *record.Subject,
156 Body: *record.Body.MarkupMarkdown,
157 Created: created,
158 ReplyTo: record.ReplyTo,
159 PullRoundIdx: pullRoundIdx,
160 }, nil
161}
162
163type CommentListItem struct {
164 Self *Comment
165 Replies []*Comment
166}
167
168func (it *CommentListItem) Participants() []syntax.DID {
169 participantSet := make(map[syntax.DID]struct{})
170 participants := []syntax.DID{}
171
172 addParticipant := func(did syntax.DID) {
173 if _, exists := participantSet[did]; !exists {
174 participantSet[did] = struct{}{}
175 participants = append(participants, did)
176 }
177 }
178
179 addParticipant(syntax.DID(it.Self.Did))
180
181 for _, c := range it.Replies {
182 addParticipant(syntax.DID(c.Did))
183 }
184
185 return participants
186}
187
188func NewCommentList(comments []Comment) []CommentListItem {
189 // Create a map to quickly find comments by their aturi
190 toplevel := make(map[syntax.ATURI]*CommentListItem)
191 var replies []*Comment
192
193 // collect top level comments into the map
194 for _, comment := range comments {
195 if comment.IsTopLevel() {
196 toplevel[comment.AtUri()] = &CommentListItem{
197 Self: &comment,
198 }
199 } else {
200 replies = append(replies, &comment)
201 }
202 }
203
204 for _, r := range replies {
205 if r.ReplyTo == nil {
206 continue
207 }
208 uri := syntax.ATURI(r.ReplyTo.Uri)
209 if parent, exists := toplevel[uri]; exists {
210 parent.Replies = append(parent.Replies, r)
211 continue
212 }
213 // HACK: fallback to legacy comment collections
214 if parent, exists := toplevel[syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", uri.Authority(), tangled.RepoIssueCommentNSID, uri.RecordKey()))]; exists {
215 parent.Replies = append(parent.Replies, r)
216 continue
217 }
218 if parent, exists := toplevel[syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", uri.Authority(), tangled.RepoPullCommentNSID, uri.RecordKey()))]; exists {
219 parent.Replies = append(parent.Replies, r)
220 continue
221 }
222 }
223
224 var listing []CommentListItem
225 for _, v := range toplevel {
226 listing = append(listing, *v)
227 }
228
229 // sort everything
230 sortFunc := func(a, b *Comment) bool {
231 return a.Created.Before(b.Created)
232 }
233 sort.Slice(listing, func(i, j int) bool {
234 return sortFunc(listing[i].Self, listing[j].Self)
235 })
236 for _, r := range listing {
237 sort.Slice(r.Replies, func(i, j int) bool {
238 return sortFunc(r.Replies[i], r.Replies[j])
239 })
240 }
241
242 return listing
243}