From 2135ec334cc9fd01e379d8b3642db512f83856ff Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sat, 13 Jun 2026 13:10:05 +0530 Subject: [PATCH] feat: add inline mentions in comments --- internal/service/comment_service.go | 37 ++++++++++++++++++++++++ internal/service/notification_service.go | 31 +++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index e961f13..00579f8 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -4,12 +4,41 @@ import ( "context" "errors" "fmt" + "regexp" "strings" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" ) +// mentionHandleRe matches @handle tokens. The leading boundary (line start or +// a non-handle character that isn't itself "@") keeps it from firing inside +// emails (foo@bar) or "@@". +var mentionHandleRe = regexp.MustCompile(`(^|[^A-Za-z0-9_@])@([A-Za-z0-9_]{1,30})`) + +// mentionHandles pulls unique @handles out of a comment body (capped, so a +// pathological body can't fan out unboundedly). +func mentionHandles(body string) []string { + matches := mentionHandleRe.FindAllStringSubmatch(body, -1) + if matches == nil { + return nil + } + seen := map[string]bool{} + out := make([]string, 0, len(matches)) + for _, m := range matches { + key := strings.ToLower(m[2]) + if seen[key] { + continue + } + seen[key] = true + out = append(out, m[2]) + if len(out) >= 20 { + break + } + } + return out +} + // maxCommentLen caps a comment/reply body. const maxCommentLen = 2000 @@ -87,6 +116,14 @@ func (s *CommentService) Add(ctx context.Context, viewerID, postID string, paren go s.notif.NotifyPostComment(context.Background(), postID, notifyUserID, viewerID) } } + + // Ping anyone @mentioned in the body — skipping the actor and whoever was + // already notified above (post/parent author) so they aren't double-pinged. + if s.notif != nil { + if handles := mentionHandles(body); len(handles) > 0 { + go s.notif.NotifyCommentMentions(context.Background(), postID, viewerID, handles, notifyUserID) + } + } return c, nil } diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 887ba65..e52b0a9 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -37,6 +37,7 @@ const ( NotifPostMention = "post_mention" NotifPostComment = "post_comment" NotifCommentReply = "comment_reply" + NotifCommentMention = "comment_mention" // Reminder kinds are delivered live (email + push + a websocket toast) // but NOT persisted — they're ephemeral nudges, not history. @@ -394,8 +395,11 @@ func (s *NotificationService) notifyComment(ctx context.Context, kind, postID, r url = s.frontendURL + "/u/" + h + "/posts#post-" + postID } title, body := "New comment", who+" commented on your post" - if kind == NotifCommentReply { + switch kind { + case NotifCommentReply: title, body = "New reply", who+" replied to your comment" + case NotifCommentMention: + title, body = "You were mentioned", who+" mentioned you in a comment" } s.push.SendToUser(ctx, recipientID, PushPayload{ Title: title, @@ -406,6 +410,31 @@ func (s *NotificationService) notifyComment(ctx context.Context, kind, postID, r }) } +// NotifyCommentMentions tells each user named by `handles` that `actorID` +// mentioned them in a comment on `postID`. Handles are resolved to users, +// deduped, and the actor plus any `skip` ids (e.g. the post/parent author who +// already got a comment/reply notification) are excluded so nobody is +// double-pinged. Reuses the comment notification path (row + realtime + push). +func (s *NotificationService) NotifyCommentMentions(ctx context.Context, postID, actorID string, handles []string, skip ...string) { + if actorID == "" || len(handles) == 0 { + return + } + skipSet := map[string]bool{actorID: true} + for _, id := range skip { + if id != "" { + skipSet[id] = true + } + } + for _, h := range handles { + u, err := s.users.FindByHandle(ctx, h) + if err != nil || u == nil || skipSet[u.ID] { + continue + } + skipSet[u.ID] = true // dedupe repeats of the same mention + s.notifyComment(ctx, NotifCommentMention, postID, u.ID, actorID) + } +} + // likeMessage renders the aggregated like text: "X liked your post" or // "X and N others liked your post". func likeMessage(who string, count int) string {