diff --git a/AGENTS.md b/AGENTS.md index 3f481d5cf..647297373 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ - Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness - Break down complex tasks into small, testable units - Iterate after confirmation -- Avoid writing specs unless explicitly asked +- New features must include specs covering the main flows (happy path + critical edge cases). Bugfixes should add a regression spec when the fix is non-trivial. Skip specs only for purely cosmetic changes (CSS tweaks, copy adjustments, log message edits) or when the user explicitly asks to skip. - Remove dead/unreachable/unused code - Donโ€™t write multiple versions or backups for the same logic โ€” pick the best approach and implement it - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs diff --git a/app/controllers/api/v1/accounts/conversations/messages/reactions_controller.rb b/app/controllers/api/v1/accounts/conversations/messages/reactions_controller.rb new file mode 100644 index 000000000..188b039d1 --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/messages/reactions_controller.rb @@ -0,0 +1,213 @@ +class Api::V1::Accounts::Conversations::Messages::ReactionsController < Api::V1::Accounts::Conversations::BaseController + before_action :ensure_channel_supports_reactions + before_action :fetch_target_message + before_action :ensure_target_is_reactable + + MAX_EMOJI_BYTES = 32 # an emoji with skin tone + ZWJ sequences fits in <=32 bytes + + # The `messages.content_attributes` column is `json` but the model writes it + # as a double-encoded JSON string (legacy `store coder: JSON`), so the `->>` + # operator can't traverse it directly. `#>>'{}'` unwraps the outer encoding + # back to a real JSON object that we can then cast to `jsonb` and query. + CONTENT_ATTRIBUTES_JSONB = "(content_attributes#>>'{}')::jsonb".freeze + + def create + # An omitted `emoji` key, or an explicit JSON `null`, would otherwise + # coerce to '' and silently wipe an active reaction. Require a String + # (explicit '' is still the intended remove signal). + return render(json: { error: 'emoji is required' }, status: :unprocessable_entity) unless params[:emoji].is_a?(String) + + emoji = reaction_params[:emoji] + return render(json: { error: 'Invalid emoji' }, status: :unprocessable_entity) unless emoji_payload_valid?(emoji) + + result = apply_toggle!(emoji) + + return render(json: { error: 'Emoji cannot be empty without an active reaction' }, status: :unprocessable_entity) if result == :invalid + + # Dispatched after the lock commits so the worker reads the post-update row + # (source_id cleared); inside the transaction it would still see the stale + # source_id and SendOnChannelService would skip the send. CREATE goes through + # Message#after_create_commit -> send_reply, which already runs post-commit, + # so we only re-dispatch for UPDATEs. + ::SendReplyJob.perform_later(result) if result.is_a?(Integer) + + # Cable broadcast so the chat list refreshes `last_non_activity_message`. + # Message#after_update_commit only sends MESSAGE_UPDATED (touches + # chat.messages on the frontend); without this, the conversation card + # snapshot stays pointed at the pre-toggle reaction state. Touch + # `updated_at` first so the frontend's out-of-order guard in + # UPDATE_CONVERSATION can drop stale cables when the user toggles fast. + @conversation.update_columns(updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + @conversation.dispatch_conversation_updated_event + + head :ok + end + + private + + # Serialize concurrent operations from the same user against the same target + # message. Without the lock, two near-simultaneous clicks would both observe + # the same state and either create duplicates or step on each other's update. + def apply_toggle!(emoji) + outcome = nil + @target_message.with_lock do + existing = current_user_reaction + if emoji.blank? && !reaction_active?(existing) + outcome = :invalid + next + end + + outcome = mutate_reaction!(emoji, existing) + end + outcome + end + + def mutate_reaction!(emoji, existing) + if existing.present? + update_existing_reaction!(existing, emoji) + existing.id + elsif emoji.present? + build_reaction_message!(emoji) + :created + end + end + + # WhatsApp allows one reaction per (message, user). We mirror that in storage: + # a single Message row holds the user's current reaction. Replacing the emoji + # updates the row in-place, removing it sets content='' + deleted=true, and a + # subsequent re-add resurrects the same row. This keeps the conversation + # history clean instead of accumulating one Message per toggle. + def update_existing_reaction!(existing, emoji) + is_removing = reaction_active?(existing) && (emoji.blank? || existing.content == emoji) + new_attrs = existing.content_attributes.dup + + if is_removing + new_content = '' + new_attrs['deleted'] = true + else + new_content = emoji + new_attrs.delete('deleted') + end + + # Reset source_id so SendOnChannelService doesn't treat this as a message + # echoed back from the provider and skip the resend. The provider assigns a + # fresh source_id on success via send_session_message. + existing.update!(content: new_content, content_attributes: new_attrs, source_id: nil) + end + + def reaction_active?(message) + return false if message.nil? + + message.content.present? && !message.content_attributes['deleted'] + end + + # An emoji payload is either empty (removal) or a single grapheme cluster + # that actually renders as an emoji. `\p{Emoji}` alone is too broad (it + # matches keycap bases like `1`, `#`, `*`), while `\p{Extended_Pictographic}` + # alone is too narrow โ€” it only hits single codepoints, so flag sequences + # (๐Ÿ‡ง๐Ÿ‡ท = 2 regional indicators) and keycaps (1๏ธโƒฃ = digit + VS16 + U+20E3) + # would be rejected. Accept a grapheme cluster that contains at least one + # pictographic codepoint, a regional indicator, or the combining keycap. + EMOJI_PROPERTY_RE = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u{20E3}]/ + + def emoji_payload_valid?(emoji) + return true if emoji.empty? + return false if emoji.bytesize > MAX_EMOJI_BYTES + return false if emoji.each_grapheme_cluster.to_a.length != 1 + + emoji.match?(EMOJI_PROPERTY_RE) + end + + def ensure_channel_supports_reactions + channel = @conversation.inbox.channel + return if channel.respond_to?(:supports_reactions?) && channel.supports_reactions? + + render json: { error: 'Reactions are not supported on this channel' }, status: :unprocessable_entity + end + + def fetch_target_message + @target_message = @conversation.messages.find(params[:message_id]) + end + + def ensure_target_is_reactable + error = target_unreactable_error + return if error.nil? + + render(json: { error: error }, status: :unprocessable_entity) + end + + # Mirrors the client-side guard in + # app/javascript/dashboard/components-next/message/Message.vue#canShowReactionToolbar + # so a crafted POST cannot persist a reaction (and enqueue a provider send) + # against a target the UI would never let the user pick. + def target_unreactable_error # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity + return 'Cannot react to private messages' if @target_message.private? + return 'Cannot react to a reaction' if @target_message.reaction? + return 'Cannot react to deleted messages' if @target_message.content_attributes['deleted'] + return 'Cannot react to activity messages' if @target_message.activity? + return 'Cannot react to template messages' if @target_message.template? + return 'Cannot react to failed messages' if @target_message.failed? + return 'Cannot react to unsupported messages' if @target_message.content_attributes['is_unsupported'] + return 'Target message is not deliverable to WhatsApp' if @target_message.source_id.blank? + + nil + end + + # Returns the most recent reaction Message we should mutate for the current + # user. Two sources qualify: + # - Reactions the agent created via Chatwoot UI (sender = Current.user). + # - Multi-device echoes: the agent reacted from the WhatsApp mobile app on the + # same number as the inbox, so the message comes back outgoing without an + # agent. Without this fallback, a click on such a chip would create a brand + # new Chatwoot-side reaction and the original would never be removed from + # WhatsApp. + def current_user_reaction + # Match by both the internal in_reply_to (set by Chatwoot-originated + # reactions via MessageBuilder) and the in_reply_to_external_id (set by + # WhatsApp incoming/echoed reactions via IncomingMessageBaseService). A + # multi-device echo persists with only the external id, so without this OR + # the next toggle would miss the echoed row and stack a duplicate self + # reaction. + matches = @conversation.messages + .where("#{CONTENT_ATTRIBUTES_JSONB}->>'is_reaction' = 'true'") + .where( + "(#{CONTENT_ATTRIBUTES_JSONB}->>'in_reply_to')::bigint = :message_id OR " \ + "#{CONTENT_ATTRIBUTES_JSONB}->>'in_reply_to_external_id' = :source_id", + message_id: @target_message.id, + source_id: @target_message.source_id + ) + .where( + '(sender_type = ? AND sender_id = ?) OR ' \ + '(message_type = ? AND sender_type IS NULL AND sender_id IS NULL)', + 'User', Current.user.id, Message.message_types[:outgoing] + ) + # Prefer the newest active row so a stale deleted echo can't hijack the + # toggle target and either resurrect a removed reaction or leave the + # active one untouched (creating a duplicate active state for the user). + active = matches.where.not(content: '') + .where("COALESCE(#{CONTENT_ATTRIBUTES_JSONB}->>'deleted', 'false') != 'true'") + .reorder(created_at: :desc) + .first + active || matches.reorder(created_at: :desc).first + end + + def build_reaction_message!(emoji) + Messages::MessageBuilder.new( + Current.user, + @conversation, + ActionController::Parameters.new( + message_type: 'outgoing', + content: emoji, + echo_id: reaction_params[:echo_id], + content_attributes: { + is_reaction: true, + in_reply_to: @target_message.id + } + ) + ).perform + end + + def reaction_params + params.permit(:emoji, :echo_id) + end +end diff --git a/app/finders/message_finder.rb b/app/finders/message_finder.rb index 8854e239a..7ef5c5c9f 100644 --- a/app/finders/message_finder.rb +++ b/app/finders/message_finder.rb @@ -1,4 +1,11 @@ class MessageFinder + PAGE_LIMIT = 20 + + # `messages.content_attributes` is `json` but the model stores it as a + # double-encoded string (legacy `store coder: JSON`), so `->>` can't traverse + # it directly โ€” `#>>'{}'` unwraps the outer encoding into proper jsonb. + NON_REACTION_CLAUSE = "((content_attributes#>>'{}')::jsonb->>'is_reaction') IS DISTINCT FROM 'true'".freeze + def initialize(conversation, params) @conversation = conversation @params = params @@ -37,7 +44,7 @@ class MessageFinder end def messages_before(before_id) - messages.reorder('created_at desc').where('id < ?', before_id).limit(20).reverse + page_window(messages.where('id < ?', before_id)) end def messages_between(after_id, before_id) @@ -45,6 +52,32 @@ class MessageFinder end def messages_latest - messages.reorder('created_at desc').limit(20).reverse + page_window(messages) + end + + # Reactions don't count toward the page limit โ€” otherwise a heavily-reacted + # message can flood the latest page and hide regular messages from the UI on + # initial load. Pick the most recent non-reactions, then add only the + # reactions whose target is inside that window so chips render alongside + # their parents and orphan reactions on older messages don't bloat the page. + def page_window(scope) + # Drop `includes(:sender, ...)` for the id-only probe to avoid Rails trying + # to eager-load the polymorphic sender association (which would error). + # `minimum(:id)` would silently aggregate over the FULL relation (Rails + # drops the limit), pulling in old messages and blowing up the page. Pluck + # the limited window first and take the min in Ruby. + bare = scope.except(:includes) + window_ids = bare.where(NON_REACTION_CLAUSE).reorder('created_at desc').limit(PAGE_LIMIT).pluck(:id) + return scope.none if window_ids.empty? + + json_path = "(content_attributes#>>'{}')::jsonb" + # `Message#ensure_in_reply_to` always populates content_attributes['in_reply_to'] + # when either the internal id or external source_id resolves to a parent in the + # same conversation, so a single jsonb path scopes reactions to the windowed + # parents reliably. + reaction_in_window = "((#{json_path}->>'is_reaction') = 'true' AND " \ + "(#{json_path}->>'in_reply_to')::bigint IN (:ids))" + scope.where("id IN (:ids) OR #{reaction_in_window}", ids: window_ids) + .reorder('created_at asc') end end diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 99adc0302..f1ea0bef3 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -125,6 +125,13 @@ class MessageApi extends ApiClient { } ); } + + toggleReaction(conversationId, messageId, emoji, echoId) { + return axios.post( + `${this.url}/${conversationId}/messages/${messageId}/reactions`, + { emoji, echo_id: echoId } + ); + } } export default new MessageApi(); diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreview.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreview.vue index c8c397785..0f90004b5 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreview.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreview.vue @@ -2,6 +2,8 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; +import { useMapGetter } from 'dashboard/composables/store'; +import { MESSAGE_TYPE } from 'widget/helpers/constants'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; @@ -15,14 +17,76 @@ const props = defineProps({ const { t } = useI18n(); const { getPlainText } = useMessageFormatter(); +const currentUserId = useMapGetter('getCurrentUserID'); + +const isRemovedReaction = msg => + msg?.contentAttributes?.isReaction && + (msg?.contentAttributes?.deleted || !msg?.content); + +// Cable updates mutate `lastNonActivityMessage` in place, so a freshly removed +// reaction can still be referenced here. Walk back through `messages` (which +// the backend filters via `hide_removed_reactions`) for the previous visible +// message instead of rendering " reagiu <>". +const previewMessage = computed(() => { + const { lastNonActivityMessage, messages = [] } = props.conversation; + // Pre-filter once so both fallbacks share the same removed-reaction guard. + const nonRemovedMessages = messages.filter(m => !isRemovedReaction(m)); + // Mirrors conversationHelper.getLastMessage: when nothing else is available + // a non-removed activity row is preferable to a blank "no content" preview. + const lastMessageIncludingActivity = + nonRemovedMessages[nonRemovedMessages.length - 1] || null; + // The same row gets mutated in place when a reaction is toggled or echoed + // from another device, so the snapshot may be stale. Resolve by id against + // the live messages array first to pick up the freshest copy, then merge the + // store fields onto the API snapshot so jbuilder-only fields like + // `in_reply_to_snippet` survive the refresh (replacing would regress the + // CHAT_LIST.REACTED_TO_SNIPPET preview to the generic fallback). + const storeVersion = lastNonActivityMessage?.id + ? messages.find(message => message.id === lastNonActivityMessage.id) + : null; + const refreshedCandidate = storeVersion + ? { ...lastNonActivityMessage, ...storeVersion } + : lastNonActivityMessage; + if (refreshedCandidate && !isRemovedReaction(refreshedCandidate)) { + return refreshedCandidate; + } + return ( + [...nonRemovedMessages].reverse().find(m => m?.messageType !== 2) || + lastMessageIncludingActivity + ); +}); const lastNonActivityMessageContent = computed(() => { - const { lastNonActivityMessage = {}, customAttributes = {} } = - props.conversation; + const msg = previewMessage.value || {}; + const { customAttributes = {} } = props.conversation; const { email: { subject } = {} } = customAttributes; - return getPlainText( - subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT') - ); + + const isActiveReaction = + msg?.contentAttributes?.isReaction && + !msg?.contentAttributes?.deleted && + !!msg?.content; + if (isActiveReaction) { + const senderId = msg.sender?.id; + // Multi-device: agent reacts from the WhatsApp mobile app on the same + // number as the inbox; the echo is outgoing without an agent. Treat it + // as "you" so the preview doesn't show a blank reactor name. + const isOwnInboxReaction = + msg?.messageType === MESSAGE_TYPE.OUTGOING && !senderId; + const senderName = + senderId === currentUserId.value || isOwnInboxReaction + ? t('CONVERSATION.REACTIONS.YOU') + : msg.sender?.name || ''; + const params = { + sender: senderName, + emoji: msg.content, + snippet: msg.inReplyToSnippet, + }; + return params.snippet + ? t('CHAT_LIST.REACTED_TO_SNIPPET', params) + : t('CHAT_LIST.REACTED', params); + } + + return getPlainText(subject || msg?.content || t('CHAT_LIST.NO_CONTENT')); }); const assignee = computed(() => { diff --git a/app/javascript/dashboard/components-next/message/EmojiReactionPicker.vue b/app/javascript/dashboard/components-next/message/EmojiReactionPicker.vue new file mode 100644 index 000000000..8c86e8d7f --- /dev/null +++ b/app/javascript/dashboard/components-next/message/EmojiReactionPicker.vue @@ -0,0 +1,128 @@ + + + diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 0b3b9d963..b45b02cd9 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -43,6 +43,8 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue'; import MessageError from './MessageError.vue'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue'; +import EmojiReactionPicker from './EmojiReactionPicker.vue'; +import ReactionDisplay from './ReactionDisplay.vue'; import { useBranding } from 'shared/composables/useBranding'; /** @@ -139,9 +141,11 @@ const props = defineProps({ senderId: { type: Number, default: null }, senderType: { type: String, default: null }, sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties + reactions: { type: Array, default: () => [] }, + inboxSupportsReactions: { type: Boolean, default: false }, }); -const emit = defineEmits(['retry']); +const emit = defineEmits(['retry', 'toggleReaction']); const contextMenuPosition = ref({}); const showBackgroundHighlight = ref(false); @@ -422,6 +426,65 @@ const contextMenuEnabledOptions = computed(() => { }; }); +const canShowReactionToolbar = computed(() => { + if (!props.inboxSupportsReactions) return false; + if (!isBubble.value) return false; + if (isMessageDeleted.value) return false; + if (props.contentAttributes?.isUnsupported) return false; + if (props.status === MESSAGE_STATUS.FAILED) return false; + if (props.status === MESSAGE_STATUS.PROGRESS) return false; + if (props.private) return false; + if (props.messageType === MESSAGE_TYPES.TEMPLATE) return false; + // Mirror ReactionsController#target_unreactable_error: a message without a + // provider source_id can't be reacted to on WhatsApp, so the API would 422 + // if the user clicked. Hide the picker instead of offering a dead action. + if (!props.sourceId) return false; + return true; +}); + +// Short cooldown after a click so a quick double-tap (or open-pick-reopen-pick +// on the picker) doesn't fire two POSTs against the same emoji. Watching +// reactions is not enough โ€” the optimistic add mutates them synchronously, so +// we'd unblock before the human could react. +const REACTION_COOLDOWN_MS = 500; +const pendingEmojis = ref(new Set()); + +const currentUserReactionEmoji = computed(() => { + const own = props.reactions.find( + r => + (r.senderType === 'user' && r.senderId === props.currentUserId) || + (r.messageType === 1 && r.senderId == null) + ); + return own?.emoji ?? null; +}); + +// Track pending cooldown timers so we can clear them on unmount and avoid +// touching `pendingEmojis` after the component is gone. +const pendingTimeouts = new Set(); + +function handleToggleReaction(emoji) { + if (pendingEmojis.value.has(emoji)) return; + pendingEmojis.value = new Set([...pendingEmojis.value, emoji]); + emit('toggleReaction', { + messageId: props.id, + targetSourceId: props.sourceId, + emoji, + }); + const timeoutId = setTimeout(() => { + pendingTimeouts.delete(timeoutId); + if (!pendingEmojis.value.has(emoji)) return; + const next = new Set(pendingEmojis.value); + next.delete(emoji); + pendingEmojis.value = next; + }, REACTION_COOLDOWN_MS); + pendingTimeouts.add(timeoutId); +} + +onUnmounted(() => { + pendingTimeouts.forEach(clearTimeout); + pendingTimeouts.clear(); +}); + const shouldRenderMessage = computed(() => { const hasAttachments = !!(props.attachments && props.attachments.length > 0); const isEmailContentType = props.contentType === CONTENT_TYPES.INCOMING_EMAIL; @@ -616,7 +679,7 @@ provideMessageContext({
- +
+ +
+ +
+
+
+
+
[], }, }); -const emit = defineEmits(['retry']); +const emit = defineEmits(['retry', 'toggleReaction']); const allMessages = computed(() => { return useCamelCase(props.messages, { @@ -53,6 +57,111 @@ const allMessages = computed(() => { }); }); +const reactionsByMessageId = computed(() => { + // Keep only the latest reaction per (originalMessage, sender) and drop + // entries flagged as deleted or with empty content. + // Normalize sender_type casing: REST jbuilder doesn't expose `sender_type` + // (only nested `sender.type` in lowercase), while ActionCable's push_event_data + // includes Rails class names ('User', 'Contact'). We unify on lowercase so + // dedup keys and "isMine" comparisons stay consistent across both transports. + const senderTypeOf = msg => + (msg.senderType ?? msg.sender?.type ?? '').toLowerCase(); + + // Multi-device echoes (the agent reacts from the WhatsApp mobile app on the + // same number connected to the inbox) arrive as outgoing reactions without an + // agent. Treat them as "ours" so they show up in the chip and the preview + // instead of falling through as ghosts. + const isOwnInboxReaction = msg => { + const sid = msg.senderId ?? msg.sender?.id; + return msg.messageType === 1 && sid == null; + }; + + // Collapse "agent reacted via Chatwoot" and "agent reacted via the connected + // phone" into the same logical actor so the chip never double-counts the + // current user. The controller and MessagesView already treat both shapes + // as the same toggle target. + const isSelfReaction = msg => { + if (isOwnInboxReaction(msg)) return true; + const senderId = msg.senderId ?? msg.sender?.id; + return senderTypeOf(msg) === 'user' && senderId === props.currentUserId; + }; + + // Build a sourceId โ†’ id lookup so reactions that only carry + // `inReplyToExternalId` (WhatsApp echo/phone-originated) can still resolve + // to a visible target when `inReplyTo` wasn't populated at save time. + const messageIdBySourceId = new Map( + allMessages.value.filter(m => !!m.sourceId).map(m => [m.sourceId, m.id]) + ); + + const latestPerKey = new Map(); + allMessages.value.forEach(msg => { + if (!msg.contentAttributes?.isReaction) return; + const originalId = + msg.contentAttributes?.inReplyTo ?? + messageIdBySourceId.get(msg.contentAttributes?.inReplyToExternalId); + if (!originalId) return; + const senderId = msg.senderId ?? msg.sender?.id; + const senderType = senderTypeOf(msg); + const selfReaction = isSelfReaction(msg); + if (!selfReaction && (senderId == null || !senderType)) return; + // Each multi-device toggle creates a fresh Message (the backend can't + // collapse them in place because there is no agent to scope by), so + // dedupe them under a single key per target message. + const key = selfReaction + ? `${originalId}|self|self` + : `${originalId}|${senderType}|${senderId}`; + const prev = latestPerKey.get(key); + // Use <= so a later iteration wins on timestamp ties. Cable payloads carry + // second-resolution createdAt, so two toggles in the same second need a + // deterministic later-wins rule to avoid pinning the chip to a stale row. + if (!prev || (prev.createdAt ?? 0) <= (msg.createdAt ?? 0)) { + latestPerKey.set(key, msg); + } + }); + + const map = new Map(); + latestPerKey.forEach(reaction => { + if (reaction.contentAttributes?.deleted) return; + if (!reaction.content) return; + // Mirror the first pass: an echoed reaction may carry only + // inReplyToExternalId, so resolve via the sourceId lookup before giving up. + const originalId = + reaction.contentAttributes.inReplyTo ?? + messageIdBySourceId.get(reaction.contentAttributes?.inReplyToExternalId); + if (!originalId) return; + if (!map.has(originalId)) map.set(originalId, []); + map.get(originalId).push({ + id: reaction.id, + emoji: reaction.content, + senderId: reaction.senderId ?? reaction.sender?.id, + senderType: senderTypeOf(reaction), + sender: reaction.sender, + messageType: reaction.messageType, + }); + }); + return map; +}); + +const visibleMessages = computed(() => { + return allMessages.value.filter(msg => !msg.contentAttributes?.isReaction); +}); + +// firstUnreadId can point to a reaction (filtered out of visibleMessages), +// in which case the unread separator would never render. Anchor it to the +// first visible message at or after that id so the divider always shows up +// next to a real bubble. +const effectiveFirstUnreadId = computed(() => { + if (!props.firstUnreadId) return null; + const direct = visibleMessages.value.find( + msg => msg.id === props.firstUnreadId + ); + if (direct) return direct.id; + const fallback = visibleMessages.value.find( + msg => msg.id >= props.firstUnreadId + ); + return fallback?.id ?? null; +}); + const currentChat = useMapGetter('getSelectedChat'); const isGroupConversation = computed( @@ -174,25 +283,28 @@ const getInReplyToMessage = parentMessage => {