feat(whatsapp): add emoji reactions UI (#276)

* feat(whatsapp): add emoji reactions UI

Adds end-to-end agent UI for emoji reactions on WhatsApp inboxes
(Cloud API, Baileys, Z-API). Reactions arrive as Messages with
is_reaction=true; this PR exposes them in the bubble UI and lets
agents react with toggle/replace/remove semantics.

- Add POST /reactions endpoint with toggle/replace logic that handles
  multi-device echoes from the same connected number
- Add Channel::Whatsapp#supports_reactions? capability
- Add Message.hide_removed_reactions scope and use it in conversation
  card preview / last_non_activity_message
- Enrich last_non_activity_message with in_reply_to_snippet for
  reaction previews in chat list
- Frontend: hover EmojiReactionPicker (8 quick + full picker) with
  alignment-aware positioning, single ReactionDisplay chip aggregating
  emojis with total count, conversation card preview shows "Você
  reagiu" for own/multi-device echoes

* fix: address CodeRabbit review feedback

- MessagePreview: render "Você" for outgoing reaction echoes that have no
  sender (multi-device echoes from the connected number)
- MessagesView#findCurrentUserReaction: prefer active reactions over
  deleted rows so a stale deleted echo cannot hijack the toggle target
- conversationHelper: drop removed reactions up-front so the activity
  fallback never returns null when older non-removed messages exist
- imap_import rake: wrap IMAP work in begin/ensure so the session is
  closed even when uid_search/scan_new_email_uids raises
- ReactionDisplay: include reaction.id in the user row so v-for keys
  stay stable across re-renders

* fix: address CodeRabbit round 2 feedback

- enterprise Message override of mark_pending_conversation_as_open_for_human_response
  now early-returns on reaction? so reactions can no longer auto-open Captain-pending
  conversations (matches the OSS guard)
- whatsapp incoming reaction-removal handlers (Cloud/Baileys/Z-API) look up the
  reaction Message globally by sender instead of through the inbound conversation
  scope, then operate on existing.conversation; otherwise an old/resolved thread
  could be silently no-op'd while the inbound flow created a stray empty thread
- EmojiReactionPicker: localize quick-emoji tooltip labels via i18n keys
- Message.vue: track pendingTimeouts and clear them on unmount so the cooldown
  setTimeout no longer touches state after teardown
- toggleMessageReaction action returns the API promise so callers can reconcile
  if the cable echo is delayed

* fix: address CodeRabbit round 3 feedback

- MessageFinder#page_window: pluck the 20-row window IDs before taking .min
  so the latest page honors PAGE_LIMIT (ActiveRecord's .minimum(:id) ignores
  .limit and aggregates over the whole relation)
- ReactionsController#current_user_reaction: rank active reactions ahead of
  deleted rows (same invariant as the frontend lookup) so a stale deleted
  echo can no longer hijack the toggle target and resurrect itself
- Whatsapp incoming handlers (Cloud, Baileys individual & group, Z-API) now
  branch on reaction_removal? BEFORE set_conversation / find_or_create_group_
  conversation, so a blank reaction-removal webhook can never open or create
  a stray thread just to no-op
- Message#reaction?: strict-boolean cast (via ActiveModel::Type::Boolean)
  so a stored string "false" no longer leaks through .present? as truthy

* fix: address CodeRabbit round 4 feedback

- MessageList: anchor unread divider on the filtered visibleMessages
  (firstUnreadId can land on a reaction that's filtered out, otherwise
  the separator silently disappears)
- ReactionDisplay: render the removable user row as a real <button> when
  it's the current user's reaction so keyboard users can focus/activate it
- MessagesView#findCurrentUserReaction: read sender_type from m.sender_type
  OR m.sender?.type so REST-loaded messages match the same row instead of
  spawning a duplicate optimistic reaction
- Whatsapp incoming reaction-removal lookup (Cloud, Baileys, Z-API): pick
  the newest active row first and only fall back to the newest deleted row
  when no active reaction exists, mirroring the controller invariant
- CardMessagePreview: use MESSAGE_TYPE.OUTGOING constant in place of the
  literal 1 for the multi-device reaction echo check

* fix: address CodeRabbit round 5 feedback

- ReactionsController#ensure_target_is_reactable: reject activity,
  template, failed, is_unsupported and missing-source_id targets so the
  API mirrors the client toolbar gate and refuses reactions that could
  never land on WhatsApp
- MessageList reaction aggregator: treat "agent reacted via Chatwoot"
  and "agent reacted via the connected phone" as the same self bucket
  so the chip no longer double-counts the current user when both shapes
  coexist for one target
- internalChat ReactionDisplay: render the removable user row as a real
  <button> so keyboard users can focus and trigger removal (mirrors the
  fix already applied to components-next/message/ReactionDisplay)
- EventDataPresenter#push_last_non_activity_message: reorder
  created_at: :desc before .first so the cable snapshot publishes the
  latest preview instead of the oldest row
- Z-API mark_existing_reaction_as_removed: drop the blanket
  `return unless incoming_message?` and route the lookup by direction
  (contact sender for incoming removals, senderless outgoing row for
  multi-device removals from the connected phone). Chatwoot-originated
  echoes stay idempotent because the active-first guard finds nothing
  once the controller has flipped deleted=true locally
- spec: assert reaction removal does not change messages.count on the
  in-place Cloud path

* fix: address CodeRabbit round 6 feedback

- ReactionsController: validate the emoji payload is a single grapheme
  cluster containing a Unicode Emoji codepoint (not just <=32 bytes), so
  arbitrary short strings like "ok" or "123" can no longer be persisted
  as a reaction or enqueued as a WhatsApp reaction send
- target_unreactable_error: add the content_attributes['deleted'] guard
  to mirror the frontend picker gate on deleted messages
- IncomingMessageBaseService: move contact_processable? AFTER the
  reaction_removal? early-return so a blocked contact's removal webhook
  can still reconcile an existing reaction row instead of leaving a
  stale chip/preview
- imap_import rake: add safe_close_imap(imap) that falls back to
  disconnect when logout raises Net::IMAP::Error, mirroring
  terminate_imap_connection in BaseFetchEmailService, and replace the
  three ensure-block imap&.logout sites with it

* fix: address CodeRabbit round 7 feedback

- CardMessagePreview: resolve `lastNonActivityMessage` against the live
  `messages` array by id before rendering, so the chat-card preview
  picks up the freshest copy instead of the (possibly stale) snapshot
  that was mutated in place by a reaction toggle / multi-device echo
- Message + ReactionDisplay: thread an `inboxSupportsReactions` →
  `read-only` prop into the chip so non-supported channels (eg.
  360Dialog) render historical reactions without a clickable
  toggle/remove path that would only hit a 422
- conversations/index.js: replace the truthiness `&&` guard around the
  out-of-order MESSAGE_UPDATED check with `Number.isFinite` parsing so
  a malformed/missing `updated_at` is treated as stale instead of
  silently overwriting a fresher local row
- Baileys mark_existing_reaction_as_removed: drop the blanket
  `return unless incoming?` and split the lookup by direction
  (sender for incoming, sender-less outgoing for multi-device removals)
  to mirror the Z-API/Cloud handlers
- Whatsapp reaction-removal lookup (Cloud, Baileys, Z-API): drop the
  fallback to the newest deleted row so a Chatwoot-originated removal
  echo no-ops cleanly instead of bumping `updated_at` and dispatching
  another `conversation.updated`
- conversation jbuilder: explicit `reorder(created_at: :desc)` on
  `last_non_activity_message` so REST and cable both serialize the
  same most-recent preview

* fix: address CodeRabbit round 8 feedback

- ReactionsController#current_user_reaction: also match on
  content_attributes.in_reply_to_external_id = @target_message.source_id
  (via OR with the existing in_reply_to check), so WhatsApp-echoed
  reactions persisted by the incoming handlers — where in_reply_to could
  be blanked if the target wasn't resolvable at save time — are found and
  toggled instead of stacking a duplicate self-reaction
- Mirror the same defensive OR check in the frontend
  MessagesView#findCurrentUserReaction, and thread the target's
  source_id through the toggleReaction event from Message.vue so the
  lookup sees it

* fix: address CodeRabbit round 9 feedback

- emoji_payload_valid?: tighten the final property check from \p{Emoji}
  to \p{Extended_Pictographic} so plain "1", "#", "*" (which Unicode
  tags as Emoji because they're valid keycap bases) are rejected as
  reaction payloads
- EmojiReactionPicker: mirror the translated `title` into `aria-label`
  on the icon-only smile-plus / plus buttons so screen readers announce
  a meaningful action name
- internalChat ReactionDisplay: close the popover when the post-removal
  state would leave ≤1 reactions, so a singleton-user popover never
  lingers after removing one of a pair
- EventDataPresenter + conversation jbuilder: strip HTML before
  truncating `in_reply_to_snippet` so reactions to email/HTML bubbles
  don't surface literal "<p>..." markup in the chat-list preview

* fix: address CodeRabbit round 10 feedback

- MessageList#reactionsByMessageId: break createdAt ties with `<=` so a
  later iteration wins on second-resolution tie; two toggles in the same
  second no longer leave the chip pointing at a stale row
- MessagePreview: require a non-empty `message.attachments` array (via
  `?.length`) before taking the attachment preview branch, so a removed
  reaction with `[]` no longer renders the attachment placeholder
- MessagesView#findCurrentUserReaction: replace the sort-based pick with
  a reduce that deterministically takes the last element on tie, so a
  fast toggle can't hit a stale/deleted row with the same created_at
- Baileys group handler: guard against `@sender_contact.blank?` before
  dispatching mark_existing_reaction_as_removed, otherwise a nil sender
  would fall into the senderless-outgoing branch and match the wrong row
- WhatsApp reaction-removal lookups (Cloud, Baileys, Z-API): scope the
  base query to `inbox_id: inbox.id` so a colliding WhatsApp message id
  across inboxes can never mutate a reaction row from another inbox

* fix: address CodeRabbit round 11 feedback

- ReactionsController#emoji_payload_valid?: broaden the final property
  check to accept flag and keycap emoji. `\p{Extended_Pictographic}` by
  itself is per-codepoint, so 🇧🇷 (two Regional Indicators) and 1️⃣
  (digit + VS16 + U+20E3) failed validation. Allow any grapheme cluster
  that contains at least one pictographic codepoint, a Regional
  Indicator, or the combining keycap, while still rejecting plain
  ASCII like "ok", "1", "#"
- Message.vue#canShowReactionToolbar: hide the picker when the target
  has no provider source_id, mirroring the server guard in
  ReactionsController#target_unreactable_error instead of letting the
  click fall through to a 422
- MessageList#reactionsByMessageId: fall back to a sourceId → id
  lookup when a reaction only carries `inReplyToExternalId` (WhatsApp
  echo / phone-originated), so its chip still renders against the
  target bubble after reload
- getLastMessage: merge the fresher store fields onto the API
  snapshot instead of replacing it, so jbuilder-only enrichments like
  `in_reply_to_snippet` survive the store refresh

* fix(reactions): preserve API fields on card preview and expose a11y state on quick picker

* fix(reactions): consistent originalId resolution, natural PT-BR snippet phrasing, accurate outgoing-echo spec

* fix(reactions): reject requests missing emoji param and align zapi outgoing-echo spec fixture

* fix(reactions): activity preview fallback, camelCase event listener, EN snippet quoting, fromMe group removals, REST chat-only preview

* fix(reactions): reject non-string emoji, scope page reactions to window, exempt reactions from human_response, add cloud multi-device removal

* test(message): isolate hide_removed_reactions deleted-branch from blank-content branch

* fix(reactions): coerce in_reply_to_snippet to plain String

strip_tags returns an ActiveSupport::SafeBuffer; truncate preserves the
class. When this snippet flowed into ActionCableBroadcastJob via the
CONVERSATION_UPDATED dispatch, Sidekiq's strict-args check rejected the
non-native JSON type, raising synchronously through the dispatcher and
turning the reactions controller response into a 500 even though the row
had already persisted. The UI then surfaced the generic 'failed to update
reaction' toast despite the chip rendering correctly.

Wrap with String.new so the broadcast payload contains plain Strings.

* fix(reactions): don't auto-scroll to bottom on reaction add

ADD_MESSAGE emits SCROLL_TO_MESSAGE for every new push, which makes
sense for regular outgoing messages (the user just hit send and wants
to see it). Reactions render as chips on the parent bubble, so the
auto-scroll yanked the agent away from whichever older message they
were reacting to. Skip the emit when the incoming message is flagged
as a reaction.

* fix(reactions): skip scroll on conversation-only updates triggered by reactions

The reactions controller dispatches CONVERSATION_UPDATED so the chat list
preview can refresh in place. UPDATE_CONVERSATION mutation always emitted
SCROLL_TO_MESSAGE for the open conversation, so every toggle yanked the
viewport back to the bottom even after the previous fix in ADD_MESSAGE.
When the refreshed preview row is itself a reaction the update is
preview-only and the scroll is unwanted; for a regular incoming message
the latest non-activity row is the message itself, which still triggers
the scroll as before.

* fix(reactions): anchor compact picker to button side instead of centering

The compact picker was centered on the smile button, so half its width
always extended toward the bubble side and overflowed past the chat edge
on short messages. Anchor it to the button's outer side and nudge 4px
toward the bubble so it lines up with the trigger.

* test(reactions): regression coverage for safebuffer + scroll skip

The previous CodeRabbit rounds shipped three bugs the existing specs
didn't catch: a SafeBuffer return from `strip_tags` that 500'd the
reactions controller via Sidekiq strict-args, and two SCROLL_TO_MESSAGE
emits (one per mutation) that yanked the open conversation to the
bottom on every emoji toggle. Lock all three behaviors.

Also tighten the spec policy in AGENTS.md so new features default to
having specs instead of skipping them.

* test(baileys): align send_message_body helper with id:updated_at format

The reactions PR switched chatwootMessageId to "<id>:<updated_at>" so
toggle/replace cycles get a fresh idempotency key against baileys-api,
but the shared spec helper still merged the bare integer id. 18 baileys
provider specs were silently broken on CI as a result.

* fix(reactions): skip set_contact for unknown reaction-removal webhooks

Reaction-removal cloud webhooks were unconditionally creating a contact
even when the sender was unknown and there was nothing to remove,
because set_contact ran before the reaction_removal? short-circuit
(needed earlier so blocked-contact reconciliation works). Add a
sender-agnostic existence check on the inbox/in_reply_to scope and bail
out before set_contact when no candidate row exists.

Also realign two specs that were not updated when the chatwootMessageId
format gained an `:updated_at` suffix and when zapi reaction-removal
short-circuited instead of creating a Message.

* test(conversation): include last_non_activity_message in push_data fixture

Reactions PR added last_non_activity_message to the push_data payload
but conversation_spec's exact-match expectation wasn't updated, so the
sharded CI shard that landed on this file flipped red.
This commit is contained in:
Gabriel Jablonski 2026-04-30 21:09:12 -03:00 committed by GitHub
parent 5cc78c7b33
commit 72c9821270
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2316 additions and 84 deletions

View File

@ -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
- Dont 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

View File

@ -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

View File

@ -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

View File

@ -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();

View File

@ -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 "<sender> 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(() => {

View File

@ -0,0 +1,128 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useEventListener } from '@vueuse/core';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiInput from 'shared/components/emoji/EmojiInput.vue';
defineProps({
alignment: {
type: String,
default: 'right',
validator: value => ['left', 'right'].includes(value),
},
currentUserEmoji: {
type: String,
default: null,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const QUICK_EMOJIS = [
{ emoji: '👍', labelKey: 'CONVERSATION.REACTIONS.QUICK.THUMBS_UP' },
{ emoji: '❤️', labelKey: 'CONVERSATION.REACTIONS.QUICK.HEART' },
{ emoji: '😂', labelKey: 'CONVERSATION.REACTIONS.QUICK.JOY' },
{ emoji: '😮', labelKey: 'CONVERSATION.REACTIONS.QUICK.SURPRISED' },
{ emoji: '😢', labelKey: 'CONVERSATION.REACTIONS.QUICK.SAD' },
{ emoji: '🙏', labelKey: 'CONVERSATION.REACTIONS.QUICK.PRAY' },
{ emoji: '🔥', labelKey: 'CONVERSATION.REACTIONS.QUICK.FIRE' },
{ emoji: '🎉', labelKey: 'CONVERSATION.REACTIONS.QUICK.PARTY' },
];
const isOpen = ref(false);
const showFullPicker = ref(false);
function close() {
isOpen.value = false;
showFullPicker.value = false;
}
function toggle() {
if (isOpen.value) {
close();
} else {
isOpen.value = true;
}
}
function pickEmoji(emoji) {
if (!emoji) return;
emit('select', emoji);
close();
}
function openFullPicker() {
showFullPicker.value = true;
}
// Switching apps / Alt-Tab fires window blur but not a click event, so
// vOnClickOutside cannot reach it. Without this the picker stays open in
// the background and reappears on next hover.
useEventListener(window, 'blur', close);
</script>
<template>
<div v-on-click-outside="close" class="relative">
<button
type="button"
class="flex items-center justify-center rounded-full p-1 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12"
:title="t('CONVERSATION.REACTIONS.ADD_REACTION')"
:aria-label="t('CONVERSATION.REACTIONS.ADD_REACTION')"
@click="toggle"
>
<Icon icon="i-lucide-smile-plus" class="size-4" />
</button>
<div
v-if="isOpen && !showFullPicker"
class="absolute bottom-[calc(100%+0.5rem)] z-50 flex w-max items-center gap-1 rounded-full border border-n-slate-6 bg-n-solid-2 p-1 shadow-lg"
:class="
alignment === 'right' ? '-right-1 left-auto' : '-left-1 right-auto'
"
>
<button
v-for="item in QUICK_EMOJIS"
:key="item.labelKey"
type="button"
class="flex size-7 items-center justify-center rounded-full text-base hover:bg-n-alpha-2"
:class="{
'ring-2 ring-n-brand bg-n-alpha-2': item.emoji === currentUserEmoji,
}"
:title="
item.emoji === currentUserEmoji
? t('CONVERSATION.REACTIONS.CLICK_TO_REMOVE')
: t(item.labelKey)
"
:aria-label="
item.emoji === currentUserEmoji
? t('CONVERSATION.REACTIONS.CLICK_TO_REMOVE')
: t(item.labelKey)
"
:aria-pressed="item.emoji === currentUserEmoji"
@click="pickEmoji(item.emoji)"
>
{{ item.emoji }}
</button>
<button
type="button"
class="flex size-7 items-center justify-center rounded-full text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12"
:title="t('CONVERSATION.REACTIONS.MORE_EMOJIS')"
:aria-label="t('CONVERSATION.REACTIONS.MORE_EMOJIS')"
@click="openFullPicker"
>
<Icon icon="i-lucide-plus" class="size-4" />
</button>
</div>
<EmojiInput
v-if="isOpen && showFullPicker"
class="!bottom-[calc(100%+1.25rem)] !top-auto"
:class="
alignment === 'right' ? '!right-0 !left-auto' : '!left-0 !right-auto'
"
:on-click="pickEmoji"
/>
</div>
</template>

View File

@ -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({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex w-full mb-2 message-bubble-container"
class="flex w-full mb-2 message-bubble-container group"
:data-message-id="props.id"
:class="[
flexOrientationClass,
@ -679,7 +742,43 @@ provideMessageContext({
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
>
<Component :is="componentToRender" />
<div class="relative">
<Component :is="componentToRender" />
<div
v-if="canShowReactionToolbar"
class="absolute top-1/2 -translate-y-1/2 z-10 flex items-center gap-0.5 rounded-full border border-n-slate-6 bg-n-solid-2 shadow-sm p-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity [@media(hover:none)]:opacity-100"
:class="
orientation === ORIENTATION.RIGHT
? 'ltr:right-full ltr:mr-2 rtl:left-full rtl:ml-2'
: 'ltr:left-full ltr:ml-2 rtl:right-full rtl:mr-2'
"
>
<EmojiReactionPicker
:alignment="
orientation === ORIENTATION.RIGHT ? 'right' : 'left'
"
:current-user-emoji="currentUserReactionEmoji"
@select="handleToggleReaction"
/>
</div>
</div>
</div>
<div
v-if="reactions.length > 0"
class="flex"
:class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
}"
>
<ReactionDisplay
:reactions="reactions"
:current-user-id="currentUserId"
:pending-emojis="pendingEmojis"
:alignment="orientation === ORIENTATION.RIGHT ? 'right' : 'left'"
:read-only="!inboxSupportsReactions"
@toggle="handleToggleReaction"
/>
</div>
</div>
<MessageError

View File

@ -38,13 +38,17 @@ const props = defineProps({
type: Boolean,
default: false,
},
inboxSupportsReactions: {
type: Boolean,
default: false,
},
messages: {
type: Array,
default: () => [],
},
});
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 => {
<template>
<ul class="px-4 bg-n-surface-1">
<slot name="beforeAll" />
<template v-for="(message, index) in allMessages" :key="message.id">
<template v-for="(message, index) in visibleMessages" :key="message.id">
<slot
v-if="firstUnreadId && message.id === firstUnreadId"
v-if="effectiveFirstUnreadId && message.id === effectiveFirstUnreadId"
name="unreadBadge"
/>
<Message
v-bind="message"
:is-email-inbox="isAnEmailChannel"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, allMessages)"
:group-with-next="shouldGroupWithNext(index, visibleMessages)"
:group-with-previous="
index > 0 && shouldGroupWithNext(index - 1, allMessages)
index > 0 && shouldGroupWithNext(index - 1, visibleMessages)
"
:is-group-conversation="isGroupConversation"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:inbox-supports-edit="inboxSupportsEdit"
:inbox-supports-reactions="inboxSupportsReactions"
:reactions="reactionsByMessageId.get(message.id) || []"
:current-user-id="currentUserId"
data-clarity-mask="True"
@retry="emit('retry', message)"
@toggle-reaction="emit('toggleReaction', $event)"
/>
</template>
<slot name="after" />

View File

@ -0,0 +1,146 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
const props = defineProps({
reactions: {
type: Array,
default: () => [],
},
currentUserId: {
type: Number,
default: null,
},
pendingEmojis: {
type: Set,
default: () => new Set(),
},
alignment: {
type: String,
default: 'left',
validator: value => ['left', 'right'].includes(value),
},
readOnly: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['toggle']);
const { t } = useI18n();
const showPopover = ref(false);
const groupedReactions = computed(() => {
const groups = {};
props.reactions.forEach(reaction => {
const { emoji } = reaction;
if (!groups[emoji]) {
groups[emoji] = { emoji, count: 0, isMine: false, users: [] };
}
groups[emoji].count += 1;
const isMine =
(reaction.senderType === 'user' &&
reaction.senderId === props.currentUserId) ||
(reaction.messageType === 1 && reaction.senderId == null);
if (isMine) groups[emoji].isMine = true;
groups[emoji].users.push({
id: reaction.id,
name: reaction.sender?.name || '',
isMine,
});
});
return Object.values(groups);
});
const uniqueEmojis = computed(() => groupedReactions.value.map(g => g.emoji));
const totalCount = computed(() =>
groupedReactions.value.reduce((sum, g) => sum + g.count, 0)
);
const isMine = computed(() => groupedReactions.value.some(g => g.isMine));
const isAnyPending = computed(() =>
uniqueEmojis.value.some(emoji => props.pendingEmojis.has(emoji))
);
function togglePopover() {
showPopover.value = !showPopover.value;
}
function closePopover() {
showPopover.value = false;
}
function handleRowClick(emoji, user) {
if (!user.isMine) return;
if (props.readOnly) return;
emit('toggle', emoji);
closePopover();
}
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="groupedReactions.length"
class="relative -mt-1 flex flex-wrap items-center gap-1"
>
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-xs transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
:class="
isMine
? 'border-n-brand bg-n-alpha-2 text-n-brand'
: 'border-n-slate-6 bg-n-alpha-1 text-n-slate-12 hover:bg-n-alpha-2'
"
:disabled="isAnyPending"
@click="togglePopover"
>
<span class="inline-flex items-center gap-0.5">
<span v-for="emoji in uniqueEmojis" :key="emoji">{{ emoji }}</span>
</span>
<span>{{ totalCount }}</span>
</button>
<div
v-if="showPopover"
v-on-click-outside="closePopover"
class="absolute bottom-full z-50 mb-1 min-w-48 rounded-lg border border-n-slate-6 bg-n-solid-2 p-2 shadow-lg"
:class="alignment === 'right' ? 'right-0' : 'left-0'"
>
<div
v-for="(group, groupIdx) in groupedReactions"
:key="group.emoji"
:class="{ 'mt-2 border-t border-n-slate-5 pt-2': groupIdx > 0 }"
>
<component
:is="user.isMine && !readOnly ? 'button' : 'div'"
v-for="(user, userIdx) in group.users"
:key="`${group.emoji}-${user.id ?? userIdx}`"
:type="user.isMine && !readOnly ? 'button' : null"
class="flex w-full items-center gap-2 rounded px-1 py-1 text-left"
:class="
user.isMine && !readOnly ? 'cursor-pointer hover:bg-n-alpha-2' : ''
"
@click="handleRowClick(group.emoji, user)"
>
<span class="w-5 text-center text-sm">{{ group.emoji }}</span>
<div class="flex-1 min-w-0">
<div class="text-xs text-n-slate-12 truncate">
{{ user.isMine ? t('CONVERSATION.REACTIONS.YOU') : user.name }}
</div>
<div
v-if="user.isMine && !readOnly"
class="text-[10px] text-n-slate-11"
>
{{ t('CONVERSATION.REACTIONS.CLICK_TO_REMOVE') }}
</div>
</div>
</component>
</div>
</div>
</div>
</template>

View File

@ -1,6 +1,7 @@
<script>
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useMapGetter } from 'dashboard/composables/store';
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
export default {
@ -21,11 +22,40 @@ export default {
},
setup() {
const { getPlainText } = useMessageFormatter();
const currentUserId = useMapGetter('getCurrentUserID');
return {
getPlainText,
currentUserId,
};
},
computed: {
isReactionMessage() {
const attrs = this.message?.content_attributes;
// Treat removed reactions (deleted toggled or content cleared) as not a
// reaction so the preview falls back to plain text/no-content branches
// instead of rendering "X reagiu " with an empty emoji.
return (
attrs?.is_reaction === true &&
!attrs?.deleted &&
!!this.message?.content
);
},
reactionPreviewText() {
if (!this.isReactionMessage) return '';
const senderId = this.message?.sender?.id;
const isOwnInboxEcho =
this.message?.message_type === MESSAGE_TYPE.OUTGOING && !senderId;
const senderName =
senderId === this.currentUserId || isOwnInboxEcho
? this.$t('CONVERSATION.REACTIONS.YOU')
: this.message?.sender?.name || '';
const emoji = this.message?.content;
const snippet = this.message?.in_reply_to_snippet;
const params = { sender: senderName, emoji, snippet };
return snippet
? this.$t('CHAT_LIST.REACTED_TO_SNIPPET', params)
: this.$t('CHAT_LIST.REACTED', params);
},
messageByAgent() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.OUTGOING;
@ -82,7 +112,10 @@ export default {
icon="info"
/>
</template>
<span v-if="message.content && isMessageSticker">
<span v-if="isReactionMessage">
{{ reactionPreviewText }}
</span>
<span v-else-if="message.content && isMessageSticker">
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-11"
@ -93,7 +126,7 @@ export default {
<span v-else-if="message.content">
{{ parsedLastMessage }}
</span>
<span v-else-if="message.attachments">
<span v-else-if="message.attachments?.length">
<fluent-icon
v-if="attachmentIcon && showMessageType"
size="16"

View File

@ -274,6 +274,13 @@ export default {
// Currently only Baileys WhatsApp channel supports message editing
return this.isAWhatsAppBaileysChannel;
},
inboxSupportsReactions() {
return (
this.isAWhatsAppCloudChannel ||
this.isAWhatsAppBaileysChannel ||
this.isAWhatsAppZapiChannel
);
},
currentContact() {
const senderId = this.currentChat?.meta?.sender?.id;
if (!senderId) return {};
@ -578,6 +585,150 @@ export default {
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
},
async handleToggleReaction({ messageId, targetSourceId, emoji }) {
// Backend keeps a single Message row per (target, user) and toggles it
// in-place. The cable echo always carries the original create's echo_id,
// so creating a fresh optimistic per toggle leaves the new one orphaned
// in the store (the cable matches the real msg id, never the new echo).
// Those orphans show up as "reagiu <emoji>" in the chat list preview
// even after the user toggles off. Update the existing entry instead.
const existing = this.findCurrentUserReaction(messageId, targetSourceId);
if (existing) {
await this.applyToggleOnExisting(existing, messageId, emoji);
} else {
await this.applyToggleOnNew(messageId, emoji);
}
},
async applyToggleOnExisting(existing, messageId, emoji) {
const isActive =
existing.content && !existing.content_attributes?.deleted;
const isToggleOff =
isActive && (emoji === '' || existing.content === emoji);
const newAttrs = { ...(existing.content_attributes || {}) };
if (isToggleOff) newAttrs.deleted = true;
else delete newAttrs.deleted;
const previous = {
content: existing.content,
content_attributes: existing.content_attributes,
};
this.$store.dispatch('updateMessage', {
...existing,
content: isToggleOff ? '' : emoji,
content_attributes: newAttrs,
});
try {
await this.$store.dispatch('toggleMessageReaction', {
conversationId: this.currentChat.id,
messageId,
emoji,
echoId: existing.echo_id,
});
} catch (error) {
this.$store.dispatch('updateMessage', { ...existing, ...previous });
useAlert(this.$t('CONVERSATION.REACTIONS.FAILED'));
}
},
async applyToggleOnNew(messageId, emoji) {
const optimistic = this.buildOptimisticReaction(messageId, emoji);
this.$store.dispatch('addMessage', optimistic);
try {
await this.$store.dispatch('toggleMessageReaction', {
conversationId: this.currentChat.id,
messageId,
emoji,
echoId: optimistic.echo_id,
});
} catch (error) {
this.$store.dispatch('updateMessage', {
...optimistic,
content_attributes: {
...optimistic.content_attributes,
deleted: true,
},
});
useAlert(this.$t('CONVERSATION.REACTIONS.FAILED'));
}
},
findCurrentUserReaction(messageId, targetSourceId = null) {
const messages = this.currentChat?.messages || [];
const matches = messages.filter(m => {
if (!m.content_attributes?.is_reaction) return false;
// Match both in_reply_to (set by Chatwoot-originated reactions) and
// in_reply_to_external_id (set by WhatsApp echoes). Without the
// external id check, a multi-device reaction sent from the connected
// phone would be invisible here, and the next toggle would stack a
// duplicate optimistic row instead of mutating the echoed one.
const matchesInReplyTo =
m.content_attributes?.in_reply_to === messageId;
const matchesExternalId =
targetSourceId &&
m.content_attributes?.in_reply_to_external_id === targetSourceId;
if (!matchesInReplyTo && !matchesExternalId) return false;
// REST jbuilder doesn't surface sender_type; only the nested
// sender.type. ActionCable push_event_data has the top-level field.
// Read both so REST-loaded agent reactions match instead of stacking
// a duplicate optimistic row.
const senderType = (
m.sender_type ||
m.sender?.type ||
''
).toLowerCase();
const senderId = m.sender?.id ?? m.sender_id;
// Reaction created via Chatwoot UI by the current user
if (senderType === 'user' && senderId === this.currentUserId) {
return true;
}
// Multi-device echo: agent reacted from the WhatsApp mobile app on
// the same number connected to this inbox, so it has no agent in
// Chatwoot. Treat it as ours so a click toggles/removes it instead
// of stacking a duplicate reaction on top.
return m.message_type === 1 && senderId == null;
});
// Prefer active rows so we never resurrect a stale deleted echo when
// there is a fresher live reaction sitting next to it. created_at is
// second-resolution, so a sort can keep the older entry first on ties.
// Reduce with >= so that, all else equal, the later iteration wins
// giving a deterministic "newest" pick even for two toggles in the same
// second.
const pickLatest = list =>
list.reduce((latest, candidate) => {
if (!latest) return candidate;
return (candidate.created_at || 0) >= (latest.created_at || 0)
? candidate
: latest;
}, null);
const isActive = r => !!r.content && !r.content_attributes?.deleted;
return pickLatest(matches.filter(isActive)) || pickLatest(matches);
},
buildOptimisticReaction(messageId, emoji) {
// Use the echo_id as the temporary id so findPendingMessageIndex matches
// the real Message arriving later via ActionCable (it carries echo_id).
const echoId = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return {
id: echoId,
echo_id: echoId,
content: emoji,
conversation_id: this.currentChat?.id,
message_type: 1,
content_type: 'text',
content_attributes: {
is_reaction: true,
in_reply_to: messageId,
},
additional_attributes: {},
attachments: [],
sender: this.currentUser,
sender_type: 'User',
sender_id: this.currentUserId,
private: false,
status: 'progress',
created_at: Math.floor(Date.now() / 1000),
};
},
toggleReplyEditorSize() {
this.resizableEditorWrapperRef?.toggleEditorExpand?.();
},
@ -713,8 +864,10 @@ export default {
:is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:inbox-supports-edit="inboxSupportsEdit"
:inbox-supports-reactions="inboxSupportsReactions"
:messages="getMessages"
@retry="handleMessageRetry"
@toggle-reaction="handleToggleReaction"
>
<template #beforeAll>
<transition name="slide-up">

View File

@ -46,21 +46,49 @@ export const filterDuplicateSourceMessages = (messages = []) => {
* @param {Object} m - The conversation object containing messages.
* @returns {Object} The last message of the conversation.
*/
export const getLastMessage = m => {
const lastMessageIncludingActivity = m.messages[m.messages.length - 1];
// A reaction whose user-facing state is invisible (toggled off / blank).
// Treated as non-existent for chat list preview purposes — otherwise the
// preview would render "<sender> reagiu <>" or fall back to "no content"
// even when there's a real previous message we could show.
const isRemovedReaction = message =>
message?.content_attributes?.is_reaction &&
(message?.content_attributes?.deleted || !message?.content);
const nonActivityMessages = m.messages.filter(
export const getLastMessage = m => {
// Drop removed reactions up-front so neither the activity fallback nor the
// visible-messages list ever picks a blank "X reagiu " row.
const nonRemovedMessages = m.messages.filter(
message => !isRemovedReaction(message)
);
const lastMessageIncludingActivity =
nonRemovedMessages[nonRemovedMessages.length - 1];
const visibleMessages = nonRemovedMessages.filter(
message => message.message_type !== 2
);
const lastNonActivityMessageInStore =
nonActivityMessages[nonActivityMessages.length - 1];
visibleMessages[visibleMessages.length - 1];
const lastNonActivityMessageFromAPI = m.last_non_activity_message;
// ADD_MESSAGE mutates `chat.messages` in place but never touches
// `last_non_activity_message`, so the API field can hold a stale snapshot of
// a message that has since been updated (eg. a reaction toggled off). When
// the same id is present in the store, merge the fresher store fields onto
// the API snapshot — replacing would strip jbuilder-only fields like
// `in_reply_to_snippet` that aren't present on push_event_data.
const apiCandidate = m.last_non_activity_message;
const apiId = apiCandidate?.id;
const storeVersion = apiId ? m.messages.find(msg => msg.id === apiId) : null;
const refreshedApiCandidate = storeVersion
? { ...apiCandidate, ...storeVersion }
: apiCandidate;
const lastNonActivityMessageFromAPI = isRemovedReaction(refreshedApiCandidate)
? null
: refreshedApiCandidate;
// If API value and store value for last non activity message
// is empty, then return the last activity message
if (!lastNonActivityMessageInStore && !lastNonActivityMessageFromAPI) {
return lastMessageIncludingActivity;
return lastMessageIncludingActivity || null;
}
return getLastNonActivityMessage(

View File

@ -139,6 +139,8 @@
"TYPING": "typing...",
"RECORDING": "recording...",
"NO_CONTENT": "No content available",
"REACTED": "{sender} reacted {emoji}",
"REACTED_TO_SNIPPET": "{sender} reacted {emoji} to \"{snippet}\"",
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read",

View File

@ -314,6 +314,23 @@
"CONTACT": "Contact",
"COPILOT": "Copilot"
},
"REACTIONS": {
"ADD_REACTION": "Add reaction",
"MORE_EMOJIS": "More emojis",
"FAILED": "Couldn't update reaction. Try again.",
"YOU": "You",
"CLICK_TO_REMOVE": "Click to remove",
"QUICK": {
"THUMBS_UP": "Thumbs up",
"HEART": "Heart",
"JOY": "Joy",
"SURPRISED": "Surprised",
"SAD": "Sad",
"PRAY": "Pray",
"FIRE": "Fire",
"PARTY": "Party"
}
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",

View File

@ -139,6 +139,8 @@
"TYPING": "digitando...",
"RECORDING": "gravando...",
"NO_CONTENT": "Nenhum conteúdo disponível",
"REACTED": "{sender} reagiu {emoji}",
"REACTED_TO_SNIPPET": "{sender} reagiu {emoji} a \"{snippet}\"",
"HIDE_QUOTED_TEXT": "Ocultar Texto Citado",
"SHOW_QUOTED_TEXT": "Mostrar Texto Citado",
"MESSAGE_READ": "Lida",

View File

@ -314,6 +314,23 @@
"CONTACT": "Contatos",
"COPILOT": "Copiloto"
},
"REACTIONS": {
"ADD_REACTION": "Adicionar reação",
"MORE_EMOJIS": "Mais emojis",
"FAILED": "Não foi possível atualizar a reação. Tente novamente.",
"YOU": "Você",
"CLICK_TO_REMOVE": "Clique para remover",
"QUICK": {
"THUMBS_UP": "Joinha",
"HEART": "Coração",
"JOY": "Risada",
"SURPRISED": "Surpresa",
"SAD": "Triste",
"PRAY": "Oração",
"FIRE": "Fogo",
"PARTY": "Festa"
}
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Chamada recebida",
"OUTGOING_CALL": "Chamada realizada",

View File

@ -2,7 +2,6 @@
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
reactions: {
@ -32,13 +31,14 @@ const groupedReactions = computed(() => {
users: [],
};
}
const isMine = reaction.user_id === props.currentUserId;
groups[reaction.emoji].count += 1;
groups[reaction.emoji].users.push({
name: reaction.user?.name || '',
id: reaction.user_id,
reactionId: reaction.id,
isMine,
});
if (reaction.user_id === props.currentUserId) {
if (isMine) {
groups[reaction.emoji].userReactionId = reaction.id;
}
});
@ -53,14 +53,18 @@ function closePopover() {
showPopover.value = false;
}
function handleRemove(reactionId) {
emit('remove', reactionId);
if (props.reactions.length <= 1) {
showPopover.value = false;
}
function handleRowClick(user) {
if (!user.isMine) return;
emit('remove', user.reactionId);
// `reactions.length` is still the pre-removal count here. Close when the
// post-removal state would leave at most one reaction left: at that point
// the popover's list view collapses to a single user and stops being
// useful, so dropping it avoids a dangling open panel.
if (props.reactions.length - 1 <= 1) closePopover();
}
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="groupedReactions.length"
@ -81,7 +85,6 @@ function handleRemove(reactionId) {
<span>{{ group.emoji }}</span>
<span>{{ group.count }}</span>
</button>
<div
v-if="showPopover"
v-on-click-outside="closePopover"
@ -92,25 +95,25 @@ function handleRemove(reactionId) {
:key="group.emoji"
:class="{ 'mt-2 border-t border-n-slate-5 pt-2': groupIdx > 0 }"
>
<div
<component
:is="user.isMine ? 'button' : 'div'"
v-for="user in group.users"
:key="user.reactionId"
class="flex h-7 items-center gap-2 rounded px-1"
:type="user.isMine ? 'button' : null"
class="flex w-full items-center gap-2 rounded px-1 py-1 text-left"
:class="user.isMine ? 'cursor-pointer hover:bg-n-alpha-2' : ''"
@click="handleRowClick(user)"
>
<span class="w-5 text-center text-sm">{{ group.emoji }}</span>
<span class="flex-1 truncate text-xs text-n-slate-12">
{{ user.name }}
</span>
<button
v-if="user.id === currentUserId"
type="button"
class="flex-shrink-0 rounded p-1 text-n-slate-11 hover:bg-n-ruby-3 hover:text-n-ruby-11"
:title="t('INTERNAL_CHAT.MESSAGE.DELETE')"
@click.stop="handleRemove(user.reactionId)"
>
<Icon icon="i-lucide-x" class="size-4" />
</button>
</div>
<div class="flex-1 min-w-0">
<div class="text-xs text-n-slate-12 truncate">
{{ user.isMine ? t('CONVERSATION.REACTIONS.YOU') : user.name }}
</div>
<div v-if="user.isMine" class="text-[10px] text-n-slate-11">
{{ t('CONVERSATION.REACTIONS.CLICK_TO_REMOVE') }}
</div>
</div>
</component>
</div>
</div>
</div>

View File

@ -350,6 +350,18 @@ const actions = {
}
},
toggleMessageReaction: function toggleMessageReaction(
_context,
{ conversationId, messageId, emoji, echoId }
) {
// The optimistic Message is dispatched to the store by the caller.
// Backend echoes back the same echo_id via ActionCable MESSAGE_CREATED, and
// findPendingMessageIndex in the ADD_MESSAGE mutation swaps the fake for
// the real one. Returning the promise lets callers reconcile if the cable
// echo is delayed/missing.
return MessageApi.toggleReaction(conversationId, messageId, emoji, echoId);
},
editMessage: async function editMessage(
{ commit },
{ conversationId, messageId, content }

View File

@ -216,13 +216,29 @@ export const mutations = {
const pendingMessageIndex = findPendingMessageIndex(chat, message);
if (pendingMessageIndex !== -1) {
// MESSAGE_UPDATED cables can arrive out of order when the user toggles a
// reaction quickly: each Sidekiq job reads the message at run time, so a
// late-arriving cable for an older state would clobber the fresher one.
// Drop updates that are older than what we already have.
const existing = chat.messages[pendingMessageIndex];
const incomingTs = Date.parse(message.updated_at);
const existingTs = Date.parse(existing?.updated_at);
const hasIncomingTs = Number.isFinite(incomingTs);
const hasExistingTs = Number.isFinite(existingTs);
// If the incoming timestamp is unparseable, treat it as stale so a
// malformed cable can't clobber the local row.
if (hasExistingTs && (!hasIncomingTs || incomingTs < existingTs)) return;
chat.messages[pendingMessageIndex] = message;
} else {
chat.messages.push(message);
chat.timestamp = message.created_at;
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
chat.unread_count = unreadCount;
if (selectedChatId === conversationId) {
// Reactions render as chips on their parent bubble, not as standalone
// rows, so jumping the viewport to the bottom on every toggle would
// yank the user away from whatever older message they reacted to.
const isReaction = message.content_attributes?.is_reaction === true;
if (selectedChatId === conversationId && !isReaction) {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
}
@ -255,7 +271,15 @@ export const mutations = {
const { messages, ...updates } = conversation;
allConversations[index] = { ...selectedConversation, ...updates };
if (_state.selectedChatId === conversation.id) {
// The reactions controller bumps `updated_at` and dispatches
// CONVERSATION_UPDATED so the chat list preview refreshes; without this
// guard every emoji toggle would yank the open conversation back to the
// bottom via the SCROLL_TO_MESSAGE listener. When the latest preview row
// is a reaction, treat the update as preview-only and skip the scroll.
const lastIsReaction =
updates.last_non_activity_message?.content_attributes?.is_reaction ===
true;
if (_state.selectedChatId === conversation.id && !lastIsReaction) {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
} else {

View File

@ -172,6 +172,20 @@ describe('#mutations', () => {
expect(emitter.emit).toHaveBeenCalledWith('SCROLL_TO_MESSAGE');
});
it('skips SCROLL_TO_MESSAGE when the new message is a reaction', () => {
const state = {
allConversations: [{ id: 1, messages: [] }],
selectedChatId: 1,
};
mutations[types.ADD_MESSAGE](state, {
conversation_id: 1,
content: '👍',
created_at: 1602256198,
content_attributes: { is_reaction: true, in_reply_to: 42 },
});
expect(emitter.emit).not.toHaveBeenCalled();
});
it('update message if it exist in the store', () => {
global.bus = { $emit: vi.fn() };
const state = {
@ -210,6 +224,46 @@ describe('#mutations', () => {
});
});
describe('#UPDATE_CONVERSATION', () => {
it('emits SCROLL_TO_MESSAGE when the open conversation gets a regular update', () => {
const state = {
allConversations: [
{ id: 1, updated_at: 1, last_non_activity_message: null },
],
selectedChatId: 1,
};
mutations[types.UPDATE_CONVERSATION](state, {
id: 1,
updated_at: 2,
last_non_activity_message: {
id: 99,
content: 'Hello',
content_attributes: {},
},
});
expect(emitter.emit).toHaveBeenCalledWith('SCROLL_TO_MESSAGE');
});
it('skips SCROLL_TO_MESSAGE when the update was triggered by a reaction', () => {
const state = {
allConversations: [
{ id: 1, updated_at: 1, last_non_activity_message: null },
],
selectedChatId: 1,
};
mutations[types.UPDATE_CONVERSATION](state, {
id: 1,
updated_at: 2,
last_non_activity_message: {
id: 99,
content: '👍',
content_attributes: { is_reaction: true, in_reply_to: 42 },
},
});
expect(emitter.emit).not.toHaveBeenCalled();
});
});
describe('#CHANGE_CONVERSATION_STATUS', () => {
it('updates the conversation status correctly', () => {
const state = {

View File

@ -30,6 +30,7 @@ class Channel::Whatsapp < ApplicationRecord # rubocop:disable Metrics/ClassLengt
# default at the moment is 360dialog lets change later.
PROVIDERS = %w[default whatsapp_cloud baileys zapi].freeze
REACTION_SUPPORTED_PROVIDERS = %w[whatsapp_cloud baileys zapi].freeze
before_validation :ensure_webhook_verify_token
validates :provider, inclusion: { in: PROVIDERS }
@ -47,6 +48,10 @@ class Channel::Whatsapp < ApplicationRecord # rubocop:disable Metrics/ClassLengt
'Whatsapp'
end
def supports_reactions?
REACTION_SUPPORTED_PROVIDERS.include?(provider)
end
def provider_service
case provider
when 'whatsapp_cloud'

View File

@ -128,6 +128,20 @@ class Message < ApplicationRecord
scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('created_at desc') }
scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) }
scope :voice_calls, -> { where(content_type: :voice_call) }
# Excludes reactions whose user-facing state is invisible (toggled off or
# blank). Used when picking a "last meaningful message" for chat list
# previews — a removed reaction shouldn't drive the preview text.
# `#>>'{}'` unwraps the legacy double-encoded `content_attributes` (json
# column written via `store coder: JSON`) so `->>` can traverse it. The
# `IS NOT TRUE` guards keep NULL JSON values from collapsing the row under
# SQL three-valued logic.
scope :hide_removed_reactions, lambda {
json_path = "(content_attributes#>>'{}')::jsonb"
where(
"((#{json_path})->>'is_reaction' = 'true') IS NOT TRUE " \
"OR (((#{json_path})->>'deleted' = 'true') IS NOT TRUE AND content IS NOT NULL AND content <> '')"
)
}
# TODO: Get rid of default scope
# https://stackoverflow.com/a/1834250/939299
@ -230,8 +244,13 @@ class Message < ApplicationRecord
content_attributes.dig(:email, :auto_reply) == true
end
def reaction?
ActiveModel::Type::Boolean.new.cast(content_attributes['is_reaction']) == true
end
def valid_first_reply?
return false unless human_response? && !private?
return false if reaction?
return false if conversation.first_reply_created_at.present?
return false if conversation.messages.outgoing
.where.not(sender_type: ['AgentBot', 'Captain::Assistant'])
@ -370,6 +389,12 @@ class Message < ApplicationRecord
end
def human_response?
# Reactions are not substantive replies; treating them as one would
# clear `waiting_since` / dispatch REPLY_CREATED on every emoji toggle
# and skew SLA timers for conversations the agent has not actually
# answered yet.
return false if reaction?
# if the sender is not a user, it's not a human response
# if automation rule id is present, it's not a human response
# if campaign id is present, it's not a human response
@ -413,6 +438,7 @@ class Message < ApplicationRecord
def reopen_conversation
return if conversation.muted?
return unless incoming?
return if reaction?
conversation.open! if conversation.snoozed?
@ -423,6 +449,7 @@ class Message < ApplicationRecord
return unless captain_pending_conversation?
return unless human_response?
return if private?
return if reaction?
conversation.open!
end

View File

@ -9,6 +9,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
id: display_id,
inbox_id: inbox_id,
messages: push_messages,
last_non_activity_message: push_last_non_activity_message,
labels: label_list,
meta: push_meta,
status: status,
@ -33,6 +34,36 @@ class Conversations::EventDataPresenter < SimpleDelegator
[messages.where(account_id: account_id).chat.last&.push_event_data].compact
end
# Mirrors the conversation jbuilder so cable subscribers can refresh the chat
# list preview after in-place reaction updates (the snake-cased field is read
# by the frontend store and `MessagePreview` to derive the latest visible
# message). Without this, the snapshot taken at fetch time stays stale.
def push_last_non_activity_message
msg = messages.where(account_id: account_id)
.non_activity_messages
.hide_removed_reactions
.reorder(created_at: :desc)
.first
return nil unless msg
data = msg.push_event_data
if msg.reaction?
target_id = msg.content_attributes['in_reply_to']
target = target_id.present? ? messages.find_by(id: target_id) : nil
# Strip HTML before truncating so email/HTML messages don't leak
# "<p>..." markup into the chat-list preview as literal text.
# `strip_tags` returns an `ActiveSupport::SafeBuffer`, which Sidekiq's
# strict-args check rejects when this hash is passed to
# `ActionCableBroadcastJob.perform_later`; coerce back to a plain String
# so the cable broadcast doesn't 500 the controller via the dispatcher.
if target&.content.present?
plain_snippet = String.new(ActionController::Base.helpers.strip_tags(target.content))
data[:in_reply_to_snippet] = plain_snippet.truncate(60)
end
end
data
end
def webhook_push_messages
[messages.where(account_id: account_id).chat.last&.webhook_push_event_data].compact
end

View File

@ -35,6 +35,20 @@ module Whatsapp::BaileysHandlers::Concerns::GroupContactMessageHandler # rubocop
try_update_contact_avatar(@sender_contact)
end
# Reaction removals don't produce a new Message row; handle them before
# find_or_create_group_conversation so a blank webhook can't create a
# stray group thread. The sender-blank guard only matters for incoming
# removals: with `sender: nil` the lookup would accidentally match a
# senderless outgoing row. The outgoing (fromMe) branch ignores the
# sender argument entirely (it scopes by sender_id IS NULL itself), so
# gating multi-device removals on @sender_contact would leave the row
# stuck active.
if reaction_removal?
return if incoming? && @sender_contact.blank?
return mark_existing_reaction_as_removed(sender: @sender_contact)
end
@conversation = find_or_create_group_conversation(@group_contact_inbox)
add_group_member(@group_contact, @sender_contact) if @sender_contact

View File

@ -5,7 +5,7 @@ module Whatsapp::BaileysHandlers::Concerns::IndividualContactMessageHandler
private
def handle_individual_contact_message
def handle_individual_contact_message # rubocop:disable Metrics/CyclomaticComplexity
return unless extract_from_jid(type: 'lid')
@lock_acquired = acquire_message_processing_lock
@ -29,6 +29,10 @@ module Whatsapp::BaileysHandlers::Concerns::IndividualContactMessageHandler
return
end
# Reaction removals don't produce a new Message row; handle them before
# set_conversation so a blank webhook can't open/create a stray thread.
next mark_existing_reaction_as_removed(sender: @contact) if reaction_removal?
set_conversation
handle_create_message
dispatch_incoming_typing_off

View File

@ -23,6 +23,65 @@ module Whatsapp::BaileysHandlers::Concerns::MessageCreationHandler
@message
end
# WhatsApp delivers a reaction removal as a fresh message with empty text.
# Our schema keeps a single Message row per (target, sender) and toggles
# `deleted` on it, so we look up that row and mark it removed instead of
# creating a duplicate empty Message that the chat list would have to filter.
#
# `fromMe` removals can come from two paths and we want both handled:
# - Chatwoot-originated echo: the controller already toggled the row to
# deleted, so the active-only lookup finds nothing and this no-ops.
# - Multi-device removal (agent un-reacts from the connected phone): the
# row is still active and stored sender-less outgoing, so we mark it
# deleted.
# Lookup is intentionally NOT scoped to the inbound conversation: the
# original reaction may live in an older/resolved thread, while the inbound
# flow could have picked (or created) a different one. Find the row first,
# then operate on its real `existing.conversation`.
def mark_existing_reaction_as_removed(sender:)
target_external_id = unwrap_ephemeral_message(@raw_message[:message]).dig(:reactionMessage, :key, :id)
return if target_external_id.blank?
existing = find_existing_reaction(sender, target_external_id)
return if existing.nil?
new_attrs = existing.content_attributes.merge('deleted' => true)
existing.update!(content: '', content_attributes: new_attrs)
target_conversation = existing.conversation
# Refresh the chat list snapshot of `last_non_activity_message`; the cable
# MESSAGE_UPDATED event only refreshes chat.messages on the client, so
# without this the preview can stay pointed at the pre-removal reaction.
# Touch updated_at so the frontend out-of-order guard can drop stale cables.
target_conversation.update_columns(updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
target_conversation.dispatch_conversation_updated_event
existing
end
def find_existing_reaction(sender, target_external_id)
json_path = "(content_attributes#>>'{}')::jsonb"
# Scope by inbox: the senderless outgoing branch would otherwise match any
# reaction with the same provider message id, and two inboxes that ever
# receive colliding WhatsApp ids would step on each other's rows.
base = Message.where(inbox_id: inbox.id)
.where("#{json_path}->>'is_reaction' = 'true'")
.where("#{json_path}->>'in_reply_to_external_id' = ?", target_external_id)
matches = if incoming?
base.where(sender: sender)
else
# Multi-device: agent reacted via the connected phone, so the
# local row has no agent (sender_id IS NULL) and is outgoing.
base.where(sender_id: nil, sender_type: nil)
.where(message_type: Message.message_types[:outgoing])
end
# Active-only: when the only matches are already deleted, this returns nil
# so the caller no-ops instead of re-deleting and bumping the conversation
# for an echoed Chatwoot-originated removal.
matches.where.not(content: '')
.where("COALESCE(#{json_path}->>'deleted', 'false') != 'true'")
.reorder(created_at: :desc)
.first
end
def build_message_content_attributes
type = message_type
msg = unwrap_ephemeral_message(@raw_message[:message])

View File

@ -172,8 +172,11 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
end
def ignore_message?
message_type.in?(%w[protocol context edited]) ||
(message_type == 'reaction' && message_content.blank?)
message_type.in?(%w[protocol context edited])
end
def reaction_removal?
message_type == 'reaction' && message_content.blank?
end
def fetch_profile_picture_url(phone_number)

View File

@ -23,7 +23,7 @@ class Whatsapp::IncomingMessageBaseService # rubocop:disable Metrics/ClassLength
private
def process_messages
def process_messages # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize,Metrics/MethodLength
@lock_acquired = false
# We don't support ephemeral message now, we need to skip processing the message
@ -44,10 +44,27 @@ class Whatsapp::IncomingMessageBaseService # rubocop:disable Metrics/ClassLength
with_contact_lock(contact_phone_for_lock) do
# Re-check after acquiring lock to handle race conditions where an outgoing message
# was sent from Chatwoot and the webhook arrived before source_id was saved
return if find_message_by_source_id(messages_data.first[:id])
next if find_message_by_source_id(messages_data.first[:id])
# Reaction removals don't persist anything new, so peek for an existing
# reaction row before set_contact: a removal webhook for a sender we
# never stored has nothing to mark and shouldn't auto-create a contact
# just to no-op. The match is sender-agnostic on purpose; the precise
# filter happens inside `mark_existing_reaction_as_removed`.
process_in_reply_to(messages_data.first)
next if reaction_removal? && !existing_reaction_row?
set_contact
return unless contact_processable?
next if @contact.blank?
# Reactions don't create a new Message row, so handle them outside the
# transaction to avoid set_conversation opening/creating a stray thread
# for a blank webhook. We also intentionally run this BEFORE
# contact_processable? so blocked contacts can still reconcile an
# existing reaction row.
next mark_existing_reaction_as_removed if reaction_removal?
next unless contact_processable?
ActiveRecord::Base.transaction do
set_conversation
@ -61,7 +78,7 @@ class Whatsapp::IncomingMessageBaseService # rubocop:disable Metrics/ClassLength
end
def skip_message?
unprocessable_message_type?(message_type) || reaction_removal?
unprocessable_message_type?(message_type)
end
# For regular messages the contact phone is in :from; for echoes it's in :to.
@ -96,11 +113,73 @@ class Whatsapp::IncomingMessageBaseService # rubocop:disable Metrics/ClassLength
message = messages_data.first
log_error(message) && return if error_webhook_event?(message)
process_in_reply_to(message)
message_type == 'contacts' ? create_contact_messages(message) : create_regular_message(message)
end
# Cloud delivers a reaction removal as a webhook with empty emoji. Our schema
# keeps a single Message row per (target, sender) with `deleted` toggled on it,
# so we update that row in place.
#
# Two paths converge here:
# - Incoming: contact removed their reaction; mark the contact-owned row.
# - Outgoing echo (multi-device, agent un-reacted from the connected phone):
# mark the senderless outgoing row. The Chatwoot-originated removal echo
# also lands here, but the active-only filter drops it (the controller
# already toggled the row to deleted) so it no-ops harmlessly.
#
# Lookup is intentionally NOT scoped to `@conversation`: the reaction may live
# in an older/resolved thread, while `set_conversation` could have just picked
# (or created) a different one for this webhook. Find the row globally, then
# operate on its real `existing.conversation`.
# Sender-agnostic existence check used to skip set_contact for removal
# webhooks that have nothing to act on. Mirrors the inbox/in_reply_to scope
# of `mark_existing_reaction_as_removed`.
def existing_reaction_row?
return false if @in_reply_to_external_id.blank?
json_path = "(content_attributes#>>'{}')::jsonb"
Message.where(inbox_id: inbox.id)
.where("#{json_path}->>'is_reaction' = 'true'")
.exists?(["#{json_path}->>'in_reply_to_external_id' = ?", @in_reply_to_external_id])
end
def mark_existing_reaction_as_removed # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
return if @in_reply_to_external_id.blank?
json_path = "(content_attributes#>>'{}')::jsonb"
# Scope by inbox so a colliding WhatsApp id from another inbox can't match
# here and hand us back the wrong row.
base = Message.where(inbox_id: inbox.id)
.where("#{json_path}->>'is_reaction' = 'true'")
.where("#{json_path}->>'in_reply_to_external_id' = ?", @in_reply_to_external_id)
matches = if outgoing_echo
# Multi-device: agent reacted via the connected phone, so the
# local row has no agent (sender_id IS NULL) and is outgoing.
base.where(sender_id: nil, sender_type: nil)
.where(message_type: Message.message_types[:outgoing])
else
base.where(sender: @contact)
end
# Active-only: when the only matches are already deleted, return nil so
# the caller no-ops instead of re-deleting and bumping the conversation
# for an echoed Chatwoot-originated removal.
existing = matches.where.not(content: '')
.where("COALESCE(#{json_path}->>'deleted', 'false') != 'true'")
.reorder(created_at: :desc)
.first
return if existing.nil?
new_attrs = existing.content_attributes.merge('deleted' => true)
existing.update!(content: '', content_attributes: new_attrs)
target_conversation = existing.conversation
# Refresh the chat list snapshot; cable MESSAGE_UPDATED only touches
# chat.messages on the client, so the conversation card preview stays stale
# without an explicit conversation.updated dispatch. Touch updated_at so
# the frontend out-of-order guard can drop stale cables.
target_conversation.update_columns(updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
target_conversation.dispatch_conversation_updated_event
end
def create_contact_messages(message)
message['contacts'].each do |contact|
# Pass source_id from parent message since contact objects don't have :id

View File

@ -583,7 +583,12 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
body: {
jid: remote_jid,
messageContent: @message_content,
chatwootMessageId: @message.id
# baileys-api uses this as an idempotency key. Reactions UPDATE a single
# Message row in place across toggle/replace/remove cycles, so reusing
# only `id` would make every follow-up send hit the cached response and
# never reach WhatsApp. Suffixing with updated_at gives each send a fresh
# key while still letting Sidekiq retries of the same attempt dedupe.
chatwootMessageId: "#{@message.id}:#{@message.updated_at.to_f}"
}.to_json,
timeout: 120
)

View File

@ -3,7 +3,7 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
private
def process_received_callback # rubocop:disable Metrics/MethodLength
def process_received_callback # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity
@raw_message = processed_params
@message = nil
@contact_inbox = nil
@ -27,6 +27,10 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
return
end
# Reaction removals don't produce a new Message row — handle them before
# set_conversation so a blank webhook can't open/create a stray thread.
next mark_existing_reaction_as_removed if reaction_removal?
set_conversation
handle_create_message
end
@ -129,6 +133,62 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
end
end
def reaction_removal?
message_type == 'reaction' && message_content.blank?
end
# Z-API delivers a reaction removal as a webhook with empty value. Our schema
# keeps a single Message row per (target, sender) toggling `deleted` on it,
# so we update that row in place.
#
# `fromMe` removals can come from two paths and we want both handled:
# - Chatwoot-originated echo: the controller already toggled the row to
# deleted, so the active-first lookup finds nothing and this no-ops.
# - Multi-device removal (agent un-reacts from the connected phone): the row
# is still active and stored sender-less outgoing, so we mark it deleted.
# Lookup is intentionally NOT scoped to `@conversation`: the reaction may
# live in an older/resolved thread, while `set_conversation` could have
# picked (or created) a different one for this webhook. Find the row first,
# then operate on its real `existing.conversation`.
def mark_existing_reaction_as_removed # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
target_external_id = @raw_message.dig(:reaction, :referencedMessage, :messageId)
return if target_external_id.blank?
json_path = "(content_attributes#>>'{}')::jsonb"
# Scope by inbox: the senderless outgoing branch would otherwise match any
# reaction with the same provider message id, and two inboxes that ever
# receive colliding WhatsApp ids would step on each other's rows.
base = Message.where(inbox_id: inbox.id)
.where("#{json_path}->>'is_reaction' = 'true'")
.where("#{json_path}->>'in_reply_to_external_id' = ?", target_external_id)
matches = if incoming_message?
base.where(sender: @contact)
else
# Multi-device: agent reacted via the connected phone, so the
# local row has no agent (sender_id IS NULL) and is outgoing.
base.where(sender_id: nil, sender_type: nil)
.where(message_type: Message.message_types[:outgoing])
end
# Active-only: when the only matches are already deleted, return nil so
# the caller no-ops instead of re-deleting and bumping the conversation
# for an echoed Chatwoot-originated removal.
existing = matches.where.not(content: '')
.where("COALESCE(#{json_path}->>'deleted', 'false') != 'true'")
.reorder(created_at: :desc)
.first
return if existing.nil?
new_attrs = existing.content_attributes.merge('deleted' => true)
existing.update!(content: '', content_attributes: new_attrs)
target_conversation = existing.conversation
# Refresh the chat list snapshot; cable MESSAGE_UPDATED only touches
# chat.messages on the client, so the conversation card preview stays stale
# without an explicit conversation.updated dispatch. Touch updated_at so
# the frontend out-of-order guard can drop stale cables.
target_conversation.update_columns(updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
target_conversation.dispatch_conversation_updated_event
end
def create_contact_message
contact_data = @raw_message[:contact]
phones = contact_data[:phones] || []

View File

@ -27,14 +27,13 @@ json.meta do
end
json.id conversation.display_id
if conversation.messages.where(account_id: conversation.account_id).last.blank?
json.messages []
else
json.messages [
conversation.messages.where(account_id: conversation.account_id)
.includes([{ attachments: [{ file_attachment: [:blob] }] }]).last.try(:push_event_data)
]
end
last_card_message = conversation.messages.where(account_id: conversation.account_id)
.chat
.hide_removed_reactions
.includes([{ attachments: [{ file_attachment: [:blob] }] }])
.reorder(created_at: :desc)
.first
json.messages last_card_message ? [last_card_message.push_event_data] : []
json.account_id conversation.account_id
json.uuid conversation.uuid
@ -54,7 +53,32 @@ json.updated_at conversation.updated_at.to_f
json.timestamp conversation.last_activity_at.to_i
json.first_reply_created_at conversation.first_reply_created_at.to_i
json.unread_count conversation.unread_incoming_messages.count
json.last_non_activity_message conversation.messages.where(account_id: conversation.account_id).non_activity_messages.first.try(:push_event_data)
last_non_activity = conversation.messages
.where(account_id: conversation.account_id)
.non_activity_messages
.hide_removed_reactions
.reorder(created_at: :desc)
.first
if last_non_activity
json.last_non_activity_message do
json.merge! last_non_activity.push_event_data
if last_non_activity.reaction?
target_id = last_non_activity.content_attributes['in_reply_to']
target = target_id.present? ? conversation.messages.find_by(id: target_id) : nil
# strip_tags so the preview of an HTML/email target doesn't render as
# literal "<p>..." markup in the chat list card. Wrap with `String.new`
# because `strip_tags` returns `ActiveSupport::SafeBuffer`, which
# Sidekiq's strict-args check rejects when this hash flows into a cable
# broadcast job (event_data_presenter.rb shares the same pattern).
if target&.content.present?
plain_snippet = String.new(ActionController::Base.helpers.strip_tags(target.content))
json.in_reply_to_snippet plain_snippet.truncate(60)
end
end
end
else
json.last_non_activity_message nil
end
json.last_activity_at conversation.last_activity_at.to_i
json.group_type conversation.group_type
json.priority conversation.priority

View File

@ -142,6 +142,9 @@ Rails.application.routes.draw do
patch :edit_content
end
resources :attachments, only: [:update]
scope module: :messages do
resource :reactions, only: [:create]
end
end
resources :scheduled_messages, only: [:index, :create, :update, :destroy]
resources :recurring_scheduled_messages, only: [:index, :create, :update, :destroy]

View File

@ -5,6 +5,7 @@ module Enterprise::Message
return unless captain_pending_conversation?
return unless human_response?
return if private?
return if reaction?
return if template_bootstrap_message?
previous_user = Current.user

View File

@ -20,6 +20,19 @@ def connect_imap(channel, folder)
imap
end
# Logout can raise Net::IMAP::Error when the server has already closed the
# connection (eg. a transient network blip mid-scan). Mirror the
# `terminate_imap_connection` pattern used by the IMAP fetch service: log and
# fall back to a plain disconnect so ensure blocks never fail the whole task.
def safe_close_imap(imap)
return if imap.nil?
imap.logout
rescue Net::IMAP::Error => e
warn " [IMAP] logout failed: #{e.message}; disconnecting"
imap.disconnect
end
def format_duration(seconds)
seconds = seconds.to_i
if seconds < 60
@ -131,7 +144,7 @@ def import_worker(channel, folder, uid_batch, progress) # rubocop:disable Metric
warn "\n [ERROR] uid #{uid}: #{e.message}"
end
ensure
imap&.logout
safe_close_imap(imap)
end
end
rescue StandardError => e
@ -182,14 +195,17 @@ namespace :imap do # rubocop:disable Metrics/BlockLength
# Phase 1: scan headers with a single connection to find new emails
imap = connect_imap(channel, folder)
since_date = (Time.zone.today - days).strftime('%d-%b-%Y')
begin
since_date = (Time.zone.today - days).strftime('%d-%b-%Y')
puts "Searching emails since #{since_date}..."
uids = imap.uid_search(['SINCE', since_date])
puts "Found #{uids.length} emails in #{folder}."
puts "Searching emails since #{since_date}..."
uids = imap.uid_search(['SINCE', since_date])
puts "Found #{uids.length} emails in #{folder}."
new_uids, skipped = scan_new_email_uids(imap, channel, uids)
imap.logout
new_uids, skipped = scan_new_email_uids(imap, channel, uids)
ensure
safe_close_imap(imap)
end
if new_uids.empty?
puts 'Nothing new to import.'
@ -223,4 +239,46 @@ namespace :imap do # rubocop:disable Metrics/BlockLength
puts " Time: #{format_duration(elapsed)}"
puts '=' * 60
end
desc 'Dry-run: count how many emails imap:import would import (no changes)'
task :scan, %i[inbox_id days folder] => :environment do |_task, args| # rubocop:disable Metrics/BlockLength
inbox_id = args[:inbox_id]
days = (args[:days] || 7).to_i
folder = args[:folder] || 'INBOX'
if inbox_id.blank?
puts 'Usage: rails imap:scan[<inbox_id>,<days>,<folder>]'
puts ' days: how far back to look (default: 7)'
puts ' folder: IMAP folder (default: INBOX)'
next
end
inbox, channel = validate_inbox(inbox_id)
puts "Inbox: #{inbox.name} (ID: #{inbox.id})"
puts "Email: #{channel.email}"
puts "Folder: #{folder}"
puts "Lookback: #{days} days"
puts '-' * 60
imap = connect_imap(channel, folder)
begin
since_date = (Time.zone.today - days).strftime('%d-%b-%Y')
puts "Searching emails since #{since_date}..."
uids = imap.uid_search(['SINCE', since_date])
puts "Found #{uids.length} emails in #{folder}."
new_uids, skipped = scan_new_email_uids(imap, channel, uids)
ensure
safe_close_imap(imap)
end
puts ''
puts '=' * 60
puts 'Scan complete (no changes made).'
puts " Would import: #{new_uids.size}"
puts " Would skip: #{skipped} (already present)"
puts '=' * 60
end
end

View File

@ -0,0 +1,284 @@
require 'rails_helper'
RSpec.describe 'Conversation Message Reactions API', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:other_agent) { create(:user, account: account, role: :agent) }
let(:channel) { create(:channel_whatsapp, account: account, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
let(:inbox) { channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let!(:target_message) { create(:message, account: account, conversation: conversation, content: 'Hi', source_id: 'wamid.target') }
let(:reactions_url) do
"/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/messages/#{target_message.id}/reactions"
end
before do
create(:inbox_member, inbox: inbox, user: agent)
create(:inbox_member, inbox: inbox, user: other_agent)
# Provider would be invoked by SendReplyJob; stub the actual send.
allow_any_instance_of(Whatsapp::Providers::WhatsappBaileysService).to receive(:send_message).and_return('msg_id') # rubocop:disable RSpec/AnyInstance
end
describe 'POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/reactions' do
context 'when the request is unauthenticated' do
it 'returns unauthorized' do
post reactions_url, params: { emoji: '👍' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when the channel does not support reactions' do
let(:channel) { create(:channel_whatsapp, account: account, provider: 'default', validate_provider_config: false, sync_templates: false) }
it 'rejects the request with unprocessable_entity' do
post reactions_url, params: { emoji: '👍' }, headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to match(/not supported/i)
end
end
context 'when the target message is private' do
let(:target_message) { create(:message, account: account, conversation: conversation, content: 'Note', private: true) }
it 'rejects the request' do
post reactions_url, params: { emoji: '👍' }, headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to match(/private/i)
end
end
context 'when the target message is itself a reaction' do
let(:target_message) do
create(:message,
account: account,
conversation: conversation,
content: '👍',
content_attributes: { is_reaction: true })
end
it 'rejects the request' do
post reactions_url, params: { emoji: '🔥' }, headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to match(/reaction/i)
end
end
context 'when the emoji exceeds 32 bytes' do
it 'rejects the request' do
post reactions_url, params: { emoji: 'a' * 64 }, headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to match(/Invalid emoji/i)
end
end
context 'when no current-user reaction exists' do
it 'creates a new reaction Message via MessageBuilder' do
expect do
post reactions_url, params: { emoji: '👍' }, headers: agent.create_new_auth_token, as: :json
end.to change(conversation.messages, :count).by(1)
expect(response).to have_http_status(:ok)
created = conversation.messages.last
expect(created.content).to eq('👍')
expect(created.content_attributes['is_reaction']).to be true
expect(created.content_attributes['in_reply_to']).to eq(target_message.id)
expect(created.sender).to eq(agent)
end
it 'rejects an empty emoji when there is nothing to remove' do
post reactions_url, params: { emoji: '' }, headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to match(/empty/i)
end
end
context 'when the current user already has an active reaction' do
let!(:existing) do
create(:message,
account: account,
conversation: conversation,
sender: agent,
message_type: :outgoing,
content: '👍',
source_id: 'wa_existing_id',
content_attributes: { is_reaction: true, in_reply_to: target_message.id })
end
it 'toggles off when the same emoji is sent (mutates row, sets deleted, clears source_id)' do
post reactions_url, params: { emoji: '👍' }, headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:ok)
existing.reload
expect(existing.content).to eq('')
expect(existing.content_attributes['deleted']).to be true
expect(existing.source_id).to be_nil
end
it 'toggles off when an empty emoji is sent' do
post reactions_url, params: { emoji: '' }, headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:ok)
existing.reload
expect(existing.content).to eq('')
expect(existing.content_attributes['deleted']).to be true
end
it 'replaces in place when a different emoji is sent (does not create a new row)' do
expect do
post reactions_url, params: { emoji: '❤️' }, headers: agent.create_new_auth_token, as: :json
end.not_to change(conversation.messages, :count)
existing.reload
expect(existing.content).to eq('❤️')
expect(existing.content_attributes['deleted']).to be_nil.or(be(false))
expect(existing.source_id).to be_nil
end
it 'enqueues SendReplyJob with the existing message id when toggling off' do
expect do
post reactions_url, params: { emoji: '👍' }, headers: agent.create_new_auth_token, as: :json
end.to have_enqueued_job(SendReplyJob).with(existing.id)
end
end
context 'when the current user previously removed their reaction' do
let!(:existing) do
create(:message,
account: account,
conversation: conversation,
sender: agent,
message_type: :outgoing,
content: '',
content_attributes: { is_reaction: true, in_reply_to: target_message.id, deleted: true })
end
it 'resurrects the row instead of creating a new one' do
expect do
post reactions_url, params: { emoji: '🔥' }, headers: agent.create_new_auth_token, as: :json
end.not_to change(conversation.messages, :count)
existing.reload
expect(existing.content).to eq('🔥')
expect(existing.content_attributes).not_to have_key('deleted')
end
end
context 'when only a reaction from another agent exists' do
let!(:other_agent_reaction) do
create(:message,
account: account,
conversation: conversation,
sender: other_agent,
message_type: :outgoing,
content: '👍',
content_attributes: { is_reaction: true, in_reply_to: target_message.id })
end
it 'creates a separate reaction Message scoped to the current user' do
expect do
post reactions_url, params: { emoji: '🎉' }, headers: agent.create_new_auth_token, as: :json
end.to change(conversation.messages, :count).by(1)
expect(other_agent_reaction.reload.content).to eq('👍')
new_reaction = conversation.messages.where(sender: agent).last
expect(new_reaction.content).to eq('🎉')
end
end
context 'when only a multi-device echo (outgoing without agent) exists' do
let!(:multi_device_reaction) do
create(:message, :bot_message,
account: account,
conversation: conversation,
content: '👍',
source_id: 'wa_mobile_id',
content_attributes: { is_reaction: true, in_reply_to: target_message.id })
end
it 'mutates the multi-device echo when the same emoji is sent (toggle off)' do
expect do
post reactions_url, params: { emoji: '👍' }, headers: agent.create_new_auth_token, as: :json
end.not_to change(conversation.messages, :count)
multi_device_reaction.reload
expect(multi_device_reaction.content).to eq('')
expect(multi_device_reaction.content_attributes['deleted']).to be true
expect(multi_device_reaction.source_id).to be_nil
end
it 'mutates the multi-device echo when a different emoji is sent (replace)' do
expect do
post reactions_url, params: { emoji: '❤️' }, headers: agent.create_new_auth_token, as: :json
end.not_to change(conversation.messages, :count)
multi_device_reaction.reload
expect(multi_device_reaction.content).to eq('❤️')
end
it 'enqueues SendReplyJob to propagate the change to WhatsApp' do
expect do
post reactions_url, params: { emoji: '👍' }, headers: agent.create_new_auth_token, as: :json
end.to have_enqueued_job(SendReplyJob).with(multi_device_reaction.id)
end
end
context 'when a current-user reaction and a multi-device echo both exist' do
let!(:multi_device_reaction) do
create(:message, :bot_message,
account: account,
conversation: conversation,
content: '👍',
created_at: 2.minutes.ago,
content_attributes: { is_reaction: true, in_reply_to: target_message.id })
end
let!(:user_reaction) do
create(:message,
account: account,
conversation: conversation,
sender: agent,
message_type: :outgoing,
content: '🔥',
created_at: 1.minute.ago,
content_attributes: { is_reaction: true, in_reply_to: target_message.id })
end
it 'prefers the most recent qualifying reaction (current user, not multi-device)' do
post reactions_url, params: { emoji: '🔥' }, headers: agent.create_new_auth_token, as: :json
user_reaction.reload
expect(user_reaction.content).to eq('')
expect(user_reaction.content_attributes['deleted']).to be true
expect(multi_device_reaction.reload.content).to eq('👍')
end
end
context 'with side effects on the conversation snapshot' do
it 'touches the conversation updated_at so the chat list re-fetches the snapshot' do
original_updated_at = conversation.updated_at
travel(1.minute) do
post reactions_url, params: { emoji: '👍' }, headers: agent.create_new_auth_token, as: :json
end
expect(conversation.reload.updated_at).to be > original_updated_at
end
it 'dispatches conversation.updated so cable subscribers refresh last_non_activity_message' do
dispatch_calls = []
allow_any_instance_of(Conversation).to receive(:dispatch_conversation_updated_event) do |conv| # rubocop:disable RSpec/AnyInstance
dispatch_calls << conv.id
end
post reactions_url, params: { emoji: '👍' }, headers: agent.create_new_auth_token, as: :json
expect(dispatch_calls).to include(conversation.id)
end
end
end
end

View File

@ -74,4 +74,63 @@ describe MessageFinder do
end
end
end
describe 'page_window with reactions' do
# Isolated setup: skip the shared `before` block's fixtures so count assertions stay stable.
subject(:message_finder) { described_class.new(fresh_conversation, {}) }
let!(:fresh_conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
it 'does not let reactions consume the 20-item page limit' do
# 22 non-reaction messages plus a handful of reactions interleaved.
# Without the non-reaction-based pick, the trailing reactions could push
# real messages off the page.
regular_messages = []
22.times do |i|
regular_messages << create(:message, conversation: fresh_conversation, content: "msg #{i}")
end
5.times do |i|
create(:message,
conversation: fresh_conversation,
content: '👍',
content_attributes: { is_reaction: true, in_reply_to_external_id: "ext_#{i}" })
end
result = message_finder.perform
non_reactions = result.reject { |m| m.content_attributes['is_reaction'] }
# The latest non-reactions must be present even though 5 reactions came
# after them — the page is anchored on non-reactions, not raw position.
expect(non_reactions).to include(regular_messages.last)
# And the page yields at least 20 non-reactions (anchor is the 20th
# newest non-reaction, plus any newer ones). No early truncation.
expect(non_reactions.size).to be >= 20
end
it 'includes reactions whose parent message is inside the visible window' do
msg = create(:message, conversation: fresh_conversation, content: 'Hi', source_id: 'wamid.parent')
attached_reaction = create(:message,
conversation: fresh_conversation,
content: '🔥',
content_attributes: { is_reaction: true, in_reply_to_external_id: 'wamid.parent' })
orphan_reaction = create(:message,
conversation: fresh_conversation,
content: '👍',
content_attributes: { is_reaction: true, in_reply_to_external_id: 'wamid.older.not.in.window' })
result = message_finder.perform
expect(result).to include(msg, attached_reaction)
expect(result).not_to include(orphan_reaction)
end
it 'returns an empty scope when no non-reaction messages exist' do
create(:message,
conversation: fresh_conversation,
content: '👍',
content_attributes: { is_reaction: true, in_reply_to_external_id: 'ext_orphan' })
expect(message_finder.perform).to be_empty
end
end
end

View File

@ -813,4 +813,26 @@ RSpec.describe Channel::Whatsapp do
expect(channel.sync_group(conversation)).to be_nil
end
end
describe '#supports_reactions?' do
it 'returns true for whatsapp_cloud provider' do
channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
expect(channel.supports_reactions?).to be(true)
end
it 'returns true for baileys provider' do
channel = create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false)
expect(channel.supports_reactions?).to be(true)
end
it 'returns true for zapi provider' do
channel = create(:channel_whatsapp, provider: 'zapi', validate_provider_config: false, sync_templates: false)
expect(channel.supports_reactions?).to be(true)
end
it 'returns false for default (360dialog) provider' do
channel = create(:channel_whatsapp, provider: 'default', validate_provider_config: false, sync_templates: false)
expect(channel.supports_reactions?).to be(false)
end
end
end

View File

@ -613,6 +613,7 @@ RSpec.describe Conversation do
},
id: conversation.display_id,
messages: [],
last_non_activity_message: nil,
labels: [],
last_activity_at: conversation.last_activity_at.to_i,
inbox_id: conversation.inbox_id,

View File

@ -920,4 +920,87 @@ RSpec.describe Message do
end
end
end
describe '#reaction?' do
let(:conversation) { create(:conversation) }
it 'returns true when content_attributes carries is_reaction' do
message = create(:message, conversation: conversation, content_attributes: { is_reaction: true })
expect(message.reaction?).to be true
end
it 'returns false for regular messages' do
message = create(:message, conversation: conversation, content_attributes: {})
expect(message.reaction?).to be false
end
end
describe '.hide_removed_reactions' do
let(:conversation) { create(:conversation) }
it 'keeps regular non-reaction messages' do
regular = create(:message, conversation: conversation, content: 'Hello')
expect(conversation.messages.hide_removed_reactions).to include(regular)
end
it 'keeps active reactions (content present, not deleted)' do
reaction = create(:message,
conversation: conversation,
content: '👍',
content_attributes: { is_reaction: true, in_reply_to_external_id: 'EXT' })
expect(conversation.messages.hide_removed_reactions).to include(reaction)
end
it 'hides reactions flagged as deleted' do
# Non-blank content here so the assertion can only succeed via the
# `deleted: true` branch (blank-content branch is covered separately).
removed = create(:message,
conversation: conversation,
content: '👍',
content_attributes: { is_reaction: true, deleted: true })
expect(conversation.messages.hide_removed_reactions).not_to include(removed)
end
it 'hides reactions with blank content even when not flagged as deleted' do
blank_reaction = create(:message,
conversation: conversation,
content: '',
content_attributes: { is_reaction: true })
expect(conversation.messages.hide_removed_reactions).not_to include(blank_reaction)
end
end
describe 'reactions do not trigger conversation lifecycle hooks' do
let(:conversation) { create(:conversation) }
it 'does not reopen a resolved conversation' do
conversation.resolved!
create(:message,
conversation: conversation,
message_type: :incoming,
content: '👍',
content_attributes: { is_reaction: true, in_reply_to_external_id: 'EXT' })
expect(conversation.reload.open?).to be false
end
it 'does not flip a pending conversation to open' do
pending_conv = create(:conversation, status: :pending)
create(:message,
conversation: pending_conv,
message_type: :incoming,
content: '👍',
content_attributes: { is_reaction: true, in_reply_to_external_id: 'EXT' })
expect(pending_conv.reload.pending?).to be true
end
it 'does not count toward first_reply_created_at' do
expect(conversation.first_reply_created_at).to be_nil
create(:message,
conversation: conversation,
message_type: :outgoing,
content: '👍',
content_attributes: { is_reaction: true, in_reply_to: 1 })
expect(conversation.reload.first_reply_created_at).to be_nil
end
end
end

View File

@ -69,4 +69,65 @@ RSpec.describe Conversations::EventDataPresenter do
expect(presenter.webhook_data[:messages]).to eq([])
end
end
describe '#push_data last_non_activity_message' do
it 'is nil when the conversation has no non-activity messages' do
expect(presenter.push_data[:last_non_activity_message]).to be_nil
end
it 'returns the last regular non-activity message' do
message = create(:message, conversation: conversation, account: conversation.account,
message_type: :outgoing, content: 'Hello there')
data = presenter.push_data[:last_non_activity_message]
expect(data[:id]).to eq(message.id)
expect(data[:content]).to eq('Hello there')
end
it 'skips reactions whose user-facing state is removed' do
regular = create(:message, conversation: conversation, account: conversation.account,
message_type: :outgoing, content: 'A real message')
# A more recent reaction that has been toggled off should not become the
# snapshot — otherwise the chat list preview shows a "ghost" reaction.
create(:message, conversation: conversation, account: conversation.account,
message_type: :incoming, content: '',
content_attributes: { is_reaction: true, deleted: true,
in_reply_to_external_id: 'ext_999' })
data = presenter.push_data[:last_non_activity_message]
expect(data[:id]).to eq(regular.id)
end
it 'enriches reactions with in_reply_to_snippet from the targeted message' do
target = create(:message, conversation: conversation, account: conversation.account,
message_type: :incoming, content: 'Original message body that we expect to see in the snippet')
reaction = create(:message, conversation: conversation, account: conversation.account,
message_type: :incoming, content: '👍',
content_attributes: { is_reaction: true, in_reply_to: target.id })
data = presenter.push_data[:last_non_activity_message]
expect(data[:id]).to eq(reaction.id)
expect(data[:in_reply_to_snippet]).to start_with('Original message body')
end
it 'returns in_reply_to_snippet as a plain String, not SafeBuffer (regression)' do
# `strip_tags` returns ActiveSupport::SafeBuffer, which Sidekiq's
# strict-args check rejects when this hash flows into
# ActionCableBroadcastJob.perform_later. The whole reactions controller
# request 500s (and the UI shows a misleading toast) if this regresses.
target = create(:message, conversation: conversation, account: conversation.account,
message_type: :incoming, content: '<p>HTML <strong>body</strong></p>')
create(:message, conversation: conversation, account: conversation.account,
message_type: :incoming, content: '👍',
content_attributes: { is_reaction: true, in_reply_to: target.id })
snippet = presenter.push_data[:last_non_activity_message][:in_reply_to_snippet]
expect(snippet.class).to eq(String)
expect(snippet).not_to include('<')
end
end
end

View File

@ -588,6 +588,79 @@ describe Whatsapp::IncomingMessageBaileysService do
expect(message.conversation.messages.count).to eq(1)
end
it 'marks an existing incoming reaction as removed when webhook arrives with empty text' do
existing_reaction = create(:message,
conversation: message.conversation,
sender: message.conversation.contact_inbox.contact,
message_type: :incoming,
content: '👍',
content_attributes: { is_reaction: true, in_reply_to_external_id: message.source_id })
raw_message[:key][:id] = 'reaction_removal_456'
raw_message[:message] = {
reactionMessage: {
key: { remoteJid: '12345678@lid', fromMe: true, id: 'msg_123' },
text: ''
}
}
expect do
described_class.new(inbox: inbox, params: params).perform
end.not_to(change { message.conversation.messages.count })
existing_reaction.reload
expect(existing_reaction.content).to eq('')
expect(existing_reaction.content_attributes['deleted']).to be true
end
it 'dispatches conversation.updated after marking a reaction as removed' do
create(:message,
conversation: message.conversation,
sender: message.conversation.contact_inbox.contact,
message_type: :incoming,
content: '👍',
content_attributes: { is_reaction: true, in_reply_to_external_id: message.source_id })
dispatched = []
allow_any_instance_of(Conversation).to receive(:dispatch_conversation_updated_event) do |conv| # rubocop:disable RSpec/AnyInstance
dispatched << conv.id
end
raw_message[:key][:id] = 'reaction_removal_789'
raw_message[:message] = {
reactionMessage: {
key: { remoteJid: '12345678@lid', fromMe: true, id: 'msg_123' },
text: ''
}
}
described_class.new(inbox: inbox, params: params).perform
expect(dispatched).to include(message.conversation.id)
end
it 'skips reaction removal for outgoing echoes so the local controller update is not clobbered' do
# Mirror the post-controller state: the Chatwoot reactions controller
# already toggled the senderless outgoing row to deleted, so the
# echoed fromMe webhook should hit the active-only filter and no-op.
existing_reaction = create(:message,
conversation: message.conversation,
sender: nil,
message_type: :outgoing,
content: '',
content_attributes: { is_reaction: true, in_reply_to_external_id: message.source_id, deleted: true })
raw_message[:key][:id] = 'outgoing_echo_removal'
raw_message[:key][:fromMe] = true
raw_message[:message] = {
reactionMessage: {
key: { remoteJid: '12345678@lid', fromMe: true, id: 'msg_123' },
text: ''
}
}
described_class.new(inbox: inbox, params: params).perform
existing_reaction.reload
expect(existing_reaction.content).to eq('')
expect(existing_reaction.content_attributes['deleted']).to be(true)
end
end
context 'when message type is image' do

View File

@ -332,6 +332,49 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
described_class.new(inbox: whatsapp_channel.inbox, params: reaction_removal_params).perform
end.not_to(change { whatsapp_channel.inbox.messages.count })
end
it 'marks a matching existing reaction as removed in place' do
contact = create(:contact, phone_number: '+553499503261', account: whatsapp_channel.account)
contact_inbox = create(:contact_inbox, contact: contact, inbox: whatsapp_channel.inbox, source_id: '553499503261')
conversation = create(:conversation, contact: contact, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
create(:message, conversation: conversation, source_id: 'wamid.ORIGINAL_MESSAGE_ID', content: 'Original message')
existing_reaction = create(:message,
conversation: conversation,
sender: contact,
message_type: :incoming,
content: '❤️',
content_attributes: { is_reaction: true,
in_reply_to_external_id: 'wamid.ORIGINAL_MESSAGE_ID' })
expect do
described_class.new(inbox: whatsapp_channel.inbox, params: reaction_removal_params).perform
end.not_to(change { whatsapp_channel.inbox.messages.count })
existing_reaction.reload
expect(existing_reaction.content).to eq('')
expect(existing_reaction.content_attributes['deleted']).to be true
end
it 'dispatches conversation.updated after marking a reaction as removed' do
contact = create(:contact, phone_number: '+553499503261', account: whatsapp_channel.account)
contact_inbox = create(:contact_inbox, contact: contact, inbox: whatsapp_channel.inbox, source_id: '553499503261')
conversation = create(:conversation, contact: contact, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
create(:message, conversation: conversation, source_id: 'wamid.ORIGINAL_MESSAGE_ID', content: 'Original message')
create(:message,
conversation: conversation,
sender: contact,
message_type: :incoming,
content: '❤️',
content_attributes: { is_reaction: true, in_reply_to_external_id: 'wamid.ORIGINAL_MESSAGE_ID' })
dispatched = []
allow_any_instance_of(Conversation).to receive(:dispatch_conversation_updated_event) do |conv| # rubocop:disable RSpec/AnyInstance
dispatched << conv.id
end
described_class.new(inbox: whatsapp_channel.inbox, params: reaction_removal_params).perform
expect(dispatched).to include(conversation.id)
end
end
end
end

View File

@ -50,5 +50,81 @@ describe Whatsapp::IncomingMessageZapiService do
payload: params
)
end
context 'when a reaction removal webhook arrives' do
let(:contact) { create(:contact, account: inbox.account, phone_number: '+5511912345678', identifier: '12345678') }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345678') }
let(:conversation) { create(:conversation, inbox: inbox, contact_inbox: contact_inbox, contact: contact) }
# Must exist so `ensure_in_reply_to` on the reaction Message we build below
# can resolve the source_id — otherwise the before_save hook blanks
# `in_reply_to_external_id` and the removal lookup can't find the reaction.
let!(:target) { create(:message, conversation: conversation, source_id: 'target_msg_id', content: 'Hi') } # rubocop:disable RSpec/LetSetup
let(:removal_params) do
{
type: 'ReceivedCallback',
phone: '5511912345678',
chatLid: '12345678',
fromMe: false,
messageId: 'reaction_removal_id',
momment: (Time.current.to_i * 1000),
senderName: 'John Doe',
reaction: {
value: '',
referencedMessage: { messageId: 'target_msg_id' }
}
}
end
it 'marks an existing reaction as removed instead of creating a new message' do
existing_reaction = create(:message,
conversation: conversation,
sender: contact,
message_type: :incoming,
content: '❤️',
content_attributes: { is_reaction: true, in_reply_to_external_id: 'target_msg_id' })
expect do
described_class.new(inbox: inbox, params: removal_params).perform
end.not_to(change { conversation.messages.count })
existing_reaction.reload
expect(existing_reaction.content).to eq('')
expect(existing_reaction.content_attributes['deleted']).to be true
end
it 'dispatches conversation.updated after marking the reaction as removed' do
create(:message,
conversation: conversation,
sender: contact,
message_type: :incoming,
content: '❤️',
content_attributes: { is_reaction: true, in_reply_to_external_id: 'target_msg_id' })
dispatched = []
allow_any_instance_of(Conversation).to receive(:dispatch_conversation_updated_event) do |conv| # rubocop:disable RSpec/AnyInstance
dispatched << conv.id
end
described_class.new(inbox: inbox, params: removal_params).perform
expect(dispatched).to include(conversation.id)
end
it 'skips reaction removal for outgoing echoes (fromMe: true) to avoid clobbering the local update' do
# Mirror the post-controller state: the reactions controller already
# toggled the senderless outgoing row to deleted, so the echoed fromMe
# webhook should hit the active-only filter and no-op.
existing_reaction = create(:message,
conversation: conversation,
sender: nil,
message_type: :outgoing,
content: '',
content_attributes: { is_reaction: true, in_reply_to_external_id: 'target_msg_id', deleted: true })
described_class.new(inbox: inbox, params: removal_params.merge(fromMe: true, messageId: 'outgoing_echo_removal')).perform
existing_reaction.reload
expect(existing_reaction.content).to eq('')
expect(existing_reaction.content_attributes['deleted']).to be(true)
end
end
end
end

View File

@ -1825,6 +1825,6 @@ describe Whatsapp::Providers::WhatsappBaileysService do
end
def send_message_body(hash, msg = message)
hash.merge(chatwootMessageId: msg.id).to_json
hash.merge(chatwootMessageId: "#{msg.id}:#{msg.updated_at.to_f}").to_json
end
end

View File

@ -452,7 +452,7 @@ describe Whatsapp::SendOnWhatsappService do
message = create(:message, message_type: :outgoing, content: 'test', conversation: conversation, source_id: nil)
stub = stub_request(:post, send_message_url)
.with { |req| JSON.parse(req.body)['chatwootMessageId'] == message.id }
.with { |req| JSON.parse(req.body)['chatwootMessageId'].to_s.start_with?("#{message.id}:") }
.to_raise(Net::ReadTimeout.new('Net::ReadTimeout'))
.then
.to_return(status: 200, body: success_body, headers: { 'Content-Type' => 'application/json' })

View File

@ -972,16 +972,26 @@ describe Whatsapp::ZapiHandlers::ReceivedCallback do
expect(message.content_attributes[:in_reply_to_external_id]).to eq('original_123')
end
it 'creates empty reaction message' do
it 'no-ops on empty value when there is no existing reaction to remove' do
params[:reaction][:value] = ''
expect do
service.perform
end.to change(Message, :count).by(1)
end.not_to change(Message, :count)
end
message = Message.last
expect(message.content).to eq('')
expect(message.content_attributes[:is_reaction]).to be(true)
it 'marks the existing contact reaction as deleted when value is blank' do
existing = create(:message, inbox: inbox, conversation: conversation, sender: contact,
content: '👍', content_attributes: {
is_reaction: true,
in_reply_to_external_id: 'original_123'
})
params[:reaction][:value] = ''
expect { service.perform }.not_to change(Message, :count)
existing.reload
expect(existing.content).to eq('')
expect(existing.content_attributes['deleted']).to be(true)
end
end