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: add notification union

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

author
brookjeynes
committer
Tangled
date (May 27, 2026, 3:37 AM +0300) commit 4cfb51c5 parent 7326dadf change-id sxrkwyqk
+134 -86
+1 -1
input.css
··· 172 172 @utility btn-muted { 173 173 border-radius: theme(borderRadius.md); 174 174 border-color: theme(--color-bg-dark); 175 - background-color: theme(--color-bg); 175 + background-color: white; 176 176 border-width: 1px; 177 177 color: theme(--color-text-muted); 178 178
+2 -2
internal/consumer/ingester.go
··· 702 702 // via a mention 703 703 if subjectDID.String() != did { 704 704 if _, isMentioned := mentionRecipients[syntax.DID(subjectDID.String())]; !isMentioned { 705 - err = database.CreateNotification(tx, subjectDID.String(), did, subjectURI.String(), domain.NotificationTypeComment) 705 + err = database.CreateNotification(tx, subjectDID.String(), did, comment.CommentAt().String(), domain.NotificationTypeComment) 706 706 if err != nil { 707 707 logger.Error("failed to create notification record", "err", err) 708 708 } ··· 714 714 if comment.ParentCommentURI != nil && comment.ParentCommentURI.Authority().String() != did { 715 715 parentAuthorDID := syntax.DID(comment.ParentCommentURI.Authority().String()) 716 716 if _, isMentioned := mentionRecipients[parentAuthorDID]; !isMentioned { 717 - err = database.CreateNotification(tx, parentAuthorDID.String(), did, parentCommentURI.String(), domain.NotificationTypeReply) 717 + err = database.CreateNotification(tx, parentAuthorDID.String(), did, comment.CommentAt().String(), domain.NotificationTypeReply) 718 718 if err != nil { 719 719 logger.Error("failed to create notification record", "err", err) 720 720 }
+49 -28
internal/db/notification.go
··· 24 24 return nil 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 + func Notifications(e Execer, did string, limit, offset int) ([]domain.NotificationData, error) { 34 28 query := ` 35 29 select 36 30 n.id, ··· 44 38 from 45 39 notifications n 46 40 left join comments c 47 - on n.type = 'mention' 41 + on n.type in ('comment', 'reply', 'mention') 48 42 and ('at://' || c.did || '/app.yoten.feed.comment/' || c.rkey) = n.subject_uri 49 43 where 50 44 n.recipient_did = ? ··· 58 52 } 59 53 defer rows.Close() 60 54 61 - var notifications []domain.Notification 55 + var notifications []domain.NotificationData 62 56 for rows.Next() { 63 - var notification domain.Notification 57 + var notificationBase domain.NotificationBase 64 58 var createdAtStr string 65 59 var subjectURIStr string 66 60 var studySessionURIStr sql.NullString 67 61 68 62 err := rows.Scan( 69 - &notification.ID, 70 - &notification.RecipientDID, 71 - &notification.ActorDID, 63 + &notificationBase.ID, 64 + &notificationBase.RecipientDID, 65 + &notificationBase.ActorDID, 72 66 &subjectURIStr, 73 - &notification.State, 74 - &notification.Type, 67 + &notificationBase.State, 68 + &notificationBase.Type, 75 69 &createdAtStr, 76 70 &studySessionURIStr, 77 71 ) ··· 83 77 if err != nil { 84 78 return nil, fmt.Errorf("failed to parse createdAt string '%s': %w", createdAtStr, err) 85 79 } 86 - notification.CreatedAt = createdAt 80 + notificationBase.CreatedAt = createdAt 87 81 88 82 subjectURI, err := syntax.ParseATURI(subjectURIStr) 89 83 if err != nil { 90 - return nil, fmt.Errorf("failed to parse at-uri: %w", err) 84 + return nil, fmt.Errorf("failed to parse subject at-uri: %w", err) 91 85 } 92 - notification.SubjectRKey = subjectURI.RecordKey().String() 93 86 94 - subjectDID, err := subjectURI.Authority().AsDID() 95 - if err != nil { 96 - return nil, fmt.Errorf("failed to identify subject did: %w", err) 97 - } 98 - notification.SubjectDID = subjectDID.String() 99 - 100 - if studySessionURIStr.Valid { 101 - ssURI, err := syntax.ParseATURI(studySessionURIStr.String) 87 + var studySessionURI syntax.ATURI 88 + if notificationBase.Type == domain.NotificationTypeComment || 89 + notificationBase.Type == domain.NotificationTypeReply || 90 + notificationBase.Type == domain.NotificationTypeMention { 91 + if !studySessionURIStr.Valid { 92 + // study session or comment no longer exists, skip notification 93 + continue 94 + } 95 + studySessionURI, err = syntax.ParseATURI(studySessionURIStr.String) 102 96 if err != nil { 103 97 return nil, fmt.Errorf("failed to parse study session at-uri: %w", err) 104 98 } 105 - notification.StudySessionURI = &ssURI 106 99 } 107 100 108 - notifications = append(notifications, notification) 101 + switch notificationBase.Type { 102 + case domain.NotificationTypeFollow: 103 + notifications = append(notifications, domain.NotificationFollow{ 104 + NotificationBase: notificationBase, 105 + }) 106 + case domain.NotificationTypeReaction: 107 + notifications = append(notifications, domain.NotificationReaction{ 108 + NotificationBase: notificationBase, 109 + StudySessionURI: subjectURI, 110 + }) 111 + case domain.NotificationTypeComment: 112 + notifications = append(notifications, domain.NotificationComment{ 113 + NotificationBase: notificationBase, 114 + CommentURI: subjectURI, 115 + StudySessionURI: studySessionURI, 116 + }) 117 + case domain.NotificationTypeReply: 118 + notifications = append(notifications, domain.NotificationReply{ 119 + NotificationBase: notificationBase, 120 + CommentURI: subjectURI, 121 + StudySessionURI: studySessionURI, 122 + }) 123 + case domain.NotificationTypeMention: 124 + notifications = append(notifications, domain.NotificationMention{ 125 + NotificationBase: notificationBase, 126 + CommentURI: subjectURI, 127 + StudySessionURI: studySessionURI, 128 + }) 129 + } 109 130 } 110 131 if err = rows.Err(); err != nil { 111 132 return nil, err
+36 -9
internal/domain/notification.go
··· 23 23 NotificationStateRead NotificationState = 1 24 24 ) 25 25 26 - // TODO: remove this in favour of returning (Notification, BskyUser) 27 - type NotificationWithBskyHandle struct { 28 - Notification 29 - ActorBskyHandle string 26 + type NotificationData interface { 27 + GetBase() NotificationBase 30 28 } 31 29 32 - type Notification struct { 30 + type NotificationBase struct { 33 31 ID int 34 32 RecipientDID string 35 33 ActorDID string 36 - SubjectRKey string 37 - SubjectDID string 38 34 State NotificationState 39 35 Type NotificationType 40 36 CreatedAt time.Time 41 - // only populated for NotificationTypeMention 42 - StudySessionURI *syntax.ATURI 37 + } 38 + 39 + type NotificationFollow struct { 40 + NotificationBase 41 + } 42 + 43 + type NotificationReaction struct { 44 + NotificationBase 45 + StudySessionURI syntax.ATURI 46 + } 47 + 48 + type NotificationComment struct { 49 + NotificationBase 50 + StudySessionURI syntax.ATURI 51 + CommentURI syntax.ATURI 52 + } 53 + 54 + type NotificationReply struct { 55 + NotificationBase 56 + StudySessionURI syntax.ATURI 57 + CommentURI syntax.ATURI 43 58 } 59 + 60 + type NotificationMention struct { 61 + NotificationBase 62 + StudySessionURI syntax.ATURI 63 + CommentURI syntax.ATURI 64 + } 65 + 66 + func (n NotificationFollow) GetBase() NotificationBase { return n.NotificationBase } 67 + func (n NotificationReaction) GetBase() NotificationBase { return n.NotificationBase } 68 + func (n NotificationComment) GetBase() NotificationBase { return n.NotificationBase } 69 + func (n NotificationReply) GetBase() NotificationBase { return n.NotificationBase } 70 + func (n NotificationMention) GetBase() NotificationBase { return n.NotificationBase }
+2 -2
internal/server/handlers/notification.go
··· 47 47 return 48 48 } 49 49 50 - profileDIDs := utils.Map(notifications, func(item domain.Notification) string { 51 - return item.ActorDID 50 + profileDIDs := utils.Map(notifications, func(item domain.NotificationData) string { 51 + return item.GetBase().ActorDID 52 52 }) 53 53 bskyProfiles, err := bsky.GetBskyProfilesCached(r.Context(), h.ProfileCache, profileDIDs) 54 54 if err != nil {
+1 -1
internal/server/ui/components/notification-feed/notification-feed.go
··· 7 7 type NotificationFeedProps struct { 8 8 // The current logged in user 9 9 User *domain.User 10 - Feed []domain.Notification 10 + Feed []domain.NotificationData 11 11 BskyProfiles map[string]domain.BskyProfile 12 12 NextPage int 13 13 }
+1 -1
internal/server/ui/components/notification-feed/notification-feed.templ
··· 11 11 for _, n := range params.Feed { 12 12 @notification.Notification(notification.NotificationProps{ 13 13 Notification: n, 14 - BskyProfile: params.BskyProfiles[n.ActorDID], 14 + BskyProfile: params.BskyProfiles[n.GetBase().ActorDID], 15 15 }) 16 16 } 17 17 if params.NextPage > 0 {
+1 -1
internal/server/ui/components/notification/notification.go
··· 3 3 import "yoten.app/internal/domain" 4 4 5 5 type NotificationProps struct { 6 - Notification domain.Notification 6 + Notification domain.NotificationData 7 7 BskyProfile domain.BskyProfile 8 8 }
+38 -35
internal/server/ui/components/notification/notification.templ
··· 1 1 package notification 2 2 3 3 import ( 4 - "fmt" 5 - 6 - "yoten.app/api/yoten" 7 4 "yoten.app/internal/domain" 8 5 "yoten.app/internal/server/ui" 9 6 ) 10 7 11 8 templ Notification(params NotificationProps) { 12 9 <div 13 - id={ params.Notification.ID } 14 - class={ "card", templ.KV("border border-primary", params.Notification.State==domain.NotificationStateUnread) } 10 + class={ "card", templ.KV("border border-primary", params.Notification.GetBase().State==domain.NotificationStateUnread) } 15 11 > 16 - switch (params.Notification.Type) { 17 - case domain.NotificationTypeFollow: 12 + switch n := params.Notification.(type) { 13 + case domain.NotificationFollow: 18 14 <div> 19 15 <h1 class="font-semibold flex gap-2 items-center"> 20 16 if params.BskyProfile.Avatar == "" { ··· 27 23 New Follower 28 24 </h1> 29 25 <p class="text-sm mt-2"> 30 - <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDID) }> 26 + <a class="decoration-primary underline hover:text-primary" href={ templ.SafeURL("/" + n.ActorDID) }> 31 27 &commat;{ params.BskyProfile.Handle } 32 28 </a> started following you 33 29 </p> 34 30 </div> 35 - case domain.NotificationTypeReaction: 31 + case domain.NotificationReaction: 32 + {{ ssAuthorDID := n.StudySessionURI.Authority().String() }} 33 + {{ ssRKey := n.StudySessionURI.RecordKey().String() }} 36 34 <div> 37 35 <h1 class="font-semibold flex gap-2 items-center"> 38 36 if params.BskyProfile.Avatar == "" { ··· 45 43 New Reaction 46 44 </h1> 47 45 <p class="text-sm mt-2"> 48 - <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDID) }> 46 + <a class="decoration-primary underline hover:text-primary" href={ templ.SafeURL("/" + n.ActorDID) }> 49 47 &commat;{ params.BskyProfile.Handle } 50 48 </a> reacted to your 51 - <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.SubjectDID + "/session/" + params.Notification.SubjectRKey) }> 49 + <a class="decoration-primary underline hover:text-primary" href={ templ.SafeURL("/" + ssAuthorDID + "/session/" + ssRKey) }> 52 50 study session 53 51 </a> 54 52 </p> 55 53 </div> 56 - case domain.NotificationTypeComment: 54 + case domain.NotificationComment: 55 + // TODO: if the target comment is beyond the first page of comments on the 56 + // session, the anchor will not resolve on initial load. handle this when 57 + // comment pagination supports deep-linking. 58 + {{ anchor := "#" + ui.SanitiseHtmlId(n.CommentURI.String()) + "-body" }} 59 + {{ ssAuthorDID := n.StudySessionURI.Authority().String() }} 60 + {{ ssRKey := n.StudySessionURI.RecordKey().String() }} 57 61 <div> 58 62 <h1 class="font-semibold flex gap-2 items-center"> 59 63 if params.BskyProfile.Avatar == "" { ··· 66 70 New Comment 67 71 </h1> 68 72 <p class="text-sm mt-2"> 69 - <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDID) }> 73 + <a class="decoration-primary underline hover:text-primary" href={ templ.SafeURL("/" + n.ActorDID) }> 70 74 &commat;{ params.BskyProfile.Handle } 71 - </a> commented on your 72 - <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.SubjectDID + "/session/" + params.Notification.SubjectRKey) }> 73 - study session 74 75 </a> 76 + commented on your <a class="decoration-primary underline hover:text-primary" href={ templ.SafeURL("/" + ssAuthorDID + "/session/" + ssRKey + anchor) }>study session</a> 75 77 </p> 76 78 </div> 77 - case domain.NotificationTypeReply: 79 + case domain.NotificationReply: 80 + // TODO: if the target comment is beyond the first page of comments on the 81 + // session, the anchor will not resolve on initial load. handle this when 82 + // comment pagination supports deep-linking. 83 + {{ anchor := "#" + ui.SanitiseHtmlId(n.CommentURI.String()) + "-body" }} 84 + {{ ssAuthorDID := n.StudySessionURI.Authority().String() }} 85 + {{ ssRKey := n.StudySessionURI.RecordKey().String() }} 78 86 <div> 79 87 <h1 class="font-semibold flex gap-2 items-center"> 80 88 if params.BskyProfile.Avatar == "" { ··· 87 95 New Reply 88 96 </h1> 89 97 <p class="text-sm mt-2"> 90 - <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDID) }> 98 + <a class="decoration-primary underline hover:text-primary" href={ templ.SafeURL("/" + n.ActorDID) }> 91 99 &commat;{ params.BskyProfile.Handle } 92 - </a> replied to your study session 93 - // TODO: Link to comment. 94 - // <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.SubjectDID + "/comment/" + params.Notification.SubjectRKey) }> 95 - // comment 96 - // </a> 100 + </a> 101 + replied to your <a class="decoration-primary underline hover:text-primary" href={ templ.SafeURL("/" + ssAuthorDID + "/session/" + ssRKey + anchor) }>study session</a> 97 102 </p> 98 103 </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" }} 104 + case domain.NotificationMention: 105 + // TODO: if the target comment is beyond the first page of comments on the 106 + // session, the anchor will not resolve on initial load. handle this when 107 + // comment pagination supports deep-linking. 108 + {{ anchor := "#" + ui.SanitiseHtmlId(n.CommentURI.String()) + "-body" }} 109 + {{ ssAuthorDID := n.StudySessionURI.Authority().String() }} 110 + {{ ssRKey := n.StudySessionURI.RecordKey().String() }} 102 111 <div> 103 112 <h1 class="font-semibold flex gap-2 items-center"> 104 113 if params.BskyProfile.Avatar == "" { ··· 111 120 New Mention 112 121 </h1> 113 122 <p class="text-sm mt-2"> 114 - <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDID) }> 123 + <a class="decoration-primary underline hover:text-primary" href={ templ.SafeURL("/" + n.ActorDID) }> 115 124 &commat;{ params.BskyProfile.Handle } 116 125 </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 - } 126 + mentioned you in a <a class="decoration-primary underline hover:text-primary" href={ templ.SafeURL("/" + ssAuthorDID + "/session/" + ssRKey + anchor) }>comment</a> 124 127 </p> 125 128 </div> 126 129 default: 127 130 } 128 - <p class="text-xs text-text-muted">{ params.Notification.CreatedAt.Format("02/01/2006") }</p> 131 + <p class="text-xs text-text-muted">{ params.Notification.GetBase().CreatedAt.Format("02/01/2006") }</p> 129 132 </div> 130 133 }
+2 -5
internal/server/ui/views/notifications/notifications.go
··· 1 1 package notifications 2 2 3 - import ( 4 - "yoten.app/internal/domain" 5 - ) 3 + import "yoten.app/internal/domain" 6 4 7 5 type NotificationsPageParams struct { 8 6 // The current logged in user. 9 - User *domain.User 10 - Notifications []domain.NotificationWithBskyHandle 7 + User *domain.User 11 8 }
+1 -1
internal/server/ui/views/notifications/notifications.templ
··· 17 17 <div class="flex items-center space-x-2"> 18 18 if params.User.UnreadNotificationCount > 0 { 19 19 <button 20 - class="btn btn-muted" 20 + class="btn btn-secondary" 21 21 type="button" 22 22 id="mark-all-button" 23 23 hx-disabled-elt="#mark-all-button"