feat: chat reactions

This commit is contained in:
Krispire Admin
2026-08-30 01:51:32 +05:30
parent 9c458ba19f
commit cee2d3c874
8 changed files with 758 additions and 43 deletions
+227 -29
View File
@@ -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"
+255
View File
@@ -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")
}
}