feat: chat reactions
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS message_reactions;
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user