feat: chat reactions
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user