From cee2d3c8749f3c2d46f6c8473136a6bbffc04a34 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sun, 30 Aug 2026 01:51:32 +0530 Subject: [PATCH] feat: chat reactions --- internal/handler/chat.go | 74 ++++++ internal/model/chat.go | 22 ++ internal/repository/chat_repo.go | 161 +++++++++++-- internal/server/server.go | 5 + internal/service/chat_service.go | 256 ++++++++++++++++++--- internal/service/chat_service_test.go | 255 ++++++++++++++++++++ migrations/0053_message_reactions.down.sql | 1 + migrations/0053_message_reactions.up.sql | 27 +++ 8 files changed, 758 insertions(+), 43 deletions(-) create mode 100644 migrations/0053_message_reactions.down.sql create mode 100644 migrations/0053_message_reactions.up.sql diff --git a/internal/handler/chat.go b/internal/handler/chat.go index 94686e2..67b5740 100644 --- a/internal/handler/chat.go +++ b/internal/handler/chat.go @@ -38,6 +38,8 @@ func writeChatError(w http.ResponseWriter, err error) { response.Error(w, http.StatusNotFound, err.Error()) case errors.Is(err, service.ErrChatBadEntity): response.Error(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, service.ErrChatBadEmoji): + response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrChatBadQuote): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrChatBadAvatarKey): @@ -648,6 +650,78 @@ func (h *ChatHandler) SetMute(w http.ResponseWriter, r *http.Request) { response.JSON(w, http.StatusOK, map[string]any{"muted_until": until}) } +// Message — GET /chat/conversations/{id}/messages/{messageID} +func (h *ChatHandler) Message(w http.ResponseWriter, r *http.Request) { + msg, err := h.svc.Message(r.Context(), middleware.UserID(r.Context()), + chi.URLParam(r, "id"), chi.URLParam(r, "messageID")) + if err != nil { + writeChatError(w, err) + return + } + response.JSON(w, http.StatusOK, msg) +} + +// MessageReactions — GET /chat/conversations/{id}/messages/{messageID}/reactions +// +// Only read when someone opens the list; the thread itself renders from the +// grouped counts that ride along with each message. +func (h *ChatHandler) MessageReactions(w http.ResponseWriter, r *http.Request) { + list, err := h.svc.MessageReactions(r.Context(), + middleware.UserID(r.Context()), chi.URLParam(r, "messageID")) + if err != nil { + writeChatError(w, err) + return + } + response.JSON(w, http.StatusOK, list) +} + +// Typing — POST /chat/conversations/{id}/typing +// +// Fire-and-forget: nothing is stored and the response carries nothing, because +// a typing indicator is stale within seconds of being sent. +func (h *ChatHandler) Typing(w http.ResponseWriter, r *http.Request) { + if err := h.svc.Typing(r.Context(), + middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil { + writeChatError(w, err) + return + } + response.NoContent(w) +} + +type reactReq struct { + Emoji string `json:"emoji"` +} + +// React — PUT /chat/conversations/{id}/messages/{messageID}/reaction. +// Replaces any previous choice. +func (h *ChatHandler) React(w http.ResponseWriter, r *http.Request) { + var req reactReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + // The message id is authoritative: React re-derives the conversation from + // it and authorizes there, so the id in the path can't be used to react + // into a thread the caller isn't in. + if err := h.svc.React(r.Context(), middleware.UserID(r.Context()), + chi.URLParam(r, "messageID"), req.Emoji); err != nil { + writeChatError(w, err) + return + } + response.NoContent(w) +} + +// Unreact — DELETE /chat/conversations/{id}/messages/{messageID}/reaction. +// Idempotent. +func (h *ChatHandler) Unreact(w http.ResponseWriter, r *http.Request) { + if err := h.svc.Unreact(r.Context(), middleware.UserID(r.Context()), + chi.URLParam(r, "messageID")); err != nil { + writeChatError(w, err) + return + } + response.NoContent(w) +} + // Receipts — GET /chat/conversations/{id}/receipts func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) { rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")) diff --git a/internal/model/chat.go b/internal/model/chat.go index 935d2d6..81fef91 100644 --- a/internal/model/chat.go +++ b/internal/model/chat.go @@ -252,6 +252,28 @@ type Message struct { // Attachments is always present (possibly empty) so clients need no nil // check before iterating. Attachments []Attachment `json:"attachments"` + // Reactions is always present (possibly empty), same contract as + // Attachments — clients render the chip row without a nil check. + Reactions []Reaction `json:"reactions"` +} + +// ReactionDetail is one person's reaction, for the "who reacted" list. +type ReactionDetail struct { + User ChatPeer `json:"user"` + Emoji string `json:"emoji"` + CreatedAt time.Time `json:"created_at"` +} + +// Reaction is one emoji grouped across the people who chose it. +// +// Grouped rather than listed per user: a bubble shows "❤️ 3", and sending the +// full membership of every reaction would grow with the room. +type Reaction struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + // Mine marks the viewer's own choice, so the chip can be highlighted and + // tapping it toggles off. + Mine bool `json:"mine"` } // Receipts are the conversation-wide delivery cursors used to draw ticks on diff --git a/internal/repository/chat_repo.go b/internal/repository/chat_repo.go index 19fba55..7f04450 100644 --- a/internal/repository/chat_repo.go +++ b/internal/repository/chat_repo.go @@ -336,27 +336,58 @@ func (r *ChatRepo) Inbox(ctx context.Context, userID string, kinds []string, lim // Messages returns one page of history, newest first. beforeSeq == 0 starts at // the newest message; otherwise it is an exclusive upper bound, which makes // paging stable even as new messages arrive at the top. +// messageSelectFrom is the canonical read shape for a message, including the +// quoted-reply preview. Shared by the paged thread read and the single-message +// read so the two can never drift into returning different shapes. +const messageSelectFrom = ` + SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities, + m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at, m.deleted_at, m.created_at, + rep.id, rep.seq, rep.sender_id, rep.body, rep.deleted_at, COALESCE(rep.sender_name, '') + FROM messages m + LEFT JOIN LATERAL ( + SELECT r.id, r.seq, r.sender_id, r.body, r.deleted_at, ru.name AS sender_name + FROM messages r + LEFT JOIN users ru ON ru.id = r.sender_id + -- The conversation guard is repeated here on purpose: even a row + -- written before the write-side check existed cannot leak another + -- thread's text through a quote. + WHERE r.id = m.reply_to_id AND r.conversation_id = m.conversation_id + ) rep ON TRUE +` + +// Message reads one message of a conversation, in the same shape as the paged +// read. Hidden (deleted-for-me) messages are excluded, so a refresh can't +// resurrect something the caller removed from their own view. +func (r *ChatRepo) Message(ctx context.Context, conversationID, messageID, userID string) (*model.Message, error) { + msgs, err := r.scanMessages(ctx, messageSelectFrom+` + WHERE m.conversation_id = $1 + AND m.id = $2 + AND NOT EXISTS (SELECT 1 FROM message_hides h + WHERE h.message_id = m.id AND h.user_id = $3)`, + conversationID, messageID, userID) + if err != nil { + return nil, err + } + if len(msgs) == 0 { + return nil, ErrMessageNotHere + } + return &msgs[0], nil +} + func (r *ChatRepo) Messages(ctx context.Context, conversationID, userID string, beforeSeq int64, limit int) ([]model.Message, error) { - rows, err := r.pg.Pool.Query(ctx, ` - SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities, - m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at, m.deleted_at, m.created_at, - rep.id, rep.seq, rep.sender_id, rep.body, rep.deleted_at, COALESCE(rep.sender_name, '') - FROM messages m - LEFT JOIN LATERAL ( - SELECT r.id, r.seq, r.sender_id, r.body, r.deleted_at, ru.name AS sender_name - FROM messages r - LEFT JOIN users ru ON ru.id = r.sender_id - -- The conversation guard is repeated here on purpose: even a row - -- written before the write-side check existed cannot leak another - -- thread's text through a quote. - WHERE r.id = m.reply_to_id AND r.conversation_id = m.conversation_id - ) rep ON TRUE + return r.scanMessages(ctx, messageSelectFrom+` WHERE m.conversation_id = $1 AND ($2 = 0 OR m.seq < $2) AND NOT EXISTS (SELECT 1 FROM message_hides h WHERE h.message_id = m.id AND h.user_id = $3) ORDER BY m.seq DESC LIMIT $4`, conversationID, beforeSeq, userID, limit) +} + +// scanMessages runs a query built on messageSelectFrom and decodes its rows. +// Shared so the paged read and the single-message read decode identically. +func (r *ChatRepo) scanMessages(ctx context.Context, query string, args ...any) ([]model.Message, error) { + rows, err := r.pg.Pool.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query messages: %w", err) } @@ -1157,6 +1188,108 @@ func (r *ChatRepo) ReportMessage(ctx context.Context, reporterID, messageID, con return nil } +// SetReaction records one person's emoji on a message, replacing whatever +// they chose before. Returns the message's conversation so the caller can +// authorize and publish without a second query. +func (r *ChatRepo) SetReaction(ctx context.Context, messageID, userID, emoji string) (conversationID string, err error) { + err = r.pg.Pool.QueryRow(ctx, ` + INSERT INTO message_reactions (message_id, user_id, conversation_id, emoji) + SELECT $1, $2, m.conversation_id, $3 + FROM messages m + WHERE m.id = $1 AND m.deleted_at IS NULL + ON CONFLICT (message_id, user_id) + DO UPDATE SET emoji = EXCLUDED.emoji, updated_at = now() + RETURNING conversation_id`, messageID, userID, emoji).Scan(&conversationID) + if errors.Is(err, pgx.ErrNoRows) { + // No row inserted means no such message, or it was deleted — you + // cannot react to something that isn't there. + return "", ErrMessageNotHere + } + if err != nil { + return "", fmt.Errorf("set reaction: %w", err) + } + return conversationID, nil +} + +// ClearReaction removes this user's reaction. Returns the conversation so the +// caller can publish the change; ErrMessageNotHere when there was none. +func (r *ChatRepo) ClearReaction(ctx context.Context, messageID, userID string) (conversationID string, err error) { + err = r.pg.Pool.QueryRow(ctx, ` + DELETE FROM message_reactions + WHERE message_id = $1 AND user_id = $2 + RETURNING conversation_id`, messageID, userID).Scan(&conversationID) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrMessageNotHere + } + if err != nil { + return "", fmt.Errorf("clear reaction: %w", err) + } + return conversationID, nil +} + +// ReactionDetails lists everyone who reacted to one message, newest first. +// +// Separate from ReactionsFor: the grouped counts are what a thread renders, +// and sending the full membership of every reaction on every page would grow +// with the room. This is only read when someone opens the list. +func (r *ChatRepo) ReactionDetails(ctx context.Context, messageID string) ([]model.ReactionDetail, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT u.id, u.handle, u.name, u.avatar_url, mr.emoji, mr.updated_at + FROM message_reactions mr + JOIN users u ON u.id = mr.user_id + WHERE mr.message_id = $1 + ORDER BY mr.updated_at DESC`, messageID) + if err != nil { + return nil, fmt.Errorf("reaction details: %w", err) + } + defer rows.Close() + + out := []model.ReactionDetail{} + for rows.Next() { + var d model.ReactionDetail + if err := rows.Scan(&d.User.ID, &d.User.Handle, &d.User.Name, + &d.User.AvatarURL, &d.Emoji, &d.CreatedAt); err != nil { + return nil, fmt.Errorf("scan reaction detail: %w", err) + } + out = append(out, d) + } + return out, rows.Err() +} + +// ReactionsFor groups reactions for a page of messages in one query. +// +// Grouped in SQL rather than in Go: the counts are what the UI draws, and +// sending one row per person would grow with the room. +func (r *ChatRepo) ReactionsFor(ctx context.Context, messageIDs []string, viewerID string) (map[string][]model.Reaction, error) { + out := map[string][]model.Reaction{} + if len(messageIDs) == 0 { + return out, nil + } + rows, err := r.pg.Pool.Query(ctx, ` + SELECT message_id, emoji, count(*), + bool_or(user_id = $2) AS mine + FROM message_reactions + WHERE message_id = ANY($1) + GROUP BY message_id, emoji + -- Most-used first, then stable by emoji so the chip order doesn't + -- shuffle between reloads. + ORDER BY message_id, count(*) DESC, emoji`, messageIDs, viewerID) + if err != nil { + return nil, fmt.Errorf("reactions for: %w", err) + } + defer rows.Close() + + for rows.Next() { + var messageID string + var re model.Reaction + if err := rows.Scan(&messageID, &re.Emoji, &re.Count, &re.Mine); err != nil { + return nil, fmt.Errorf("scan reaction: %w", err) + } + out[messageID] = append(out[messageID], re) + } + return out, rows.Err() +} + // PushCandidate is a participant plus the state that decides whether a new // message should reach them out-of-band. type PushCandidate struct { diff --git a/internal/server/server.go b/internal/server/server.go index d5896c4..967bd06 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -951,6 +951,7 @@ func (s *Server) setupRouter() { r.Get("/messages", s.chatH.Messages) r.Post("/messages", s.chatH.Send) r.Post("/read", s.chatH.MarkRead) + r.Post("/typing", s.chatH.Typing) r.Post("/delivered", s.chatH.MarkDelivered) r.Get("/receipts", s.chatH.Receipts) r.Post("/mute", s.chatH.SetMute) @@ -961,6 +962,10 @@ func (s *Server) setupRouter() { r.Get("/starred", s.chatH.Starred) r.Post("/messages/{messageID}/star", s.chatH.Star) r.Delete("/messages/{messageID}/star", s.chatH.Unstar) + r.Get("/messages/{messageID}", s.chatH.Message) + r.Get("/messages/{messageID}/reactions", s.chatH.MessageReactions) + r.Put("/messages/{messageID}/reaction", s.chatH.React) + r.Delete("/messages/{messageID}/reaction", s.chatH.Unreact) r.Delete("/messages/{messageID}", s.chatH.DeleteMessage) r.Post("/messages/{messageID}/report", s.chatH.ReportMessage) r.Post("/forward", s.chatH.Forward) diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 16c68aa..6c02308 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -43,6 +43,7 @@ var ( ErrChatNotAccepting = errors.New("this person isn't accepting messages from you") ErrChatBadWhoCanMessage = errors.New("unknown messaging restriction") ErrChatBadQuote = errors.New("that quote is not a valid passage reference") + ErrChatBadEmoji = errors.New("that is not a valid reaction") ErrChatBadEntity = errors.New("that post cannot be shared") ErrChatBadAccountAge = errors.New("account age must be between 1 and 120 months") ErrChatGenderUnset = errors.New("add your gender in profile settings to use this option") @@ -350,6 +351,12 @@ func (s *ChatService) hydrateAttachments(ctx context.Context, viewerID string, m if err != nil { return nil, err } + // One grouped query for the whole page. Counts depend on the viewer + // (`mine`), which is why this is hydrated per request rather than cached. + reactions, err := s.chat.ReactionsFor(ctx, ids, viewerID) + if err != nil { + return nil, err + } for i := range msgs { if a := byMessage[msgs[i].ID]; a != nil { msgs[i].Attachments = a @@ -357,6 +364,11 @@ func (s *ChatService) hydrateAttachments(ctx context.Context, viewerID string, m // Always a slice, never nil, so clients need no guard. msgs[i].Attachments = []model.Attachment{} } + if r := reactions[msgs[i].ID]; r != nil { + msgs[i].Reactions = r + } else { + msgs[i].Reactions = []model.Reaction{} + } } pages := make([][]model.Attachment, 0, len(msgs)) for i := range msgs { @@ -478,7 +490,7 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r // the first time, and re-publishing would duplicate the bubble for // everyone else. if !created { - return s.withAttachments(ctx, userID, msg) + return s.hydrateSent(ctx, userID, conversationID, msg), nil } if err := s.attachEntity(ctx, conversationID, userID, msg, req.Entity); err != nil { @@ -488,14 +500,33 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r s.attachMedia(ctx, conversationID, userID, msg, req.Attachments) s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL) - full, err := s.withAttachments(ctx, userID, msg) - if err != nil { - full = msg - } + full := s.hydrateSent(ctx, userID, conversationID, msg) s.publish(ctx, conversationID, userID, full) return full, nil } +// hydrateSent re-reads a just-written message through the full read path. +// +// Append RETURNINGs the row it inserted, which carries reply_to_id but not the +// quoted preview — that is built by a LATERAL join only the read query has. So +// a reply sent over the wire, and the realtime event announcing it, would both +// arrive with nothing to quote and stay that way until the thread was +// reloaded. Reading it back costs one query and makes the sent message +// identical to the one everyone else receives. +func (s *ChatService) hydrateSent(ctx context.Context, viewerID, conversationID string, msg *model.Message) *model.Message { + full, err := s.chat.Message(ctx, conversationID, msg.ID, viewerID) + if err != nil { + // Fall back to the unhydrated row: a missing quote is better than a + // message that appears not to have sent at all. + full = msg + } + hydrated, err := s.withAttachments(ctx, viewerID, full) + if err != nil { + return full + } + return hydrated +} + // withAttachments hydrates a message's attachments for the response. func (s *ChatService) withAttachments(ctx context.Context, viewerID string, msg *model.Message) (*model.Message, error) { byMessage, err := s.chat.AttachmentsFor(ctx, []string{msg.ID}) @@ -506,6 +537,12 @@ func (s *ChatService) withAttachments(ctx context.Context, viewerID string, msg if msg.Attachments == nil { msg.Attachments = []model.Attachment{} } + if reactions, rErr := s.chat.ReactionsFor(ctx, []string{msg.ID}, viewerID); rErr == nil { + msg.Reactions = reactions[msg.ID] + } + if msg.Reactions == nil { + msg.Reactions = []model.Reaction{} + } s.resolveEntities(ctx, viewerID, [][]model.Attachment{msg.Attachments}) return msg, nil } @@ -1033,10 +1070,7 @@ func (s *ChatService) ForwardMessage(ctx context.Context, userID, messageID, toC return nil, err } if created { - full, hErr := s.withAttachments(ctx, userID, msg) - if hErr == nil { - msg = full - } + msg = s.hydrateSent(ctx, userID, toConversationID, msg) s.publish(ctx, toConversationID, userID, msg) } return msg, nil @@ -1552,6 +1586,157 @@ func (s *ChatService) IsMuted(ctx context.Context, userID, conversationID string return p.MutedUntil != nil && p.MutedUntil.After(time.Now()), nil } +// HeartEmoji is what a double-tap sends, and the one reaction with its own +// notification copy. +const HeartEmoji = "❤️" + +// maxEmojiRunes bounds a reaction. Generous enough for a flag or a skin-tone +// sequence, small enough that the column can't be used as free text storage. +const maxEmojiRunes = 8 + +// MessageReactions lists who reacted to a message and with what. +// +// Authorized against the message's OWN conversation, so a message id alone +// never reveals who is in a thread the caller isn't part of. +func (s *ChatService) MessageReactions(ctx context.Context, userID, messageID string) ([]model.ReactionDetail, error) { + conversationID, _, err := s.chat.MessageConversation(ctx, messageID) + if err != nil { + return nil, err + } + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return nil, err + } + return s.chat.ReactionDetails(ctx, messageID) +} + +// Message returns one hydrated message for this viewer. +// +// Exists because reaction counts carry a per-viewer `mine` flag, so a client +// told "message X changed" cannot compute the new chips itself — it has to ask. +func (s *ChatService) Message(ctx context.Context, userID, conversationID, messageID string) (*model.Message, error) { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return nil, err + } + msg, err := s.chat.Message(ctx, conversationID, messageID, userID) + if err != nil { + return nil, err + } + return s.withAttachments(ctx, userID, msg) +} + +// Typing broadcasts that someone is composing. +// +// Never persisted and never pushed: a typing indicator is only meaningful to +// someone already looking at the thread, and it is stale within seconds. +// Publishing failures are ignored for the same reason. +func (s *ChatService) Typing(ctx context.Context, userID, conversationID string) error { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return err + } + if s.rt == nil { + return nil + } + if err := s.rt.Trigger(ctx, ConversationChannel(conversationID), "typing", map[string]any{ + "user_id": userID, + }); err != nil { + s.logger.Debug("chat: typing publish failed", zap.Error(err)) + } + return nil +} + +// React sets the caller's emoji on a message, replacing any previous one. +// +// Authorization is by membership of the message's OWN conversation, read back +// from the insert — a message id alone must not let someone react into a +// thread they were never in. +func (s *ChatService) React(ctx context.Context, userID, messageID, emoji string) error { + emoji = strings.TrimSpace(emoji) + if emoji == "" || len([]rune(emoji)) > maxEmojiRunes { + return ErrChatBadEmoji + } + conversationID, author, err := s.chat.MessageConversation(ctx, messageID) + if err != nil { + return err + } + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return err + } + if _, err := s.chat.SetReaction(ctx, messageID, userID, emoji); err != nil { + return err + } + + s.publishReaction(ctx, conversationID, messageID) + s.notifyReaction(ctx, conversationID, author, userID, emoji) + return nil +} + +// Unreact removes the caller's reaction. +func (s *ChatService) Unreact(ctx context.Context, userID, messageID string) error { + conversationID, _, err := s.chat.MessageConversation(ctx, messageID) + if err != nil { + return err + } + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return err + } + if _, err := s.chat.ClearReaction(ctx, messageID, userID); err != nil { + // Nothing to remove is the desired end state, not a failure. + if errors.Is(err, repository.ErrMessageNotHere) { + return nil + } + return err + } + s.publishReaction(ctx, conversationID, messageID) + return nil +} + +// publishReaction tells the open thread to refresh one message's chips. The +// event carries only the id: counts depend on the viewer, so each client +// re-reads its own. +func (s *ChatService) publishReaction(ctx context.Context, conversationID, messageID string) { + if s.rt == nil { + return + } + if err := s.rt.Trigger(ctx, ConversationChannel(conversationID), "reaction", map[string]any{ + "message_id": messageID, + }); err != nil { + s.logger.Debug("chat: reaction publish failed", zap.Error(err)) + } +} + +// notifyReaction pushes to the message's author. +// +// Never for reacting to your own message, and it reuses the chat push rules — +// mute, the Messages preference, presence and the debounce — so a reaction +// cannot become a way around them. +func (s *ChatService) notifyReaction(ctx context.Context, conversationID string, author *string, actorID, emoji string) { + if s.push == nil || !s.push.Enabled() { + return + } + // No author on system and assistant messages, and reacting to your own + // message must never notify you. + if author == nil || *author == actorID { + return + } + conv, err := s.chat.Conversation(ctx, conversationID) + if err != nil { + return + } + + actor := "Someone" + if s.users != nil { + if u, uErr := s.users.FindByID(ctx, actorID); uErr == nil && u != nil && u.Name != "" { + actor = u.Name + } + } + body := actor + " reacted " + emoji + " to your message" + if emoji == HeartEmoji { + body = actor + " liked your message" + } + + s.pushTo(ctx, conv, *author, actor, body) +} + // Receipts reports how far EVERY other participant has got, which is what // draws the caller's tick states. // @@ -1697,29 +1882,42 @@ func (s *ChatService) notifyNewMessage(ctx context.Context, conv *model.Conversa if c.MutedUntil != nil && c.MutedUntil.After(now) { continue } - if s.prefs != nil { - if off, err := s.prefs.DisabledSet(ctx, c.UserID); err == nil && - prefOff(off, NotifChatMessage, repository.ChannelPush) { - continue - } - } - if s.online(ctx, c.UserID) { - continue - } - if !s.claimPushSlot(ctx, c.UserID, conv.ID) { - continue - } - s.push.SendToUser(ctx, c.UserID, PushPayload{ - Title: title, - Body: body, - URL: "/chats/" + conv.ID, - // Same tag per thread: a later message replaces the earlier - // notification rather than stacking up. - Tag: "chat:" + conv.ID, - }) + s.pushTo(ctx, conv, c.UserID, title, body) } } +// pushTo delivers one chat notification, applying the suppression rules every +// chat push obeys: the Messages preference, a live connection, and the +// per-thread debounce. Reactions go through here too, so they cannot become a +// way around any of them. +// +// Mute is checked by callers, since they already hold the participant row. +func (s *ChatService) pushTo(ctx context.Context, conv *model.Conversation, userID, title, body string) { + if s.push == nil || !s.push.Enabled() { + return + } + if s.prefs != nil { + if off, err := s.prefs.DisabledSet(ctx, userID); err == nil && + prefOff(off, NotifChatMessage, repository.ChannelPush) { + return + } + } + if s.online(ctx, userID) { + return + } + if !s.claimPushSlot(ctx, userID, conv.ID) { + return + } + s.push.SendToUser(ctx, userID, PushPayload{ + Title: title, + Body: body, + URL: "/chats/" + conv.ID, + // Same tag per thread: a later notification replaces the earlier one + // rather than stacking up. + Tag: "chat:" + conv.ID, + }) +} + // pushCopy builds the lock-screen text. func (s *ChatService) pushCopy(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) (title, body string) { sender := "Someone" diff --git a/internal/service/chat_service_test.go b/internal/service/chat_service_test.go index 3bed8f6..a803d53 100644 --- a/internal/service/chat_service_test.go +++ b/internal/service/chat_service_test.go @@ -2204,3 +2204,258 @@ func TestReceipts_TickProgression(t *testing.T) { t.Errorf("a stale ack rewound the cursors: %+v then %+v", rc, back) } } + +func reactionsOf(t *testing.T, msgs []model.Message, id string) []model.Reaction { + t.Helper() + for i := range msgs { + if msgs[i].ID == id { + return msgs[i].Reactions + } + } + return nil +} + +// Reacting again replaces rather than stacks, and the chip knows whose it is. +func TestReactions_ReplaceAndToggle(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "hare kṛṣṇa"}) + if err != nil { + t.Fatal(err) + } + + if err := svc.React(ctx, b, msg.ID, HeartEmoji); err != nil { + t.Fatal(err) + } + msgs, _ := svc.Messages(ctx, b, conv.ID, 0, 10) + got := reactionsOf(t, msgs, msg.ID) + if len(got) != 1 || got[0].Emoji != HeartEmoji || got[0].Count != 1 || !got[0].Mine { + t.Fatalf("after reacting: %+v", got) + } + + // The author sees the same reaction, but it is not theirs. + msgs, _ = svc.Messages(ctx, a, conv.ID, 0, 10) + got = reactionsOf(t, msgs, msg.ID) + if len(got) != 1 || got[0].Mine { + t.Errorf("author's view: %+v — Mine should be false", got) + } + + // Changing your mind replaces; it must not accumulate. + if err := svc.React(ctx, b, msg.ID, "😂"); err != nil { + t.Fatal(err) + } + msgs, _ = svc.Messages(ctx, b, conv.ID, 0, 10) + got = reactionsOf(t, msgs, msg.ID) + if len(got) != 1 || got[0].Emoji != "😂" { + t.Errorf("after changing: %+v — want exactly one, the new emoji", got) + } + + // Removing leaves nothing, and removing twice is harmless. + if err := svc.Unreact(ctx, b, msg.ID); err != nil { + t.Fatal(err) + } + if err := svc.Unreact(ctx, b, msg.ID); err != nil { + t.Errorf("second unreact should be a no-op, got %v", err) + } + msgs, _ = svc.Messages(ctx, b, conv.ID, 0, 10) + if got = reactionsOf(t, msgs, msg.ID); len(got) != 0 { + t.Errorf("after removing: %+v — want none", got) + } +} + +// Counts group across people, and each viewer sees only their own as "mine". +func TestReactions_GroupAcrossPeople(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + owner, a, b := chatUser(t, pg, "O"), chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.CreateGroup(ctx, owner, "Kīrtana", + []string{handleOf(t, pg, a), handleOf(t, pg, b)}) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + msg, err := svc.Send(ctx, owner, conv.ID, SendRequest{Body: "a verse"}) + if err != nil { + t.Fatal(err) + } + + for _, u := range []string{a, b} { + if err := svc.React(ctx, u, msg.ID, HeartEmoji); err != nil { + t.Fatal(err) + } + } + msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10) + got := reactionsOf(t, msgs, msg.ID) + if len(got) != 1 || got[0].Count != 2 || !got[0].Mine { + t.Errorf("A's view: %+v — want one chip, count 2, mine", got) + } + msgs, _ = svc.Messages(ctx, owner, conv.ID, 0, 10) + got = reactionsOf(t, msgs, msg.ID) + if len(got) != 1 || got[0].Count != 2 || got[0].Mine { + t.Errorf("owner's view: %+v — count 2 but not mine", got) + } +} + +// A message id alone must not let an outsider react into a thread they were +// never part of. +func TestReactions_OutsiderRefused(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "private"}) + if err != nil { + t.Fatal(err) + } + + if err := svc.React(ctx, outsider, msg.ID, HeartEmoji); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("outsider react: err = %v, want ErrNotParticipant", err) + } + if err := svc.Typing(ctx, outsider, conv.ID); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("outsider typing: err = %v, want ErrNotParticipant", err) + } +} + +// An emoji column is not free text storage. +func TestReactions_RejectsJunk(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "x"}) + if err != nil { + t.Fatal(err) + } + + for _, bad := range []string{"", " ", strings.Repeat("a", 64)} { + if err := svc.React(ctx, b, msg.ID, bad); !errors.Is(err, ErrChatBadEmoji) { + t.Errorf("React(%q): err = %v, want ErrChatBadEmoji", bad, err) + } + } +} + +// The "who reacted" list names each person and their emoji, and is readable +// only by people in the thread. +func TestReactionDetails_ListsEveryoneAndGuardsOutsiders(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + owner, a, b := chatUser(t, pg, "O"), chatUser(t, pg, "A"), chatUser(t, pg, "B") + outsider := chatUser(t, pg, "X") + conv, err := svc.CreateGroup(ctx, owner, "Kīrtana", + []string{handleOf(t, pg, a), handleOf(t, pg, b)}) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + msg, err := svc.Send(ctx, owner, conv.ID, SendRequest{Body: "a verse"}) + if err != nil { + t.Fatal(err) + } + + if err := svc.React(ctx, a, msg.ID, HeartEmoji); err != nil { + t.Fatal(err) + } + if err := svc.React(ctx, b, msg.ID, "😂"); err != nil { + t.Fatal(err) + } + + list, err := svc.MessageReactions(ctx, owner, msg.ID) + if err != nil { + t.Fatal(err) + } + if len(list) != 2 { + t.Fatalf("list = %d entries, want 2", len(list)) + } + byUser := map[string]string{} + for _, d := range list { + byUser[d.User.ID] = d.Emoji + if d.User.Handle == "" || d.User.Name == "" { + t.Errorf("entry %+v is missing the details needed to render a row", d) + } + } + if byUser[a] != HeartEmoji || byUser[b] != "😂" { + t.Errorf("emoji did not follow their authors: %+v", byUser) + } + + // Changing your mind replaces your entry rather than adding one. + if err := svc.React(ctx, a, msg.ID, "🙏"); err != nil { + t.Fatal(err) + } + list, _ = svc.MessageReactions(ctx, owner, msg.ID) + if len(list) != 2 { + t.Errorf("after changing: %d entries, want 2", len(list)) + } + + // Knowing a message id must not reveal who is in a thread you're not in. + if _, err := svc.MessageReactions(ctx, outsider, msg.ID); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("outsider: err = %v, want ErrNotParticipant", err) + } +} + +// A reply must arrive with its quoted preview already attached. Append only +// RETURNINGs reply_to_id, so without a read-back the sender — and everyone +// receiving the realtime event — would see a reply quoting nothing until the +// thread was reloaded. +func TestSend_ReplyCarriesItsQuoteImmediately(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + original, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "the original"}) + if err != nil { + t.Fatal(err) + } + reply, err := svc.Send(ctx, b, conv.ID, SendRequest{ + Body: "answering", ReplyToID: &original.ID, + }) + if err != nil { + t.Fatal(err) + } + + if reply.ReplyTo == nil { + t.Fatal("the send response has no quoted preview — the client has nothing to render") + } + if reply.ReplyTo.ID != original.ID || reply.ReplyTo.Body != "the original" { + t.Errorf("quote = %+v, want the original message", reply.ReplyTo) + } + + // And a retried send must return the same shape, not a bare row. + clientID := uuid.NewString() + first, err := svc.Send(ctx, b, conv.ID, SendRequest{ + Body: "once", ReplyToID: &original.ID, ClientMsgID: &clientID, + }) + if err != nil { + t.Fatal(err) + } + retry, err := svc.Send(ctx, b, conv.ID, SendRequest{ + Body: "once", ReplyToID: &original.ID, ClientMsgID: &clientID, + }) + if err != nil { + t.Fatal(err) + } + if retry.ID != first.ID { + t.Fatalf("retry created a second message: %s vs %s", retry.ID, first.ID) + } + if retry.ReplyTo == nil { + t.Error("a retried reply lost its quote") + } +} diff --git a/migrations/0053_message_reactions.down.sql b/migrations/0053_message_reactions.down.sql new file mode 100644 index 0000000..70659ce --- /dev/null +++ b/migrations/0053_message_reactions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS message_reactions; diff --git a/migrations/0053_message_reactions.up.sql b/migrations/0053_message_reactions.up.sql new file mode 100644 index 0000000..57e81bc --- /dev/null +++ b/migrations/0053_message_reactions.up.sql @@ -0,0 +1,27 @@ +-- Emoji reactions on chat messages. +-- +-- One reaction per person per message: reacting again REPLACES rather than +-- accumulates, which is what the primary key enforces. That matches how the +-- UI reads — a row of distinct emoji with counts — and stops someone stacking +-- twenty reactions on one bubble. +-- +-- The emoji is stored as text rather than a code point or shortname: what the +-- sender picked is what everyone should see, and a mapping table would need +-- updating every time the emoji set grows. +CREATE TABLE IF NOT EXISTS message_reactions ( + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- Denormalized so deleting a conversation takes its reactions with it + -- without relying on the messages cascade alone. + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + emoji VARCHAR(32) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (message_id, user_id), + -- A stray empty string would render as an invisible reaction chip. + CONSTRAINT message_reactions_emoji_not_blank CHECK (btrim(emoji) <> '') +); + +-- Hydrating a page of messages: one index range scan per batch. +CREATE INDEX IF NOT EXISTS message_reactions_message_idx + ON message_reactions (message_id);