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