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:
parent
5cc78c7b33
commit
72c9821270
@ -48,7 +48,7 @@
|
||||
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||
- Break down complex tasks into small, testable units
|
||||
- Iterate after confirmation
|
||||
- Avoid writing specs unless explicitly asked
|
||||
- New features must include specs covering the main flows (happy path + critical edge cases). Bugfixes should add a regression spec when the fix is non-trivial. Skip specs only for purely cosmetic changes (CSS tweaks, copy adjustments, log message edits) or when the user explicitly asks to skip.
|
||||
- Remove dead/unreachable/unused code
|
||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
@ -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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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] || []
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user