Yōten: A social platform for tracking the essential points of your language learning yoten.app
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: support mentions in comments

Signed-off-by: brookjeynes <me@brookjeynes.dev>

author
brookjeynes
committer
Tangled
date (May 25, 2026, 12:25 AM +0300) commit dd94922d parent a3a99f33 change-id wltryusp
+493 -45
+29
internal/atproto/resolver.go
··· 27 27 "context" 28 28 "net" 29 29 "net/http" 30 + "sync" 30 31 "time" 31 32 32 33 "github.com/bluesky-social/indigo/atproto/identity" ··· 75 76 } 76 77 77 78 return r.directory.Lookup(ctx, id) 79 + } 80 + 81 + func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { 82 + results := make([]*identity.Identity, len(idents)) 83 + var wg sync.WaitGroup 84 + 85 + done := make(chan struct{}) 86 + defer close(done) 87 + 88 + for idx, ident := range idents { 89 + wg.Add(1) 90 + go func(index int, id string) { 91 + defer wg.Done() 92 + 93 + select { 94 + case <-ctx.Done(): 95 + results[index] = nil 96 + case <-done: 97 + results[index] = nil 98 + default: 99 + identity, _ := r.ResolveIdent(ctx, id) 100 + results[index] = identity 101 + } 102 + }(idx, ident) 103 + } 104 + 105 + wg.Wait() 106 + return results 78 107 } 79 108 80 109 func (r *Resolver) Directory() identity.Directory {
+51 -10
internal/consumer/ingester.go
··· 13 13 "github.com/bluesky-social/jetstream/pkg/models" 14 14 15 15 "yoten.app/api/yoten" 16 + "yoten.app/internal/atproto" 16 17 "yoten.app/internal/cache" 17 18 "yoten.app/internal/clients/google" 18 19 "yoten.app/internal/config" 19 20 database "yoten.app/internal/db" 20 21 "yoten.app/internal/domain" 22 + richtext "yoten.app/internal/rich-text" 21 23 ) 22 24 23 25 type Ingester struct { ··· 25 27 Config *config.Config 26 28 Logger *slog.Logger 27 29 ProfileCache *cache.ProfileCache 30 + IdResolver *atproto.Resolver 28 31 } 29 32 30 33 type ProcessFunc func(ctx context.Context, event *models.Event) error ··· 72 75 err = i.ingestReaction(event, logger) 73 76 case yoten.FeedCommentNSID: 74 77 logger = logger.With("handler", "ingestComment") 75 - err = i.ingestComment(event, logger) 78 + err = i.ingestComment(ctx, event, logger) 76 79 } 77 80 if err != nil { 78 81 logger.Error("failed to ingest event", "err", err) ··· 608 611 return nil 609 612 } 610 613 611 - func (i *Ingester) ingestComment(event *models.Event, logger *slog.Logger) error { 614 + func (i *Ingester) ingestComment(ctx context.Context, event *models.Event, logger *slog.Logger) error { 612 615 var err error 613 616 614 617 did := event.Did ··· 663 666 parentCommentURI = &parentURI 664 667 } 665 668 669 + facets := richtext.ParseFacets(ctx, *i.IdResolver, logger, body) 670 + mentions := richtext.FacetsToMentions(facets) 671 + 672 + oldMentionDIDs, err := database.MentionDIDsForComment(i.Db, did, event.Commit.RKey) 673 + if err != nil { 674 + logger.Error("failed to fetch existing mention DIDs", "err", err) 675 + } 676 + 666 677 comment := domain.Comment{ 667 678 DID: did, 668 679 RKey: event.Commit.RKey, 669 680 StudySessionURI: subjectURI, 670 681 ParentCommentURI: parentCommentURI, 671 682 Body: body, 683 + Mentions: mentions, 672 684 CreatedAt: createdAt, 673 685 } 674 686 ··· 679 691 return fmt.Errorf("failed to upsert comment record: %w", err) 680 692 } 681 693 682 - // Create a comment if not commenting on self post. 694 + mentionRecipients := make(map[syntax.DID]struct{}, len(mentions)) 695 + for _, m := range mentions { 696 + if m.DID.String() != did { 697 + mentionRecipients[m.DID] = struct{}{} 698 + } 699 + } 700 + 701 + // send notification if the session owner is not already being notified 702 + // via a mention 683 703 if subjectDID.String() != did { 684 - err = database.CreateNotification(tx, subjectDID.String(), did, subjectURI.String(), domain.NotificationTypeComment) 685 - if err != nil { 686 - logger.Error("failed to create notification record", "err", err) 704 + if _, isMentioned := mentionRecipients[syntax.DID(subjectDID.String())]; !isMentioned { 705 + err = database.CreateNotification(tx, subjectDID.String(), did, subjectURI.String(), domain.NotificationTypeComment) 706 + if err != nil { 707 + logger.Error("failed to create notification record", "err", err) 708 + } 687 709 } 688 710 } 689 711 690 - // Notify comment creator of reply if not replying to their own comment. 712 + // notify the parent comment author of a reply, unless they are already 713 + // being notified via a mention 691 714 if comment.ParentCommentURI != nil && comment.ParentCommentURI.Authority().String() != did { 692 - err = database.CreateNotification(tx, comment.ParentCommentURI.Authority().String(), did, parentCommentURI.String(), domain.NotificationTypeReply) 693 - if err != nil { 694 - logger.Error("failed to create notification record", "err", err) 715 + parentAuthorDID := syntax.DID(comment.ParentCommentURI.Authority().String()) 716 + if _, isMentioned := mentionRecipients[parentAuthorDID]; !isMentioned { 717 + err = database.CreateNotification(tx, parentAuthorDID.String(), did, parentCommentURI.String(), domain.NotificationTypeReply) 718 + if err != nil { 719 + logger.Error("failed to create notification record", "err", err) 720 + } 721 + } 722 + } 723 + 724 + // notify only DIDs that weren't already mentioned 725 + commentURI := comment.CommentAt().String() 726 + oldMentionSet := make(map[syntax.DID]struct{}, len(oldMentionDIDs)) 727 + for _, d := range oldMentionDIDs { 728 + oldMentionSet[d] = struct{}{} 729 + } 730 + for recipientDID := range mentionRecipients { 731 + if _, alreadyNotified := oldMentionSet[recipientDID]; !alreadyNotified { 732 + err = database.CreateNotification(tx, recipientDID.String(), did, commentURI, domain.NotificationTypeMention) 733 + if err != nil { 734 + logger.Error("failed to create mention notification", "err", err) 735 + } 695 736 } 696 737 } 697 738
+111 -4
internal/db/comment.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 + "strings" 6 7 "time" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/syntax" ··· 13 14 func UpsertComment(e Execer, comment domain.Comment) error { 14 15 parentCommentURI := new(string) 15 16 if comment.ParentCommentURI != nil { 16 - parentCommentURI = ToPtr(comment.ParentCommentURI.String()) 17 - } else { 18 - parentCommentURI = nil 17 + parentCommentURI = new(comment.ParentCommentURI.String()) 19 18 } 20 19 21 20 _, err := e.Exec(` ··· 44 43 return fmt.Errorf("failed to insert or update comment: %w", err) 45 44 } 46 45 46 + var commentID int64 47 + err = e.QueryRow(`select id from comments where did = ? and rkey = ?`, comment.DID, comment.RKey).Scan(&commentID) 48 + if err != nil { 49 + return fmt.Errorf("failed to fetch comment id after upsert: %w", err) 50 + } 51 + 52 + _, err = e.Exec(`delete from comment_mentions where comment_id = ?`, commentID) 53 + if err != nil { 54 + return fmt.Errorf("failed to clear comment mentions: %w", err) 55 + } 56 + 57 + if len(comment.Mentions) > 0 { 58 + placeholders := strings.Repeat(",(?,?,?,?)", len(comment.Mentions))[1:] 59 + args := make([]any, 0, len(comment.Mentions)*4) 60 + for _, m := range comment.Mentions { 61 + args = append(args, commentID, string(m.DID), m.ByteStart, m.ByteEnd) 62 + } 63 + _, err = e.Exec(`insert into comment_mentions (comment_id, did, byte_start, byte_end) values `+placeholders, args...) 64 + if err != nil { 65 + return fmt.Errorf("failed to insert comment mentions: %w", err) 66 + } 67 + } 68 + 47 69 return nil 48 70 } 49 71 ··· 94 116 comment.ParentCommentURI = &parsedParentURI 95 117 } 96 118 119 + mentionsByID, err := loadMentionsForComments(e, []int64{comment.ID}) 120 + if err != nil { 121 + return domain.Comment{}, fmt.Errorf("failed to load mentions: %w", err) 122 + } 123 + comment.Mentions = mentionsByID[comment.ID] 124 + 97 125 return comment, nil 98 126 } 99 127 ··· 107 135 query := fmt.Sprintf( 108 136 `select study_session_uri, count(*) as total_comments 109 137 from comments 110 - where study_session_uri in (%s) and is_deleted == 0 138 + where study_session_uri in (%s) 111 139 group by study_session_uri`, placeholders) 112 140 113 141 args := utils.Map(uris, func(uri string) any { ··· 204 232 } 205 233 if err = replyRows.Err(); err != nil { 206 234 return nil, fmt.Errorf("error iterating reply rows: %w", err) 235 + } 236 + 237 + allIDs := make([]int64, 0) 238 + for _, uri := range topLevelCommentURIs { 239 + c := topLevelMap[uri] 240 + allIDs = append(allIDs, c.ID) 241 + for _, r := range c.Replies { 242 + allIDs = append(allIDs, r.ID) 243 + } 244 + } 245 + 246 + mentionsByID, err := loadMentionsForComments(e, allIDs) 247 + if err != nil { 248 + return nil, fmt.Errorf("failed to load mentions: %w", err) 249 + } 250 + 251 + for _, uri := range topLevelCommentURIs { 252 + c := topLevelMap[uri] 253 + c.Mentions = mentionsByID[c.ID] 254 + for i := range c.Replies { 255 + c.Replies[i].Mentions = mentionsByID[c.Replies[i].ID] 256 + } 207 257 } 208 258 209 259 result := make([]domain.Comment, 0, len(topLevelCommentURIs)) ··· 214 264 return result, nil 215 265 } 216 266 267 + func MentionDIDsForComment(e Execer, did, rkey string) ([]syntax.DID, error) { 268 + rows, err := e.Query(` 269 + select cm.did 270 + from comment_mentions cm 271 + join comments c on c.id = cm.comment_id 272 + where c.did = ? and c.rkey = ?`, 273 + did, rkey, 274 + ) 275 + if err != nil { 276 + return nil, fmt.Errorf("failed to query mention DIDs: %w", err) 277 + } 278 + defer rows.Close() 279 + 280 + var dids []syntax.DID 281 + for rows.Next() { 282 + var d string 283 + if err := rows.Scan(&d); err != nil { 284 + return nil, fmt.Errorf("failed to scan mention DID: %w", err) 285 + } 286 + dids = append(dids, syntax.DID(d)) 287 + } 288 + 289 + return dids, rows.Err() 290 + } 291 + 217 292 func scanComment(rows *sql.Rows) (domain.Comment, error) { 218 293 var comment domain.Comment 219 294 var parentURI sql.NullString ··· 249 324 250 325 return comment, nil 251 326 } 327 + 328 + func loadMentionsForComments(e Execer, commentIDs []int64) (map[int64][]domain.Mention, error) { 329 + result := make(map[int64][]domain.Mention) 330 + if len(commentIDs) == 0 { 331 + return result, nil 332 + } 333 + 334 + args := utils.Map(commentIDs, func(id int64) any { return any(id) }) 335 + query := `select comment_id, did, byte_start, byte_end from comment_mentions where comment_id in (` + GetPlaceholders(len(commentIDs)) + `)` 336 + 337 + rows, err := e.Query(query, args...) 338 + if err != nil { 339 + return nil, fmt.Errorf("failed to query comment_mentions: %w", err) 340 + } 341 + defer rows.Close() 342 + 343 + for rows.Next() { 344 + var cID int64 345 + var m domain.Mention 346 + var did string 347 + if err := rows.Scan(&cID, &did, &m.ByteStart, &m.ByteEnd); err != nil { 348 + return nil, fmt.Errorf("failed to scan comment_mention row: %w", err) 349 + } 350 + m.DID = syntax.DID(did) 351 + result[cID] = append(result[cID], m) 352 + } 353 + if err = rows.Err(); err != nil { 354 + return nil, fmt.Errorf("error iterating comment_mention rows: %w", err) 355 + } 356 + 357 + return result, nil 358 + }
+19
internal/db/db.go
··· 306 306 } 307 307 308 308 _, err = tx.ExecContext(ctx, ` 309 + create table if not exists comment_mentions ( 310 + -- id 311 + id integer primary key autoincrement, 312 + 313 + -- data 314 + comment_id integer not null, 315 + did text not null, 316 + byte_start integer not null, 317 + byte_end integer not null, 318 + 319 + -- constraints 320 + foreign key (comment_id) references comments(id) on delete cascade 321 + ); 322 + `) 323 + if err != nil { 324 + return nil, fmt.Errorf("failed to create comment_mentions table: %w", err) 325 + } 326 + 327 + _, err = tx.ExecContext(ctx, ` 309 328 create table if not exists _jetstream ( 310 329 -- id 311 330 id integer primary key autoincrement,
+31 -10
internal/db/notification.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 6 "time" 6 7 ··· 24 25 } 25 26 26 27 func Notifications(e Execer, did string, limit, offset int) ([]domain.Notification, error) { 28 + // for mention-type notifications the subject_uri is the comment at-uri. 29 + // we left-join comments so we can resolve the study session uri needed for 30 + // building the anchor link in the ui. 31 + // TODO: we should simplify this modal. perhaps we can use unions to handle 32 + // the different notification types? 33 + 27 34 query := ` 28 35 select 29 - id, 30 - recipient_did, 31 - actor_did, 32 - subject_uri, 33 - state, 34 - type, 35 - created_at 36 + n.id, 37 + n.recipient_did, 38 + n.actor_did, 39 + n.subject_uri, 40 + n.state, 41 + n.type, 42 + n.created_at, 43 + c.study_session_uri 36 44 from 37 - notifications 45 + notifications n 46 + left join comments c 47 + on n.type = 'mention' 48 + and ('at://' || c.did || '/app.yoten.feed.comment/' || c.rkey) = n.subject_uri 38 49 where 39 - recipient_did = ? 40 - order by created_at desc 50 + n.recipient_did = ? 51 + order by n.created_at desc 41 52 limit ? offset ? 42 53 ` 43 54 ··· 52 63 var notification domain.Notification 53 64 var createdAtStr string 54 65 var subjectURIStr string 66 + var studySessionURIStr sql.NullString 55 67 56 68 err := rows.Scan( 57 69 &notification.ID, ··· 61 73 &notification.State, 62 74 &notification.Type, 63 75 &createdAtStr, 76 + &studySessionURIStr, 64 77 ) 65 78 if err != nil { 66 79 return nil, fmt.Errorf("failed to scan notification row: %w", err) ··· 83 96 return nil, fmt.Errorf("failed to identify subject did: %w", err) 84 97 } 85 98 notification.SubjectDID = subjectDID.String() 99 + 100 + if studySessionURIStr.Valid { 101 + ssURI, err := syntax.ParseATURI(studySessionURIStr.String) 102 + if err != nil { 103 + return nil, fmt.Errorf("failed to parse study session at-uri: %w", err) 104 + } 105 + notification.StudySessionURI = &ssURI 106 + } 86 107 87 108 notifications = append(notifications, notification) 88 109 }
+7
internal/domain/comment.go
··· 6 6 7 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 8 "yoten.app/api/yoten" 9 + "yoten.app/internal/utils" 9 10 ) 10 11 11 12 type Comment struct { ··· 18 19 IsDeleted bool 19 20 CreatedAt time.Time 20 21 Replies []Comment 22 + Mentions []Mention 21 23 } 22 24 23 25 func (c Comment) CommentAt() syntax.ATURI { 24 26 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.DID, yoten.FeedCommentNSID, c.RKey)) 25 27 } 28 + 29 + func (c Comment) HasActiveReplies() bool { 30 + activeReplies := utils.Filter(c.Replies, func(r Comment) bool { return !r.IsDeleted }) 31 + return len(activeReplies) > 0 32 + }
+34
internal/domain/mention.go
··· 1 + package domain 2 + 3 + import ( 4 + "regexp" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + "yoten.app/api/yoten" 8 + ) 9 + 10 + const ( 11 + // Start at '@' 12 + MatchAtStart = 4 13 + // Start of handle 14 + MatchHandleStart = 6 15 + // End of handle 16 + MatchHandleEnd = 7 17 + ) 18 + 19 + // https://docs.bsky.app/docs/advanced-guides/post-richtext#producing-facets 20 + var MentionRegex = regexp.MustCompile( 21 + `(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`, 22 + ) 23 + 24 + type Mention struct { 25 + DID syntax.DID 26 + ByteStart int64 27 + ByteEnd int64 28 + } 29 + 30 + // Used when parsing raw text 31 + type PendingMention struct { 32 + Handle syntax.Handle 33 + Facet *yoten.RichtextFacet 34 + }
+8 -1
internal/domain/notification.go
··· 1 1 package domain 2 2 3 - import "time" 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 4 8 5 9 type NotificationType string 6 10 ··· 9 13 NotificationTypeReaction NotificationType = "reaction" 10 14 NotificationTypeComment NotificationType = "comment" 11 15 NotificationTypeReply NotificationType = "reply" 16 + NotificationTypeMention NotificationType = "mention" 12 17 ) 13 18 14 19 type NotificationState int ··· 33 38 State NotificationState 34 39 Type NotificationType 35 40 CreatedAt time.Time 41 + // only populated for NotificationTypeMention 42 + StudySessionURI *syntax.ATURI 36 43 }
+97
internal/rich-text/facets.go
··· 1 + package richtext 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "yoten.app/api/yoten" 9 + "yoten.app/internal/atproto" 10 + "yoten.app/internal/domain" 11 + ) 12 + 13 + func FacetsToMentions(facets []*yoten.RichtextFacet) []domain.Mention { 14 + mentions := make([]domain.Mention, 0, len(facets)) 15 + for _, facet := range facets { 16 + if facet == nil || facet.Index == nil { 17 + continue 18 + } 19 + if len(facet.Features) == 0 || facet.Features[0] == nil || facet.Features[0].RichtextFacet_Mention == nil { 20 + continue 21 + } 22 + mentions = append(mentions, domain.Mention{ 23 + DID: syntax.DID(facet.Features[0].RichtextFacet_Mention.Did), 24 + ByteStart: facet.Index.ByteStart, 25 + ByteEnd: facet.Index.ByteEnd, 26 + }) 27 + } 28 + return mentions 29 + } 30 + 31 + func ParseFacets(ctx context.Context, idResolver atproto.Resolver, logger *slog.Logger, text string) []*yoten.RichtextFacet { 32 + logger = logger.With("handler", "ParseFacets") 33 + 34 + matches := domain.MentionRegex.FindAllStringSubmatchIndex(text, -1) 35 + if len(matches) == 0 { 36 + return nil 37 + } 38 + 39 + pending := make([]domain.PendingMention, 0, len(matches)) 40 + handles := make([]string, 0, len(matches)) 41 + 42 + for _, match := range matches { 43 + atStart := match[domain.MatchAtStart] 44 + handleStart := match[domain.MatchHandleStart] 45 + handleEnd := match[domain.MatchHandleEnd] 46 + 47 + handleRaw := text[handleStart:handleEnd] 48 + 49 + facet := &yoten.RichtextFacet{ 50 + Index: &yoten.RichtextFacet_ByteSlice{ 51 + ByteStart: int64(atStart), 52 + ByteEnd: int64(handleEnd), 53 + }, 54 + } 55 + 56 + handle, err := syntax.ParseHandle(handleRaw) 57 + if err != nil { 58 + logger.Error("failed to parse handle", "handle", handleRaw, "err", err) 59 + continue 60 + } 61 + 62 + pending = append( 63 + pending, 64 + domain.PendingMention{ 65 + Handle: handle, 66 + Facet: facet, 67 + }, 68 + ) 69 + handles = append(handles, string(handle)) 70 + } 71 + 72 + identities := idResolver.ResolveIdents(ctx, handles) 73 + mentions := make(map[syntax.Handle]syntax.DID, len(identities)) 74 + for _, identity := range identities { 75 + if identity != nil && !identity.Handle.IsInvalidHandle() { 76 + mentions[identity.Handle] = identity.DID 77 + } 78 + } 79 + 80 + facets := make([]*yoten.RichtextFacet, 0, len(pending)) 81 + for _, mention := range pending { 82 + did, ok := mentions[syntax.Handle(mention.Handle)] 83 + if !ok { 84 + continue 85 + } 86 + mention.Facet.Features = []*yoten.RichtextFacet_Features_Elem{ 87 + { 88 + RichtextFacet_Mention: &yoten.RichtextFacet_Mention{ 89 + Did: string(did), 90 + }, 91 + }, 92 + } 93 + facets = append(facets, mention.Facet) 94 + } 95 + 96 + return facets 97 + }
+22 -3
internal/server/handlers/comment.go
··· 15 15 ph "yoten.app/internal/clients/posthog" 16 16 "yoten.app/internal/db" 17 17 "yoten.app/internal/domain" 18 + richtext "yoten.app/internal/rich-text" 18 19 "yoten.app/internal/server/htmx" 19 20 "yoten.app/internal/server/ui/components/comment" 20 21 editcomment "yoten.app/internal/server/ui/components/edit-comment" 21 22 newreply "yoten.app/internal/server/ui/components/new-reply" 22 23 "yoten.app/internal/server/ui/components/reply" 24 + richtextui "yoten.app/internal/server/ui/components/rich-text" 23 25 ) 24 26 25 27 func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { ··· 68 70 } 69 71 70 72 userReply := new(yoten.FeedComment_Reply) 71 - parentCommentURI := new(string) 73 + var parentCommentURI *string 72 74 parentCommentURIStr := r.FormValue("parent_uri") 73 75 if len(parentCommentURIStr) != 0 { 74 76 parentCommentURI = &parentCommentURIStr ··· 78 80 } 79 81 } 80 82 83 + facets := richtext.ParseFacets(r.Context(), *h.IdResolver, l, commentBody) 84 + mentions := richtext.FacetsToMentions(facets) 85 + 81 86 newComment := domain.Comment{ 82 87 RKey: atproto.TID(), 83 88 DID: user.DID, ··· 85 90 StudySessionURI: syntax.ATURI(studySessionURI), 86 91 Body: commentBody, 87 92 CreatedAt: time.Now().UTC(), 93 + Mentions: mentions, 88 94 } 89 95 90 96 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ ··· 97 103 Body: newComment.Body, 98 104 Subject: newComment.StudySessionURI.String(), 99 105 Reply: userReply, 106 + Facets: facets, 100 107 CreatedAt: newComment.CreatedAt.Format(time.RFC3339), 101 108 }, 102 109 }, ··· 267 274 return 268 275 } 269 276 277 + facets := richtext.ParseFacets(r.Context(), *h.IdResolver, l, commentBody) 278 + mentions := richtext.FacetsToMentions(facets) 279 + 280 + // TODO: validate comment 270 281 updatedComment := domain.Comment{ 271 282 RKey: comment.RKey, 272 283 DID: comment.DID, ··· 274 285 ParentCommentURI: comment.ParentCommentURI, 275 286 Body: commentBody, 276 287 CreatedAt: comment.CreatedAt, 288 + Mentions: mentions, 277 289 } 278 290 279 291 var reply *yoten.FeedComment_Reply = nil ··· 301 313 Subject: updatedComment.StudySessionURI.String(), 302 314 Reply: reply, 303 315 CreatedAt: updatedComment.CreatedAt.Format(time.RFC3339), 316 + Facets: facets, 304 317 }, 305 318 }, 306 319 SwapRecord: cid, ··· 332 345 } 333 346 334 347 w.WriteHeader(http.StatusOK) 335 - w.Write([]byte(updatedComment.Body)) 348 + richtextui.Body(richtextui.RichTextProps{ 349 + Body: updatedComment.Body, 350 + Mentions: updatedComment.Mentions, 351 + }).Render(r.Context(), w) 336 352 } 337 353 } 338 354 ··· 379 395 } 380 396 381 397 w.WriteHeader(http.StatusOK) 382 - w.Write([]byte(comment.Body)) 398 + richtextui.Body(richtextui.RichTextProps{ 399 + Body: comment.Body, 400 + Mentions: comment.Mentions, 401 + }).Render(r.Context(), w) 383 402 }
+2
internal/server/server-state.go
··· 101 101 yoten.FeedReactionNSID, 102 102 yoten.ActivityDefNSID, 103 103 yoten.GraphFollowNSID, 104 + yoten.RichtextFacetNSID, 104 105 }, 105 106 nil, 106 107 log.SubLogger(logger, "jetstream"), ··· 116 117 Config: config, 117 118 Logger: log.SubLogger(logger, "ingester"), 118 119 ProfileCache: profileCache, 120 + IdResolver: idResolver, 119 121 } 120 122 err = jc.StartJetstream(ctx, ingester.Ingest()) 121 123 if err != nil {
+9 -7
internal/server/ui/components/comment/comment.templ
··· 3 3 import ( 4 4 "fmt" 5 5 6 - "yoten.app/internal/domain" 7 6 "yoten.app/internal/server/ui" 8 7 "yoten.app/internal/server/ui/components/reply" 9 - "yoten.app/internal/utils" 8 + richtext "yoten.app/internal/server/ui/components/rich-text" 10 9 ) 11 10 12 11 templ Comment(params CommentProps) { 13 - {{ elementId := ui.SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.DID, params.Comment.RKey)) }} 12 + {{ elementId := ui.SanitiseHtmlId(params.Comment.CommentAt().String()) }} 14 13 <div id={ elementId } class="flex flex-col gap-3" x-init="lucide.createIcons()"> 15 14 <div class="flex items-center justify-between"> 16 15 <div class="flex items-center gap-3"> ··· 48 47 type="button" 49 48 id="edit-button" 50 49 hx-disabled-elt="#delete-button,#edit-button" 51 - hx-target={ fmt.Sprintf("#comment-body-%s-%s", ui.SanitiseHtmlId(params.Comment.DID), ui.SanitiseHtmlId(params.Comment.RKey)) } 50 + hx-target={ "#" + elementId + "-body" } 52 51 hx-swap="innerHTML" 53 52 hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", params.Comment.RKey)) } 54 53 > ··· 74 73 } 75 74 </div> 76 75 <p 77 - id={ fmt.Sprintf("comment-body-%s-%s", ui.SanitiseHtmlId(params.Comment.DID), ui.SanitiseHtmlId(params.Comment.RKey)) } 76 + id={ elementId + "-body" } 78 77 class="leading-relaxed break-words" 79 78 > 80 79 if params.Comment.IsDeleted { 81 80 <span class="opacity-70">This comment has been deleted</span> 82 81 } else { 83 - { params.Comment.Body } 82 + @richtext.Body(richtext.RichTextProps{ 83 + Body: params.Comment.Body, 84 + Mentions: params.Comment.Mentions, 85 + }) 84 86 } 85 87 </p> 86 - if !params.Comment.IsDeleted || len(utils.Filter(params.Comment.Replies, func(r domain.Comment) bool { return !r.IsDeleted })) > 0 { 88 + if !params.Comment.IsDeleted || params.Comment.HasActiveReplies() { 87 89 <button 88 90 hx-swap="afterend" 89 91 id="reply-button"
+2 -5
internal/server/ui/components/edit-comment/edit-comment.templ
··· 1 1 package editcomment 2 2 3 - import ( 4 - "fmt" 5 - "yoten.app/internal/server/ui" 6 - ) 3 + import "yoten.app/internal/server/ui" 7 4 8 5 templ EditComment(params EditCommentProps) { 9 6 <form ··· 35 32 </button> 36 33 <button 37 34 hx-get={ templ.SafeURL("/comment/cancel/" + params.Comment.RKey) } 38 - hx-target={ fmt.Sprintf("#comment-body-%s-%s", ui.SanitiseHtmlId(params.Comment.DID), ui.SanitiseHtmlId(params.Comment.RKey)) } 35 + hx-target={ "#" + ui.SanitiseHtmlId(params.Comment.CommentAt().String()) + "-body" } 39 36 hx-swap="innerHTML" 40 37 type="button" 41 38 id="cancel-comment-button"
+34 -1
internal/server/ui/components/notification/notification.templ
··· 1 1 package notification 2 2 3 - import "yoten.app/internal/domain" 3 + import ( 4 + "fmt" 5 + 6 + "yoten.app/api/yoten" 7 + "yoten.app/internal/domain" 8 + "yoten.app/internal/server/ui" 9 + ) 4 10 5 11 templ Notification(params NotificationProps) { 6 12 <div ··· 88 94 // <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.SubjectDID + "/comment/" + params.Notification.SubjectRKey) }> 89 95 // comment 90 96 // </a> 97 + </p> 98 + </div> 99 + case domain.NotificationTypeMention: 100 + {{ commentAtURI := fmt.Sprintf("at://%s/%s/%s", params.Notification.SubjectDID, yoten.FeedCommentNSID, params.Notification.SubjectRKey) }} 101 + {{ anchor := "#" + ui.SanitiseHtmlId(commentAtURI) + "-body" }} 102 + <div> 103 + <h1 class="font-semibold flex gap-2 items-center"> 104 + if params.BskyProfile.Avatar == "" { 105 + <div class="flex items-center justify-center w-6 h-6 rounded-full bg-primary"> 106 + <i class="w-4 h-4" data-lucide="user"></i> 107 + </div> 108 + } else { 109 + <img src={ params.BskyProfile.Avatar } class="w-6 h-6 rounded-full"/> 110 + } 111 + New Mention 112 + </h1> 113 + <p class="text-sm mt-2"> 114 + <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDID) }> 115 + &commat;{ params.BskyProfile.Handle } 116 + </a> 117 + if params.Notification.StudySessionURI != nil { 118 + {{ ssAuthorDID := params.Notification.StudySessionURI.Authority().String() }} 119 + {{ ssRKey := params.Notification.StudySessionURI.RecordKey().String() }} 120 + mentioned you in a <a class="hover:underline" href={ templ.SafeURL("/" + ssAuthorDID + "/session/" + ssRKey + anchor) }>comment</a> 121 + } else { 122 + mentioned you in a comment 123 + } 91 124 </p> 92 125 </div> 93 126 default:
+8 -4
internal/server/ui/components/reply/reply.templ
··· 3 3 import ( 4 4 "fmt" 5 5 "yoten.app/internal/server/ui" 6 + richtext "yoten.app/internal/server/ui/components/rich-text" 6 7 ) 7 8 8 9 templ Reply(props ReplyProps) { 9 - {{ replyId := ui.SanitiseHtmlId(fmt.Sprintf("reply-%s-%s", props.Reply.DID, props.Reply.RKey)) }} 10 + {{ replyId := ui.SanitiseHtmlId(props.Reply.CommentAt().String()) }} 10 11 <div id={ replyId } class="flex flex-col gap-3 pl-4 py-2 border-l-2 border-gray-200" x-init="lucide.createIcons()"> 11 12 <div class="flex items-center justify-between"> 12 13 <div class="flex items-center gap-3"> ··· 44 45 type="button" 45 46 id="edit-button" 46 47 hx-disabled-elt="#delete-button,#edit-button" 47 - hx-target={ fmt.Sprintf("#comment-body-%s-%s", ui.SanitiseHtmlId(props.Reply.DID), ui.SanitiseHtmlId(props.Reply.RKey)) } 48 + hx-target={ "#" + replyId + "-body" } 48 49 hx-swap="innerHTML" 49 50 hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", props.Reply.RKey)) } 50 51 > ··· 70 71 } 71 72 </div> 72 73 <p 73 - id={ fmt.Sprintf("comment-body-%s-%s", ui.SanitiseHtmlId(props.Reply.DID), ui.SanitiseHtmlId(props.Reply.RKey)) } 74 + id={ replyId + "-body" } 74 75 class="leading-relaxed break-words" 75 76 > 76 77 if props.Reply.IsDeleted { 77 78 <span class="opacity-70">This comment has been deleted</span> 78 79 } else { 79 - { props.Reply.Body } 80 + @richtext.Body(richtext.RichTextProps{ 81 + Body: props.Reply.Body, 82 + Mentions: props.Reply.Mentions, 83 + }) 80 84 } 81 85 </p> 82 86 </div>
+8
internal/server/ui/components/rich-text/rich-text.go
··· 1 + package richtext 2 + 3 + import "yoten.app/internal/domain" 4 + 5 + type RichTextProps struct { 6 + Body string 7 + Mentions []domain.Mention 8 + }
+21
internal/server/ui/components/rich-text/rich-text.templ
··· 1 + package richtext 2 + 3 + templ Body(props RichTextProps) { 4 + {{ cursor := 0 }} 5 + for _, mention := range props.Mentions { 6 + if mention.ByteStart > int64(cursor) { 7 + { props.Body[cursor:mention.ByteStart] } 8 + } 9 + {{ handle := props.Body[mention.ByteStart:mention.ByteEnd] }} 10 + <a 11 + href={ templ.URL("/" + mention.DID.String()) } 12 + class="decoration-primary font-medium underline hover:text-primary" 13 + > 14 + { handle } 15 + </a> 16 + {{ cursor = int(mention.ByteEnd) }} 17 + } 18 + if cursor < len(props.Body) { 19 + { props.Body[cursor:] } 20 + } 21 + }