feat: add post mention

This commit is contained in:
Krispire Admin
2026-06-10 16:28:57 +05:30
parent d86d876ac1
commit cb62adf2de
11 changed files with 472 additions and 34 deletions
+33
View File
@@ -32,6 +32,7 @@ const (
NotifFollowRequest = "follow_request"
NotifFollowAccepted = "follow_accepted"
NotifPostLike = "post_like"
NotifPostMention = "post_mention"
// Reminder kinds are delivered live (email + push + a websocket toast)
// but NOT persisted — they're ephemeral nudges, not history.
@@ -192,6 +193,38 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
// single post — a burst of likes within the window produces one email.
const likeEmailThrottle = 6 * time.Hour
// NotifyPostMention tells `mentionedID` that `actorID` (the post author)
// tagged them in a post. Persisted row + realtime + Web Push; no email
// (push/in-app is enough, and a post can tag many people). Skips self-tags.
func (s *NotificationService) NotifyPostMention(ctx context.Context, mentionedID, actorID, postID string) {
if mentionedID == "" || actorID == "" || mentionedID == actorID {
return
}
if err := s.notifs.CreateMention(ctx, mentionedID, actorID, postID); err != nil {
s.logger.Warn("persist mention notification failed",
zap.String("recipient", mentionedID), zap.Error(err))
}
actorUser, _ := s.users.FindByID(ctx, actorID)
s.publishRealtime(ctx, mentionedID, NotifPostMention, actorUser)
if s.push == nil {
return
}
who := "Someone"
url := s.frontendURL + "/notifications"
if actorUser != nil {
who = actorDisplay(actorUser)
url = s.frontendURL + "/u/" + actorUser.Handle + "/posts#post-" + postID
}
s.push.SendToUser(ctx, mentionedID, PushPayload{
Title: "You were mentioned",
Body: who + " mentioned you in a post",
URL: url,
Icon: s.iconURL(),
Tag: NotifPostMention + ":" + postID,
})
}
// 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 {
+99 -8
View File
@@ -31,14 +31,15 @@ var (
// ---------------------------------------------------------------------------
type PostService struct {
posts *repository.PostRepo
r2 *repository.R2
likes *repository.LikeRepo
notif *NotificationService // optional; set via SetNotifier
posts *repository.PostRepo
r2 *repository.R2
likes *repository.LikeRepo
mentions *repository.MentionRepo
notif *NotificationService // optional; set via SetNotifier
}
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo) *PostService {
return &PostService{posts: posts, r2: r2, likes: likes}
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo, mentions *repository.MentionRepo) *PostService {
return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions}
}
// SetNotifier wires the notification seam after construction (the
@@ -49,7 +50,7 @@ func (s *PostService) SetNotifier(n *NotificationService) { s.notif = n }
// the frontend to send the exact text the user selected. Source URL must be
// a same-origin path (e.g. /books/bg/1/1?hl=…) — we strip the host if one
// was provided so links can't be hijacked.
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs, mentionUserIDs []string) (*model.Post, error) {
caption = strings.TrimSpace(caption)
if caption == "" {
return nil, errors.New("caption is required")
@@ -64,7 +65,19 @@ func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, so
if len(imageURLs) > 5 {
return nil, errors.New("a post can hold up to 5 images")
}
return s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
p, err := s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
if err != nil {
return nil, err
}
// Tag mentioned users (best-effort, additive — a bad id never fails the
// post itself). Reflect the count on the returned post, then notify the
// tagged people (fire-and-forget on a detached context).
if len(mentionUserIDs) > 0 {
_ = s.mentions.AddMany(ctx, p.ID, mentionUserIDs)
_ = s.mentions.HydrateCounts(ctx, []*model.Post{p})
go s.notifyMentions(context.Background(), p.ID, userID, mentionUserIDs)
}
return p, nil
}
// Get returns one post (public). `viewerID` is "" for anonymous callers;
@@ -81,6 +94,9 @@ func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post
if err := s.likes.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil {
return nil, err
}
if err := s.mentions.HydrateCounts(ctx, []*model.Post{p}); err != nil {
return nil, err
}
return p, nil
}
@@ -183,6 +199,9 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string,
if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil {
return nil, 0, err
}
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
return nil, 0, err
}
return posts, total, nil
}
@@ -198,6 +217,75 @@ func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, po
return s.likes.Hydrate(ctx, viewerID, ptrs)
}
// hydrateMentionsSlice fills MentionsCount on a value slice.
func (s *PostService) hydrateMentionsSlice(ctx context.Context, posts []model.Post) error {
if len(posts) == 0 {
return nil
}
ptrs := make([]*model.Post, len(posts))
for i := range posts {
ptrs[i] = &posts[i]
}
return s.mentions.HydrateCounts(ctx, ptrs)
}
// SuggestMentions returns prioritised mention suggestions for the composer.
func (s *PostService) SuggestMentions(ctx context.Context, viewerID, q string, limit, offset int) ([]model.UserSummary, error) {
return s.mentions.Suggest(ctx, viewerID, q, limit, offset)
}
// Mentions lists the users tagged on a post.
func (s *PostService) Mentions(ctx context.Context, postID string) ([]model.UserSummary, error) {
return s.mentions.ListForPost(ctx, postID)
}
// AddMention / RemoveMention edit a post's tags — author only.
func (s *PostService) AddMention(ctx context.Context, viewerID, postID, targetUserID string) error {
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
return err
}
if err := s.mentions.Add(ctx, postID, targetUserID); err != nil {
return err
}
if s.notif != nil {
go s.notif.NotifyPostMention(context.Background(), targetUserID, viewerID, postID)
}
return nil
}
// notifyMentions notifies each freshly-tagged user (deduped, never the author).
func (s *PostService) notifyMentions(ctx context.Context, postID, authorID string, userIDs []string) {
if s.notif == nil {
return
}
seen := make(map[string]bool, len(userIDs))
for _, uid := range userIDs {
if uid == "" || uid == authorID || seen[uid] {
continue
}
seen[uid] = true
s.notif.NotifyPostMention(ctx, uid, authorID, postID)
}
}
func (s *PostService) RemoveMention(ctx context.Context, viewerID, postID, targetUserID string) error {
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
return err
}
return s.mentions.Remove(ctx, postID, targetUserID)
}
func (s *PostService) requireAuthor(ctx context.Context, viewerID, postID string) error {
p, err := s.posts.FindByID(ctx, postID)
if err != nil {
return err
}
if p.UserID != viewerID {
return ErrForbidden
}
return nil
}
// Like / Unlike toggle the caller's like on a post and return the effective
// total (likes_count + pending Redis delta).
func (s *PostService) Like(ctx context.Context, userID, postID string) (int, error) {
@@ -256,6 +344,9 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
return nil, 0, err
}
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
return nil, 0, err
}
return posts, total, nil
}