feat(internal-chat): implement internal chat system for agents (#247)
* feat(internal-chat): implement internal chat system for agents (Phase 1+2 MVP)
Add a Slack/Discord-style internal messaging system for Chatwoot agents with
text channels (public/private), direct messages, reactions, typing indicators,
and real-time updates via ActionCable.
Backend:
- 6 database migrations (categories, channels, members, messages, attachments, reactions)
- 6 models under InternalChat:: namespace with validations and associations
- API controllers for categories, channels, messages, members, and reactions
- Pundit policies for authorization (public/private/DM access control)
- MessageCreateService, TypingStatusManager, DefaultChannelSetupService
- InternalChatListener for real-time broadcasting to channel members
- Event types for internal chat events
- Default category/channel setup for new and existing accounts
Frontend:
- Vuex store modules for channels, messages, and typing status
- API clients for channels and messages
- Vue 3 components: InternalChatLayout, ChannelSidebar, ChannelView,
ChannelHeader, MessageList, MessageBubble, MessageEditor,
EmojiReactionPicker, ReactionDisplay, TypingIndicator
- Sidebar integration with "Internal Chat" menu item
- ActionCable handlers for real-time message/reaction/typing events
- Route definitions and i18n translations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(internal-chat): add comprehensive specs for models, controllers, policies, services, and listener
- 6 model specs (74 examples) covering associations, validations, scopes, methods
- 5 request specs for all API controllers (categories, channels, messages, members, reactions)
- 4 policy specs testing authorization rules for all actions
- 3 service specs (DefaultChannelSetupService, MessageCreateService, TypingStatusManager)
- 1 listener spec testing real-time broadcasting for all event types
- 6 FactoryBot factories with traits for all InternalChat models
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): fix dispatcher mock in service specs and cursor pagination test
- Allow dispatcher.dispatch in service specs to handle Account.created
callbacks from factory setup before asserting specific event dispatch
- Fix after-cursor pagination test by adding 1 second offset to avoid
timestamp precision issues with iso8601 rounding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): address CodeRabbit review — 7 critical security/correctness fixes
- Scope member creation through Current.account.users to prevent cross-account membership
- Scope member_ids in DM creation through Current.account to prevent cross-account invites
- Scope reaction message lookup through channel account to prevent cross-account access
- Fix Vuex store to commit messages array instead of response envelope
- Add UUID generation callback on Channel model (before_validation)
- Add channel access check to reaction deletion policy
- Validate parent_id belongs to same channel in MessageCreateService
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): address CodeRabbit round 2 + fix ChannelSidebar runtime error
- Re-throw error in fetchMessages instead of swallowing with empty array
- Wrap message + attachment creation in transaction for atomicity
- Fix factory to derive account from message (prevent cross-account fixtures)
- Guard listener against cross-account mismatch (not just missing records)
- Add cross-account regression tests to listener spec
- Fix ChannelSidebar computed properties to default to empty arrays
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(internal-chat): auto-setup default channels on account creation and migration
- Add after_create_commit :setup_internal_chat callback on Account model
- Add data migration to create default channels for existing accounts
- Make DefaultChannelSetupService convergent (find_or_create) instead of
bail-on-exists, so it can sync new members on subsequent runs
- Fix specs to handle default category/channel created by callback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): avoid Vuex state mutation in sort + align muted styling in fallback section
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): fix store module name mismatch — register as 'internalChat' not 'internalChatChannels'
Components dispatch to 'internalChat/get' but the module was registered
as 'internalChatChannels'. Also fix ActionCable handlers to use
'internalChat/messages/' nested module path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* i18n(internal-chat): add pt-BR translations for internal chat feature
Backend: default_category_name ('Canais') and default_channel_name ('Geral')
Frontend: all 40+ keys translated to Brazilian Portuguese
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): handle ISO 8601 timestamps in MessageBubble and MessageList
The API returns created_at as ISO strings but messageTimestamp() expects
Unix seconds and MessageList used `* 1000`. Now handles both formats.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(internal-chat): build swagger output for internal chat API endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(internal-chat): register internal chat tags and paths in swagger index
Add tag definitions and path entries for all 5 internal chat resource
groups in swagger/index.yml and swagger/paths/index.yml. Rebuild output.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* i18n(internal-chat): add SIDEBAR.INTERNAL_CHAT key to pt-BR settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): comprehensive review fixes — backend and frontend
Backend:
- Add attachments to message API responses in both controllers
- Add internal_chat_channel_updated listener handler
- Include reactions in message event broadcast data
Frontend:
- Fix ActionCable dispatch paths to use correct action names
(addMessageFromCable, updateMessageFromCable, deleteMessageFromCable)
- Connect typingUsers to internalChatTypingStatus store getter
- Fix message field references (edited → content_attributes.edited_at)
- Fix channel type comparisons (use 'private_channel'/'dm' strings)
- Add parent 'internal_chat' to sidebar activeOn array
- Increment unread_count on ActionCable message receive
- Add loadMore handler for cursor-based pagination
- Remove unused is-direct-message prop from InternalChatLayout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(internal-chat): implement phases 3-5 — threads, mentions, notifications, polls, drafts
Phase 3 — Threads, Mentions, Notifications:
- MentionService: parse @user mentions, @all (admin only), generate notifications
- NotificationService: create notifications for channel messages (respects mute)
- Add internal_chat_message/mention notification types to Notification model
- ThreadPanel.vue: slide-out panel for threaded replies
- Integrate mentions + notifications into MessageCreateService
Phase 4 — Polls:
- 3 new migrations: polls, poll_options, poll_votes tables
- 3 new models: Poll, PollOption, PollVote with validations
- PollsController: create poll, vote, unvote with routes
- PollService: voting logic with multiple choice + revote support
- PollCreator.vue: modal for creating polls with options
- PollDisplay.vue: vote UI with progress bars and results
- Polls Vuex store module
- INTERNAL_CHAT_POLL_VOTED event type
Phase 5 — Drafts:
- 1 new migration: drafts table
- Draft model with validations
- DraftsController: full CRUD (replace stub)
- DraftsList.vue: list all user drafts with navigation
- Drafts Vuex store module with auto-save
- Draft route and sidebar integration
Phase 6 — Feature Flag:
- Add INTERNAL_CHAT feature flag to features.yml and featureFlags.js
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): fix API routing for drafts and polls, add poll voting ActionCable handler
- Fix draft API client to use channel-scoped PATCH/DELETE endpoints
- Create dedicated polls API client with correct poll-based endpoints
- Update polls store to use InternalChatPollsAPI with pollId-based voting
- Add ActionCable handler for internal_chat.poll.voted events
- Add thread and drafts routes to sidebar activeOn array
- Fix drafts store to pass channelId to API calls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): fix poll response format and API client routing (review round 2)
- Return message with embedded poll data instead of raw poll response
- Add poll data to message_response in messages controller
- Create dedicated InternalChatPollsAPI client with correct endpoints
- Update PollDisplay.vue to read from message.poll or content_attributes.poll
- Use option.voted flag instead of checking voters array
- Add missing PERCENTAGE i18n key to pt-BR
- Remove unused currentUserId prop from PollDisplay
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): fix poll voting and draft lookup bugs (review round 3)
- Fix draft getter to use internal_chat_channel_id field name
- Split poll set_poll into vote/unvote variants — unvote doesn't need option_id
- Unvote finds user's vote by user_id across all poll options
- Fix ChannelView to extract pollId from message.poll before dispatching
- Fix unvote handler to not require optionId
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 3)
- Expose is_dm, favorited, muted on channel API responses
- Normalize poll cable updates into message-shaped patch
- Add file presence validation to MessageAttachment
- Remove duplicate mention notifications from MentionService
- Make data migration rollback safe (IrreversibleMigration)
- Update factory to include file by default
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: return 201 for channel creation and optimize DM lookup
- Return :created (201) instead of :ok (200) for channel creation
- Replace O(n) Ruby scan with SQL-based DM lookup using ARRAY_AGG
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 5)
- Broadcast channel event after create for real-time notifications
- Separate create/update strong params to prevent channel_type transitions
- Use strong params for typing_status input
- Replace find_by with detect on preloaded collections to fix N+1
- Preload attachments with blobs in show response
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 6)
- Serialize DM creation with advisory lock to prevent duplicates
- Broadcast channel deletion event for real-time UI updates
- Use strong params for mark_unread message_id
- Batch unread count computation to eliminate N+1 in index
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: eliminate N+1 in compute_unread_counts with single JOIN query
Replace per-membership COUNT loop with a single JOIN + GROUP BY query
that returns all unread counts in one database call.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): quality fixes, missing tests, and Playwright E2E setup
Addresses quality issues found during review and fills test coverage gaps
for the internal chat feature.
Backend fixes:
- Return 201 for all create endpoints (messages, categories, polls, reactions, members)
- Fix N+1 queries: replies.size, poll votes, category channels.count, votes.exists?
- Fix pagination has_more logic to check page size instead of total count
- Scope poll vote/unvote to current account (security fix)
- Add internal_chat.messages.deleted i18n key
- Use find_by! in mark_unread for proper 404 on non-members
- Guard time param parsing with rescue ArgumentError
- Align message response format between channels and messages controllers
- Switch notification service to ActionCable-only (avoid push/email crashes)
Frontend fixes:
- Fix pinned message detection (content_attributes.pinned, not message.pinned)
- Fix thread reply count key (replies_count, not thread_replies_count)
- Fix markUnread to pass message_id parameter
- Fix pagination: PREPEND_MESSAGES mutation instead of overwriting
- Fix typing status to read Vuex reactive state, not stale closure
- Fix deleteDraft argument shape (pass { channelId, draftId })
- Fix DM channel filtering (check both is_dm and channel_type)
- Fix DraftsList navigation to use correct channel ID key
- Wire PollCreator to poll button in MessageEditor
- Wire settings event handler on ChannelHeader
- Reset PollCreator isSubmitting on timeout
New RSpec tests (67 examples):
- Factories: polls, poll_options, poll_votes, drafts
- Model specs: Poll, PollOption, PollVote, Draft
- Controller specs: PollsController, DraftsController
- Service specs: PollService, NotificationService, MentionService
Playwright E2E setup (37 tests):
- Install Playwright with Chromium
- Auth helper with Devise Token Auth login flow
- 8 test suites: navigation, channels, messaging, DMs, reactions, threads, polls, mark-read-unread
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 7)
Backend:
- Use lambda for UUID default in channels migration
- Wrap poll creation in transaction for atomicity
- Preload replies in thread action to avoid N+1
- Broadcast replies_count + attachments in listener (match REST shape)
- Scope draft listing through accessible channels
- Key draft upserts/deletes by parent_id for thread drafts
Frontend:
- Remove duplicate poll methods from internalChatMessages.js (use internalChatPolls.js)
- Persist toggleMute/toggleFavorite to backend via updateMember API
- Clear active channel on DELETE_CHANNEL mutation
- Skip unread increment for active channel in ActionCable handler
- Filter archived channels from sidebar getters
- Fix ChannelHeader isArchived to check status === 'archived'
- Prevent duplicate reactions in ADD_REACTION mutation
- Merge poll data into existing content_attributes on cable updates
- Guard infinite scroll against duplicate loads
- Add response.ok() check in E2E auth helper, remove hardcoded account ID
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 8)
- Remove unused nested typingStatus module from internalChat store
- Add parent_id to draft uniqueness scope and migration index
- Exclude reaction creator from reaction_created broadcast
- Preload attachments and poll associations in thread/messages queries
- Handle `after` fetches with APPEND_MESSAGES mutation
- Wrap channel creation payloads under `channel` key in E2E helpers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: rewrite Playwright E2E tests to use actual UI interactions
Completely rewrote all 8 E2E test suites to work with the live app:
- Test through actual UI interactions, not API bypass
- Use correct Portuguese (pt_BR) locale strings
- Use structural selectors matching real Vue component DOM
- Dynamic account ID from login response (no hardcoded values)
- 3 parallel workers, increased timeouts for reliability
- API calls only for preconditions (seeding test data)
29 tests passing across navigation, channels, messaging, DMs,
reactions, threads, polls, and mark-read-unread suites.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use partial unique indexes for draft uniqueness with NULL parent_id
PostgreSQL treats NULL as distinct in unique constraints, so a composite
index on (user_id, channel_id, parent_id) allows duplicate root drafts.
Split into two partial indexes: one for root drafts (WHERE parent_id IS
NULL) and one for thread drafts (WHERE parent_id IS NOT NULL).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 9)
- Remove duplicate index on internal_chat_polls.internal_chat_message_id
(keep only unique index)
- Add options validation in polls create (return 400 instead of 500)
- Add expiration check to unvote action (match vote behavior)
- Use strong params in messages update action
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 10)
- Change channel associations from destroy_async to destroy (FK
constraints are ON DELETE RESTRICT, blocking async deletion)
- Remove unused internal_chat notification types and PRIMARY_ACTORS
entry (notification service uses ActionCable only, no DB records)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 11)
- Scope category_id to current account in channels controller (security)
- Defer message-created event in poll creation until after transaction
- Change message associations from destroy_async to destroy (FK compat)
- Validate option belongs to poll in poll_service
- Use strong params for emoji in reactions controller
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 12)
Backend (9 fixes):
- Gate message update/destroy by channel accessibility in policy
- Guard content_attributes nil before merge in polls controller
- Fix after-cursor pagination to use limit() instead of last()
- Wrap revote in transaction for atomicity in poll service
- Make unvote option-specific for multi-choice polls
- Exclude own messages from unread count
- Make channel activity update monotonic (only write if newer)
- Include actor in message/reaction broadcasts (multi-tab support)
- Return 400 for empty member create instead of 201
Frontend (8 fixes):
- Show uncategorized channels even when categories exist
- Clear editor on channel switch when no draft exists
- Soft-delete messages in store (update in place, don't remove)
- Guard ThreadPanel against out-of-order fetch responses
- Replace hardcoded channel label with i18n key in DraftsList
- Add accessible name to settings button in ChannelHeader
- Add aria-label to search field in ChannelSidebar
- Make MessageBubble actions keyboard-accessible via focus-within
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 13)
- Fix keyword argument mismatch in reactions dispatch_reaction_event
- Add user_id to reaction cable broadcast for shape consistency with REST
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): quality fixes, expanded RSpec + Playwright E2E tests
Fix isArchived computed (checked .archived instead of .status), fix
ReactionDisplay user identification (.user?.id vs .user_id), update
17 spec assertions from :success to :created on create endpoints,
add 32 new RSpec examples (polls, drafts, services), and rewrite
8 Playwright E2E test files with correct selectors, proper test
isolation, and dynamic user ID discovery.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 14)
Prevent duplicate votes on same option in multi-choice polls with
explicit BadRequest guard. Add internal_chat webhook events to
ALLOWED_WEBHOOK_EVENTS so users can subscribe to them.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: include poll data in ActionCable broadcast for poll messages
Extract base_message_data helper and enrich message_event_data with
poll options when the message has an associated poll, ensuring
realtime subscribers receive the same poll data as REST API clients.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 15)
Backend: wrap single-choice revotes in transaction, capture member
tokens before channel destroy, exclude own messages from unread count,
strip attachments from deleted messages, enrich poll broadcast payload.
Frontend: use getCurrentRole getter, fix public-results poll display,
sync thread replies via store, add close button a11y, pass option_id
to unvote API, pass parent_id to deleteDraft API.
Models: handle nil last_read_at for new members, skip content
validation for attachment-only messages, align PollService guards
with controller, change category dependent to nullify.
Swagger: add attachments to message schema, fix create status to 201.
E2E: remove fragile waitForTimeout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): fix 21 UX/functional issues
Address 21 UX gaps discovered during product testing:
Sidebar & Navigation:
- Fix search icon overlap, extend search to description + DM members
- Add create channel/DM/category buttons and modals
- Show DM member names instead of null
- Include members data in channel index API for DMs
Message Interactions:
- Add delete confirmation dialog
- Implement inline message editing with cancel support
- Toggle emoji reactions (add/remove)
- Support multiple pinned messages with click-to-scroll
- Prevent thread replies from appearing in main chat
- Fix reply count live updates
- Hide pin button on thread messages
- Improve deleted message styling with greyed-out card
- Replace spinner with skeleton loading
- Add markdown toolbar (bold/italic/code)
- Fix thread editing and add vote/unvote handlers
Features & Polish:
- Implement channel settings slide-over panel
- Fix thread loading not affecting main channel spinner
- Fix poll creation field name mismatch with backend API
- Fix drafts: show channel names, handle DM navigation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): use Dialog modal for delete confirmation
Replace window.confirm with the project's Dialog component for
message delete confirmation, providing a consistent UI experience.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 16)
- Require content field in message update OpenAPI schema
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 17)
- Sanitize advisory lock SQL with sanitize_sql_array
- Use semantic button for pinned message banner
- Add aria-label to ChannelSettings close button
- Add type="button" to all ChannelSettings buttons
- Gate channel/DM/category creation to admins
- Replace hardcoded 'Direct Message' with i18n key
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review feedback (round 18)
- Wrap DM creation payload in channel key for consistency
- Replace raw text in category select with i18n key
- Add IME composition guard to prevent premature send
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(internal-chat): UX round 2, rich editor, team members, drag-and-drop
- Reduce sidebar spacing between search bar and drafts
- Fix search icon overlapping placeholder text
- Replace inline category form with Dialog modal
- Add collapsible sidebar sections with localStorage persistence
- Add drag-and-drop channels across categories (admin-only, vuedraggable)
- Replace textarea editor with WootWriter ProseMirror rich text editor
- Replace regex markdown rendering with shared MessageFormatter
- Wire draft auto-save pipeline with WootWriter (3s debounce watcher)
- Add team + agent selection when creating private channels
- Auto-add all agents when creating public channels
- Sync team members to linked channels via TeamMember callback
- Fix member list not loading on first settings panel open
- Complete PT-BR translations for all internal chat strings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(internal-chat): UX round 3, Enter-to-send, mentions, copy link, poll modal
- Send with Enter (not Cmd+Enter), Shift+Enter for newlines
- Enable @mentions via WootWriter suggestions plugin
- Refocus editor after sending a message
- Copy link to message button in hover toolbar
- Poll creator refactored to Dialog with confirm-discard on close
- Channel type uses Switch instead of dropdown
- Category uses components-next Select instead of native select
- Skeleton loading: only on initial load, spinner for pagination
- Scroll position preserved when loading older messages
- Mute/Favorite buttons fixed (store members updated after fetch)
- Add/remove channel members after creation (admin-only)
- Save draft immediately when switching channels
- Settings sidebar remembers open/closed state via localStorage
- Search icon overlap fixed (increased padding)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): DM settings, copy updates, input refocus, member UX
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): member edit for private only, emoji overflow, reaction tooltips
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): thread count sync, scroll loading, copy link, thread/settings exclusivity
- Fix thread reply count doubling (remove duplicate INCREMENT_REPLY_COUNT from sendThreadReply, cable handles it)
- Fix copy link button (use window.location.origin + pathname as fallback)
- Hide poll button in thread editor
- Add "Also send in #channel" checkbox for thread replies
- Increase scroll threshold for loading older messages (100px instead of 0)
- Track and stop loading when oldest message reached
- Thread and settings panels are mutually exclusive
- Refocus editor after send with delayed focus
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): scroll to linked message via ?messageId= query param
Read messageId from route query on mount, scroll to and highlight the
target message after messages load, then clean the query param.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): prevent editor from becoming unfocusable after send
Root cause: passing disabled prop to WootWriter applies pointer-events-none
and ProseMirror does not re-enable contenteditable when disabled returns to
false. Fix: never disable the WootWriter, use a local isSending guard to
prevent double-sends. Refocus 300ms after send for ProseMirror state reload.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): simplify send guard, no artificial timeout
Content is cleared immediately before emit, so canSend naturally
returns false (empty content). No isSending guard needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): align poll option remove buttons vertically
Increase padding to p-1.5 and add flex-shrink-0 for consistent sizing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): close poll modal after creating poll
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): poll option X button alignment, discard modal on submit
- Button uses explicit 34px height matching input, no items-center
- Reset form before closing dialog so hasUnsavedChanges is false
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): close settings sidebar when clicking reply
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): update poll UI after voting, fix re-vote error
Vote/unvote actions now dispatch updateMessageFromCable with the API
response to update poll state locally. Pass channelId to enable this.
Clicking an already-voted option correctly triggers unvote.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): include poll data in message response, add timer and voters
Backend: message_response now includes poll data (options, votes, voted
status, voters for public polls) via eager-loaded poll association.
This fixes polls not rendering after page reload.
Frontend PollDisplay:
- Countdown timer showing time remaining until poll closes
- Read-only state when expired (div instead of button, no hover)
- Voter names shown below each option (public polls or admin)
- Prefer content_attributes.poll over message.poll for fresh data
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): include channel_id in poll voted cable broadcast
The poll_event_data was missing internal_chat_channel_id, so the
frontend cable handler could not route the update to the correct
channel's message store.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): poll vote highlight, typing off, reaction broadcast, translations
- Preserve per-user voted flags when merging cable poll broadcast
- Send typing_off after 3s of no typing activity
- Include internal_chat_channel_id in reaction event broadcasts
- Fix reaction deleted handler to also check channel_id field
- Simplify "also send in" copy (works for both channels and DMs)
- Add PT-BR translation for ALSO_SEND_IN_CHANNEL
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(internal-chat): unified reaction popover with all emoji groups
Clicking any reaction badge opens a single popover showing all reactions
grouped by emoji with user names. Current user can remove their own
reaction via X button. Replaces per-reaction popover with unified view.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): wire close DM button to archive and navigate home
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(internal-chat): close DM via hidden flag on channel membership
Add hidden boolean to channel_members table. Close DM sets hidden=true
via member update API. Sidebar filters out hidden DMs. New messages on
a DM channel automatically unhide all members via listener callback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): reaction popover, user names, file upload support
- Include user name in reaction API response (was missing)
- Redesign reaction popover: flat list with emoji + name per row,
aligned X button for removing own reactions
- Add file upload: paperclip button opens file picker, attached files
shown as chips with remove, sent via FormData with message
- Store action and API client support files parameter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): reaction user names, unreact button, attachment rendering
- Include user name in reactions across all endpoints (messages_controller,
listener base_message_data)
- Make unreact X button always visible (bg-n-alpha-2 background)
- Render message attachments as downloadable links with paperclip icon
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(internal-chat): image preview for attachments in messages and editor
Messages: images render inline with max-h-60, non-images as download links.
Editor: image files show thumbnail preview, non-images show file icon + name.
Remove button as floating circle on top-right corner of each attachment.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): attachment preview matches conversation pattern
- File previews show name + size (e.g. "2 MB") in a horizontal card
- Image thumbnails as 32px squares, non-images show document emoji
- Remove button is a visible X icon (not a floating circle)
- Layout matches AttachmentsPreview from conversation ReplyBox
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): auto-detect image file type from MIME on upload
MessageCreateService now detects file type from content_type instead of
defaulting to :file. Images are correctly tagged as :image so they
render inline in message bubbles.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): include file_url in cable broadcast, fix filename display
Listener attachment_event_data now includes file_url so attachments
render correctly on real-time messages without page refresh.
MessageBubble extracts filename from URL or falls back to file_type+ext.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): pin attachments, edit members modal, settings persistence
- Skip content validation when pinning/unpinning (fixes pin on file-only messages)
- Add EditMembersModal with search, add, and remove members for private channels
- Fix settings sidebar always opening: @close now calls handleToggleSettings
which updates localStorage, not just sets ref to false
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): fix X buttons for attachment remove and reaction unreact
Replace Icon component with inline SVG cross for reliable rendering.
Both attachment remove and reaction unreact buttons now show a visible
X icon at all times with proper vertical alignment.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): allow any user to pin messages, not just sender/admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): restore Icon component for X buttons (size-4 in size-6 container)
SVG inline approach didn't render. Reverted to Icon i-lucide-x with
larger sizes (size-4 icon in size-6 button) which renders reliably.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): use p-1 + size-4 pattern for X buttons (matches message toolbar)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(internal-chat): thread indicator on messages from threads, allow pinning all
- Show "Thread" badge with icon on messages that have parent_id,
clicking it opens the parent thread
- Remove parent_id restriction from canPin, any non-deleted message
can be pinned
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): thread indicator, poll close loop, thread navigation
- Hide thread indicator inside thread panel (inThread prop)
- Open parent thread when clicking thread badge on messages with parent_id
- Fix PollCreator infinite close loop (handleClose no longer calls
dialogRef.close, since Dialog already triggered the close)
- Look up parent message in store when opening thread from indicator
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(internal-chat): poll duration translations and clickable switch labels
- Duration options use i18n keys (EN + PT-BR: 24 horas, 7 dias, etc.)
- Multiple choice and Public results switches toggle by clicking label
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(internal_chat): enhance message handling and search functionality
- Added broadcasting of typing off events in InternalChatListener.
- Included member user IDs in channel data for better context.
- Updated message model to allow optional sender association.
- Implemented team mention expansion in MentionService to include team members.
- Enhanced message creation service to store mentioned user IDs in content attributes.
- Introduced a new SearchService for searching channels, DMs, and messages.
- Updated API responses to include has_unread_mention flag for channels.
- Added tests for user deletion behavior in internal chat, ensuring message preservation and reaction handling.
- Improved draft model to allow coexistence of root and thread drafts.
- Added unique indexes for drafts to prevent duplicate entries.
- Implemented foreign key constraints with appropriate delete behaviors for internal chat models.
* feat(internal-chat): swagger docs, webhook events, search UX improvements
Add Swagger documentation for drafts, polls, and search endpoints.
Wire internal_chat_message_deleted and internal_chat_channel_updated
webhook events to the UI and listener. Improve search empty state with
min-chars hint and friendly no-results message. Update CLAUDE.md to
include pt_BR translations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(internal-chat): add draft count display in channel sidebar
* chore: remove playwright config and dependencies
* feat(internal-chat): polish UX, swagger updates, and migration consolidation
- Editor toolbar shortcuts (@ and #) with instant popover trigger,
including accent-insensitive matching and wider conversation popover
- Localized last activity time and inbox name on conversation preview cards
- Thread + main interplay: also-send-in-channel mirror, parent_id filter,
per-message conversation link, hidden buttons inside thread view,
reactions update across both lists, scroll-to-message behavior
- Search service uses f_unaccent for messages, channel names and user names
via dedicated GIN trigram functional indexes
- Renamed InternalChat::ProGating to InternalChat::Limits with neutral
semantics
- Consolidated 17 internal chat migrations into 3 (tables, default channels,
unaccent search) and added a rake task to ensure the f_unaccent function
exists before db:schema:load
- Swagger paths and definitions updated to match the current state of the
feature (also_send_in_channel, status codes, pro-required responses,
hidden member flag, search meta fields, etc.)
* fix(internal-chat): use Rake task augmentation for db:schema:load hook
The previous `Rake::Task['db:schema:load'].enhance(...)` guarded by
`task_defined?` silently no-op'd in CI when the rake file loaded before
ActiveRecord's rake_tasks block ran. Re-opening `db:schema:load` via
Rake's `task name => deps` DSL augments the existing task regardless of
load order, ensuring the f_unaccent function is created before schema.rb
references it.
* fix(internal-chat): enhance db:schema:load from Rakefile after load_tasks
Adding the prereq inside lib/tasks/internal_chat_search.rake (via either
`Rake::Task#enhance` or task DSL augmentation) was being silently dropped
in CI, presumably due to load order between application rake files and
ActiveRecord's `rake_tasks` block. Moving the `enhance` to the Rakefile
itself, after `Rails.application.load_tasks`, guarantees both
`db:schema:load` and `db:internal_chat:ensure_search_functions` are
defined before the prereq is added.
Also leaves a debug `puts` in the task body so future regressions are
visible from CI logs.
* chore(internal-chat): add diagnostic logging to f_unaccent hook
* fix(internal-chat): install f_unaccent on all envs iterated by db:schema:load
Rails' `db:schema:load` in development env iterates over BOTH the
development and test databases (see
`ActiveRecord::Tasks::DatabaseTasks.each_current_environment`), but our
hook was only installing the function on the currently-connected
database. CI defaults to development env (no `RAILS_ENV` set), so the
function landed on `chatwoot_dev` while `chatwoot_test` remained
without it, causing the schema load to fail when creating the functional
indexes against the test DB.
The hook now mirrors the same iteration logic and installs the function
on every relevant config, restoring the original AR connection
afterwards.
* fix(internal-chat): align listener spec with current broadcast payload
- internal_chat_message_created now emits two broadcasts (the message
itself plus an automatic typing_off), so the spec switches to
`allow`/`have_received` to assert the message broadcast without caring
about the additional typing_off call.
- internal_chat_reaction_created payload uses `message_id`, not
`internal_chat_message_id`. Update the spec expectation to match.
* chore(internal-chat): remove redundant DSL augmentation in rake task
* fix(internal-chat): harden gates, kill N+1s and reduce race risk
Closes review findings raised on the internal chat PR:
- Restrict role mass-assignment in ChannelMembersController so only
account administrators can promote new members to channel admin.
- Wrap private-channel create/unarchive in a Postgres advisory lock per
account so concurrent requests can no longer bypass the freemium limit.
- Replace `replies.size` and `votes.size` (per-broadcast queries) with
`replies_count` / `votes_count` counter caches.
- Make `update_channel_activity` an atomic compare-and-set update so
concurrent message creates can never regress `last_activity_at`.
- Optimize `Poll#total_votes_count` to use the cached column and eager-
loaded options instead of a per-poll `votes.count` query.
- Add `internal_chat_messages.account_id` foreign key (`ON DELETE
CASCADE`) to prevent orphan rows.
- Escape HTML in `ChannelSidebar.highlightMatch` to close a v-html XSS
via incomplete tags in message search snippets.
- Cleanup `typingOffTimer` on `ChannelView` unmount.
- Add stable sort to `getChannelsByCategory` (alphabetical) and
`getDMChannels` (last activity) to prevent UI reorder thrash.
- Localize `PollDisplay` time-remaining strings (en + pt-BR).
- Add specs covering the 90-day search history filter and the search
controller endpoint, plus regenerate the consolidated migration
with the new columns and FK.
* docs(swagger): note role mass-assignment restriction on channel members
Document that the `role` field on the channel member create payload is
silently coerced to `member` for callers that are not account
administrators, matching the controller behavior introduced in the
previous commit.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b0a8fa70d0
commit
3aca86aa43
@ -79,9 +79,9 @@
|
||||
## Project-Specific
|
||||
|
||||
- **Translations**:
|
||||
- Only update `en.yml` and `en.json`
|
||||
- Update `en.yml`/`en.json` and `pt_BR.yml`/`pt_BR.json`
|
||||
- Other languages are handled by the community
|
||||
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
|
||||
- Backend i18n → `.yml`, Frontend i18n → `.json`
|
||||
- **Frontend**:
|
||||
- Use `components-next/` for message bubbles (the rest is being deprecated)
|
||||
|
||||
|
||||
9
Rakefile
9
Rakefile
@ -7,3 +7,12 @@ enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s
|
||||
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
|
||||
|
||||
Rails.application.load_tasks
|
||||
|
||||
# Ensure the f_unaccent function used by internal chat search indexes is created
|
||||
# before db:schema:load runs. This must happen after Rails.application.load_tasks
|
||||
# so that both `db:schema:load` and `db:internal_chat:ensure_search_functions`
|
||||
# are guaranteed to be defined.
|
||||
if Rake::Task.task_defined?('db:schema:load') &&
|
||||
Rake::Task.task_defined?('db:internal_chat:ensure_search_functions')
|
||||
Rake::Task['db:schema:load'].enhance(['db:internal_chat:ensure_search_functions'])
|
||||
end
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
class Api::V1::Accounts::InternalChat::BaseController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
def current_channel
|
||||
@current_channel ||= Current.account.internal_chat_channels.find(params[:channel_id] || params[:id])
|
||||
end
|
||||
|
||||
def current_membership
|
||||
@current_membership ||= current_channel.channel_members.find_by(user_id: Current.user.id)
|
||||
end
|
||||
|
||||
def channel_member?
|
||||
current_channel.channel_type_public_channel? || current_membership.present?
|
||||
end
|
||||
|
||||
def render_pro_required(feature)
|
||||
render json: { error: 'pro_feature_required', feature: feature }, status: :payment_required
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,49 @@
|
||||
class Api::V1::Accounts::InternalChat::CategoriesController < Api::V1::Accounts::InternalChat::BaseController
|
||||
before_action :fetch_category, only: [:update, :destroy]
|
||||
|
||||
def index
|
||||
authorize InternalChat::Category, :index?
|
||||
@categories = Current.account.internal_chat_categories.ordered.includes(:channels)
|
||||
render json: @categories.map { |category| category_response(category) }
|
||||
end
|
||||
|
||||
def create
|
||||
authorize InternalChat::Category, :create?
|
||||
@category = Current.account.internal_chat_categories.create!(category_params)
|
||||
render json: category_response(@category), status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @category, :update?
|
||||
@category.update!(category_params)
|
||||
render json: category_response(@category)
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @category, :destroy?
|
||||
@category.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_category
|
||||
@category = Current.account.internal_chat_categories.find(params[:id])
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:category).permit(:name, :position)
|
||||
end
|
||||
|
||||
def category_response(category)
|
||||
{
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
position: category.position,
|
||||
account_id: category.account_id,
|
||||
channels_count: category.channels.size,
|
||||
created_at: category.created_at,
|
||||
updated_at: category.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,107 @@
|
||||
class Api::V1::Accounts::InternalChat::ChannelMembersController < Api::V1::Accounts::InternalChat::BaseController
|
||||
include Events::Types
|
||||
|
||||
before_action :current_channel
|
||||
before_action :fetch_member, only: [:update, :destroy]
|
||||
|
||||
def index
|
||||
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
|
||||
@members = current_channel.channel_members.includes(user: :account_users)
|
||||
render json: @members.map { |member| member_response(member) }
|
||||
end
|
||||
|
||||
def create
|
||||
authorize current_channel, :update?, policy_class: InternalChat::ChannelPolicy
|
||||
members = create_channel_members(validated_user_ids, requested_role)
|
||||
dispatch_member_update
|
||||
render json: members.map { |member| member_response(member) }, status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
authorize_member_update!
|
||||
@member.update!(member_update_params)
|
||||
render json: member_response(@member)
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize_member_destroy!
|
||||
removed_user = @member.user
|
||||
@member.destroy!
|
||||
dispatch_member_update(removed_user: removed_user)
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validated_user_ids
|
||||
user_ids = Array(params[:user_ids] || [params[:user_id]]).compact.map(&:to_i)
|
||||
valid_user_ids = Current.account.users.where(id: user_ids).pluck(:id)
|
||||
raise ActionController::BadRequest, 'No valid user IDs provided' if valid_user_ids.empty?
|
||||
|
||||
valid_user_ids
|
||||
end
|
||||
|
||||
def create_channel_members(user_ids, role)
|
||||
ActiveRecord::Base.transaction do
|
||||
user_ids.map do |user_id|
|
||||
current_channel.channel_members.find_or_create_by!(user_id: user_id) do |m|
|
||||
m.role = role
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Only account administrators can promote a new member to channel admin via params.
|
||||
# Channel admins (without account-admin) always create plain members.
|
||||
def requested_role
|
||||
return :member unless Current.account_user&.administrator?
|
||||
return :member if params[:role].blank?
|
||||
|
||||
InternalChat::ChannelMember.roles.key?(params[:role].to_s) ? params[:role] : :member
|
||||
end
|
||||
|
||||
def fetch_member
|
||||
@member = current_channel.channel_members.find(params[:id])
|
||||
end
|
||||
|
||||
def authorize_member_update!
|
||||
raise Pundit::NotAuthorizedError unless @member.user_id == Current.user.id || Current.account_user&.administrator?
|
||||
end
|
||||
|
||||
def authorize_member_destroy!
|
||||
raise Pundit::NotAuthorizedError unless @member.user_id == Current.user.id || Current.account_user&.administrator?
|
||||
end
|
||||
|
||||
def dispatch_member_update(removed_user: nil)
|
||||
# Capture tokens before the broadcast so the removed user also receives the event
|
||||
tokens = current_channel.members.pluck(:pubsub_token)
|
||||
tokens << removed_user.pubsub_token if removed_user.present?
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
INTERNAL_CHAT_CHANNEL_UPDATED,
|
||||
Time.zone.now,
|
||||
channel: current_channel,
|
||||
member_tokens: tokens.uniq
|
||||
)
|
||||
end
|
||||
|
||||
def member_update_params
|
||||
params.permit(:muted, :favorited, :hidden)
|
||||
end
|
||||
|
||||
def member_response(member)
|
||||
{
|
||||
id: member.id,
|
||||
user_id: member.user_id,
|
||||
role: member.role,
|
||||
muted: member.muted,
|
||||
favorited: member.favorited,
|
||||
last_read_at: member.last_read_at,
|
||||
name: member.user.name,
|
||||
avatar_url: member.user.avatar_url,
|
||||
availability_status: member.user.availability_status,
|
||||
created_at: member.created_at,
|
||||
updated_at: member.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,495 @@
|
||||
class Api::V1::Accounts::InternalChat::ChannelsController < Api::V1::Accounts::InternalChat::BaseController # rubocop:disable Metrics/ClassLength
|
||||
include Events::Types
|
||||
|
||||
before_action :current_channel, only: [:show, :update, :destroy, :archive, :unarchive, :toggle_typing_status, :mark_read, :mark_unread]
|
||||
|
||||
RECENT_MESSAGES_LIMIT = 20
|
||||
# Arbitrary 32-bit namespace for the private-channel limit advisory lock; paired with account id.
|
||||
PRIVATE_CHANNEL_LOCK_KEY = 0x49434C4D # 'ICLM'
|
||||
|
||||
def index
|
||||
authorize InternalChat::Channel, :index?
|
||||
@channels = filtered_channels
|
||||
@unread_counts = compute_unread_counts(@channels)
|
||||
@mention_channel_ids = compute_mention_channel_ids(@channels)
|
||||
render json: @channels.map { |channel| channel_index_response(channel) }
|
||||
end
|
||||
|
||||
def show
|
||||
authorize @current_channel, :show?
|
||||
render json: channel_show_response(@current_channel)
|
||||
end
|
||||
|
||||
def create
|
||||
@channel = build_channel
|
||||
authorize @channel, :create?
|
||||
created = @channel.new_record?
|
||||
|
||||
if dm_params? && created
|
||||
create_dm_with_lock
|
||||
else
|
||||
with_private_channel_limit_lock(@channel) do
|
||||
return if enforce_private_channel_limit(@channel)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@channel.save!
|
||||
add_creator_as_admin
|
||||
add_initial_members
|
||||
add_channel_type_members
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
dispatch_channel_event(@channel) if created
|
||||
render json: channel_show_response(@channel), status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @current_channel, :update?
|
||||
attrs = update_channel_params
|
||||
validate_category!(attrs[:category_id])
|
||||
@current_channel.update!(attrs)
|
||||
dispatch_channel_event(@current_channel)
|
||||
render json: channel_show_response(@current_channel)
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @current_channel, :destroy?
|
||||
# Capture member tokens before destroying so the listener can broadcast to them
|
||||
cached_tokens = channel_member_tokens(@current_channel)
|
||||
@current_channel.destroy!
|
||||
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_CHANNEL_UPDATED, Time.zone.now, channel: @current_channel,
|
||||
member_tokens: cached_tokens)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def archive
|
||||
authorize @current_channel, :archive?
|
||||
head(:unprocessable_entity) and return if @current_channel.channel_type_dm?
|
||||
|
||||
@current_channel.archived!
|
||||
dispatch_channel_event(@current_channel)
|
||||
render json: channel_show_response(@current_channel)
|
||||
end
|
||||
|
||||
def unarchive
|
||||
authorize @current_channel, :unarchive?
|
||||
|
||||
with_private_channel_limit_lock(@current_channel) do
|
||||
return if enforce_private_channel_limit(@current_channel)
|
||||
|
||||
@current_channel.active!
|
||||
end
|
||||
|
||||
dispatch_channel_event(@current_channel)
|
||||
render json: channel_show_response(@current_channel)
|
||||
end
|
||||
|
||||
def toggle_typing_status
|
||||
authorize @current_channel, :toggle_typing_status?
|
||||
InternalChat::TypingStatusManager.new(
|
||||
channel: @current_channel, user: Current.user, params: { typing_status: typing_status_param }
|
||||
).perform
|
||||
head :ok
|
||||
end
|
||||
|
||||
def mark_read
|
||||
authorize @current_channel, :mark_read?
|
||||
membership = @current_channel.channel_members.find_by(user_id: Current.user.id)
|
||||
membership&.update!(last_read_at: Time.current)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def mark_unread
|
||||
authorize @current_channel, :mark_unread?
|
||||
msg_id = mark_unread_params[:message_id]
|
||||
return head(:ok) if msg_id.blank?
|
||||
|
||||
membership = @current_channel.channel_members.find_by!(user_id: Current.user.id)
|
||||
message = @current_channel.messages.find(msg_id)
|
||||
membership.update!(last_read_at: message.created_at - 1.second)
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enforce_private_channel_limit(channel)
|
||||
return unless channel.channel_type_private_channel?
|
||||
|
||||
max = InternalChat::Limits.max_private_channels
|
||||
return if max.blank?
|
||||
|
||||
count = Current.account.internal_chat_channels.where(channel_type: :private_channel).active.count
|
||||
render_pro_required('private_channels') if count >= max
|
||||
end
|
||||
|
||||
# Postgres advisory transaction lock keyed by account so concurrent create/unarchive
|
||||
# cannot bypass the private-channel limit by racing between count and save.
|
||||
def with_private_channel_limit_lock(channel)
|
||||
return yield unless channel.channel_type_private_channel? && InternalChat::Limits.max_private_channels.present?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
ActiveRecord::Base.connection.execute(
|
||||
ActiveRecord::Base.sanitize_sql_array(['SELECT pg_advisory_xact_lock(?, ?)', PRIVATE_CHANNEL_LOCK_KEY, Current.account.id])
|
||||
)
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
def filtered_channels
|
||||
channels = Current.account.internal_chat_channels.includes(channel_members: { user: :account_users }, category: [])
|
||||
channels = apply_type_filter(channels)
|
||||
channels = apply_category_filter(channels)
|
||||
channels = apply_status_filter(channels)
|
||||
channels = apply_visibility_filter(channels)
|
||||
channels.order(last_activity_at: :desc)
|
||||
end
|
||||
|
||||
def apply_type_filter(channels)
|
||||
case params[:type]
|
||||
when 'text_channels'
|
||||
channels.text_channels
|
||||
when 'direct_messages'
|
||||
channels.direct_messages
|
||||
else
|
||||
channels
|
||||
end
|
||||
end
|
||||
|
||||
def apply_category_filter(channels)
|
||||
return channels if params[:category_id].blank?
|
||||
|
||||
channels.where(category_id: params[:category_id])
|
||||
end
|
||||
|
||||
def apply_status_filter(channels)
|
||||
case params[:status]
|
||||
when 'archived'
|
||||
channels.archived
|
||||
else
|
||||
channels.active
|
||||
end
|
||||
end
|
||||
|
||||
def apply_visibility_filter(channels)
|
||||
user_channels = channels.where(id: Current.user.internal_chat_channels.select(:id))
|
||||
|
||||
return channels.where(channel_type: %i[public_channel private_channel]).or(user_channels) if Current.account_user&.administrator?
|
||||
|
||||
channels.where(channel_type: :public_channel).or(user_channels)
|
||||
end
|
||||
|
||||
def build_channel
|
||||
if dm_params?
|
||||
find_or_build_dm
|
||||
else
|
||||
attrs = create_channel_params.except(:member_ids, :team_ids)
|
||||
validate_category!(attrs[:category_id])
|
||||
Current.account.internal_chat_channels.build(attrs.merge(created_by: Current.user))
|
||||
end
|
||||
end
|
||||
|
||||
def dm_params?
|
||||
params[:channel_type] == 'dm' || params.dig(:channel, :channel_type) == 'dm'
|
||||
end
|
||||
|
||||
def find_or_build_dm
|
||||
user_ids = dm_member_ids
|
||||
existing_dm = find_existing_dm(user_ids)
|
||||
return existing_dm if existing_dm.present?
|
||||
|
||||
Current.account.internal_chat_channels.build(
|
||||
channel_type: :dm,
|
||||
name: nil,
|
||||
created_by: Current.user
|
||||
)
|
||||
end
|
||||
|
||||
def find_existing_dm(user_ids)
|
||||
sorted_ids = user_ids.sort
|
||||
member_count = sorted_ids.size
|
||||
|
||||
Current.account.internal_chat_channels
|
||||
.where(channel_type: :dm)
|
||||
.joins(:channel_members)
|
||||
.group('internal_chat_channels.id')
|
||||
.having('COUNT(internal_chat_channel_members.id) = ?', member_count)
|
||||
.having(
|
||||
'ARRAY_AGG(internal_chat_channel_members.user_id ORDER BY internal_chat_channel_members.user_id) = ARRAY[?]::bigint[]',
|
||||
sorted_ids
|
||||
)
|
||||
.first
|
||||
end
|
||||
|
||||
def dm_member_ids
|
||||
ids = Array(permitted_member_ids).map(&:to_i)
|
||||
ids = Current.account.users.where(id: ids).pluck(:id)
|
||||
ids << Current.user.id unless ids.include?(Current.user.id)
|
||||
ids
|
||||
end
|
||||
|
||||
def add_creator_as_admin
|
||||
return if @channel.channel_type_dm?
|
||||
return if @channel.channel_members.exists?(user_id: Current.user.id)
|
||||
|
||||
@channel.channel_members.create!(user_id: Current.user.id, role: :admin)
|
||||
end
|
||||
|
||||
def add_initial_members
|
||||
member_ids = Array(permitted_member_ids).map(&:to_i)
|
||||
member_ids = Current.account.users.where(id: member_ids).pluck(:id)
|
||||
member_ids << Current.user.id if @channel.channel_type_dm? && member_ids.exclude?(Current.user.id)
|
||||
|
||||
member_ids.uniq.each do |user_id|
|
||||
next if @channel.channel_members.exists?(user_id: user_id)
|
||||
|
||||
@channel.channel_members.create!(user_id: user_id, role: :member)
|
||||
end
|
||||
end
|
||||
|
||||
def add_channel_type_members
|
||||
return if @channel.channel_type_dm?
|
||||
|
||||
if @channel.channel_type_public_channel?
|
||||
add_all_agents_as_members
|
||||
else
|
||||
add_team_members
|
||||
end
|
||||
end
|
||||
|
||||
def add_all_agents_as_members
|
||||
agent_ids = Current.account.agents.where.not(id: Current.user.id).pluck(:id)
|
||||
agent_ids.each do |uid|
|
||||
@channel.channel_members.find_or_create_by!(user_id: uid) { |m| m.role = :member }
|
||||
end
|
||||
end
|
||||
|
||||
def add_team_members
|
||||
team_ids = permitted_team_ids
|
||||
return if team_ids.blank?
|
||||
|
||||
team_ids.each do |team_id|
|
||||
team = Current.account.teams.find_by(id: team_id)
|
||||
next unless team
|
||||
|
||||
@channel.channel_teams.find_or_create_by!(team: team)
|
||||
team.members.each do |user|
|
||||
@channel.channel_members.find_or_create_by!(user_id: user.id) { |m| m.role = :member }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_channel_params
|
||||
@create_channel_params ||= params.require(:channel).permit(:name, :description, :channel_type, :category_id, member_ids: [], team_ids: [])
|
||||
end
|
||||
|
||||
def update_channel_params
|
||||
params.require(:channel).permit(:name, :description, :category_id)
|
||||
end
|
||||
|
||||
def permitted_member_ids
|
||||
params.permit(member_ids: [])[:member_ids] || create_channel_params[:member_ids]
|
||||
end
|
||||
|
||||
def permitted_team_ids
|
||||
ids = params.permit(team_ids: [])[:team_ids] || create_channel_params[:team_ids]
|
||||
Array(ids).map(&:to_i).compact_blank
|
||||
end
|
||||
|
||||
def mark_unread_params
|
||||
params.permit(:message_id)
|
||||
end
|
||||
|
||||
def typing_status_param
|
||||
params.permit(:typing_status)[:typing_status]
|
||||
end
|
||||
|
||||
def create_dm_with_lock
|
||||
lock_key = "internal_chat_dm_#{Current.account.id}_#{dm_member_ids.sort.join('_')}"
|
||||
ActiveRecord::Base.transaction do
|
||||
ActiveRecord::Base.connection.execute(
|
||||
ActiveRecord::Base.sanitize_sql_array(['SELECT pg_advisory_xact_lock(?)', Zlib.crc32(lock_key)])
|
||||
)
|
||||
existing = find_existing_dm(dm_member_ids)
|
||||
if existing
|
||||
@channel = existing
|
||||
else
|
||||
@channel.save!
|
||||
add_initial_members
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def compute_mention_channel_ids(channels)
|
||||
user_id = Current.user.id
|
||||
InternalChat::ChannelMember
|
||||
.joins(
|
||||
'INNER JOIN internal_chat_messages ' \
|
||||
'ON internal_chat_messages.internal_chat_channel_id = internal_chat_channel_members.internal_chat_channel_id ' \
|
||||
'AND internal_chat_messages.created_at > internal_chat_channel_members.last_read_at'
|
||||
)
|
||||
.where(internal_chat_channel_id: channels.select(:id), user_id: user_id)
|
||||
.where.not(last_read_at: nil)
|
||||
.where.not('internal_chat_messages.sender_id' => user_id)
|
||||
.where("internal_chat_messages.content_attributes->'mentioned_user_ids' @> ?", [user_id].to_json)
|
||||
.pluck(Arel.sql('DISTINCT internal_chat_channel_members.internal_chat_channel_id'))
|
||||
end
|
||||
|
||||
def compute_unread_counts(channels)
|
||||
InternalChat::ChannelMember
|
||||
.joins(
|
||||
'INNER JOIN internal_chat_messages ' \
|
||||
'ON internal_chat_messages.internal_chat_channel_id = internal_chat_channel_members.internal_chat_channel_id ' \
|
||||
'AND internal_chat_messages.created_at > internal_chat_channel_members.last_read_at'
|
||||
)
|
||||
.where(internal_chat_channel_id: channels.select(:id), user_id: Current.user.id)
|
||||
.where.not(last_read_at: nil)
|
||||
.where.not('internal_chat_messages.sender_id' => Current.user.id)
|
||||
.group('internal_chat_channel_members.internal_chat_channel_id')
|
||||
.count('internal_chat_messages.id')
|
||||
end
|
||||
|
||||
def channel_base_response(channel)
|
||||
{
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
description: channel.description,
|
||||
channel_type: channel.channel_type,
|
||||
status: channel.status,
|
||||
category_id: channel.category_id,
|
||||
last_activity_at: channel.last_activity_at,
|
||||
created_at: channel.created_at,
|
||||
updated_at: channel.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def channel_index_response(channel) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
||||
membership = channel.channel_members.detect { |member| member.user_id == Current.user.id }
|
||||
response = channel_base_response(channel).merge(
|
||||
is_dm: channel.channel_type_dm?,
|
||||
muted: membership&.muted || false,
|
||||
favorited: membership&.favorited || false,
|
||||
hidden: membership&.hidden || false,
|
||||
members_count: channel.channel_members.size,
|
||||
unread_count: @unread_counts&.dig(channel.id) || 0,
|
||||
has_unread_mention: @mention_channel_ids&.include?(channel.id) || false
|
||||
)
|
||||
if channel.channel_type_dm?
|
||||
response[:members] = channel.channel_members.map do |m|
|
||||
{ user_id: m.user_id, name: m.user.name, avatar_url: m.user.avatar_url, availability_status: m.user.availability_status }
|
||||
end
|
||||
end
|
||||
response
|
||||
end
|
||||
|
||||
def channel_show_response(channel) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
||||
members = channel.channel_members.includes(:user).load
|
||||
membership = members.detect { |member| member.user_id == Current.user.id }
|
||||
recent_messages = channel.messages
|
||||
.includes(:sender, :reactions, :replies, { poll: { options: { votes: :user } } },
|
||||
attachments: { file_attachment: :blob })
|
||||
.recent.limit(RECENT_MESSAGES_LIMIT).reverse
|
||||
|
||||
channel_base_response(channel).merge(
|
||||
is_dm: channel.channel_type_dm?,
|
||||
muted: membership&.muted || false,
|
||||
favorited: membership&.favorited || false,
|
||||
account_id: channel.account_id,
|
||||
created_by_id: channel.created_by_id,
|
||||
members_count: members.size,
|
||||
unread_count: membership&.unread_messages_count || 0,
|
||||
members: members.map { |m| member_response(m) },
|
||||
messages: recent_messages.map { |msg| message_response(msg) }
|
||||
)
|
||||
end
|
||||
|
||||
def member_response(member)
|
||||
{
|
||||
id: member.id,
|
||||
user_id: member.user_id,
|
||||
role: member.role,
|
||||
muted: member.muted,
|
||||
favorited: member.favorited,
|
||||
name: member.user.name,
|
||||
avatar_url: member.user.avatar_url
|
||||
}
|
||||
end
|
||||
|
||||
def message_response(message)
|
||||
deleted = message.content_attributes&.dig('deleted')
|
||||
attrs = message.content_attributes || {}
|
||||
attrs = attrs.merge(poll: poll_response_for(message.poll)) if message.poll.present?
|
||||
{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
content_type: message.content_type,
|
||||
content_attributes: attrs,
|
||||
sender: message.sender&.push_event_data,
|
||||
parent_id: message.parent_id,
|
||||
echo_id: message.echo_id,
|
||||
replies_count: message.replies_count,
|
||||
created_at: message.created_at,
|
||||
updated_at: message.updated_at,
|
||||
reactions: reaction_responses(message),
|
||||
attachments: deleted ? [] : message.attachments.map { |a| attachment_response(a) }
|
||||
}
|
||||
end
|
||||
|
||||
def poll_response_for(poll)
|
||||
{
|
||||
id: poll.id,
|
||||
question: poll.question,
|
||||
multiple_choice: poll.multiple_choice,
|
||||
public_results: poll.public_results,
|
||||
allow_revote: poll.allow_revote,
|
||||
expires_at: poll.expires_at,
|
||||
internal_chat_message_id: poll.internal_chat_message_id,
|
||||
options: poll.options.ordered.includes(votes: :user).map { |opt| poll_option_response(opt, poll) },
|
||||
total_votes: poll.total_votes_count,
|
||||
created_at: poll.created_at,
|
||||
updated_at: poll.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def poll_option_response(option, poll)
|
||||
response = {
|
||||
id: option.id,
|
||||
text: option.text,
|
||||
votes_count: option.votes_count,
|
||||
voted: option.votes.any? { |v| v.user_id == Current.user.id }
|
||||
}
|
||||
response[:voters] = option.votes.map { |v| { id: v.user_id, name: v.user.name } } if poll.public_results
|
||||
response
|
||||
end
|
||||
|
||||
def reaction_responses(message)
|
||||
message.reactions.includes(:user).map do |r|
|
||||
{ id: r.id, emoji: r.emoji, user_id: r.user_id, user: { name: r.user&.name } }
|
||||
end
|
||||
end
|
||||
|
||||
def attachment_response(attachment)
|
||||
{
|
||||
id: attachment.id,
|
||||
file_type: attachment.file_type,
|
||||
external_url: attachment.external_url,
|
||||
extension: attachment.extension,
|
||||
file_url: attachment.file.attached? ? url_for(attachment.file) : nil
|
||||
}
|
||||
end
|
||||
|
||||
def channel_member_tokens(channel)
|
||||
users = channel.channel_type_public_channel? ? channel.account.users : channel.members
|
||||
users.pluck(:pubsub_token)
|
||||
end
|
||||
|
||||
def validate_category!(category_id)
|
||||
return if category_id.blank?
|
||||
|
||||
Current.account.internal_chat_categories.find(category_id)
|
||||
end
|
||||
|
||||
def dispatch_channel_event(channel)
|
||||
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_CHANNEL_UPDATED, Time.zone.now, channel: channel)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,55 @@
|
||||
class Api::V1::Accounts::InternalChat::DraftsController < Api::V1::Accounts::InternalChat::BaseController
|
||||
before_action :current_channel, only: [:update, :destroy]
|
||||
|
||||
def index
|
||||
accessible_channel_ids = Current.account.internal_chat_channels
|
||||
.where(channel_type: :public_channel)
|
||||
.or(Current.account.internal_chat_channels.where(id: Current.user.internal_chat_channels.select(:id)))
|
||||
.select(:id)
|
||||
@drafts = InternalChat::Draft.where(user: Current.user, account: Current.account,
|
||||
internal_chat_channel_id: accessible_channel_ids).recent
|
||||
render json: @drafts.map { |draft| draft_response(draft) }
|
||||
end
|
||||
|
||||
def update
|
||||
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
|
||||
|
||||
@draft = InternalChat::Draft.find_or_initialize_by(
|
||||
user: Current.user,
|
||||
internal_chat_channel_id: current_channel.id,
|
||||
parent_id: draft_params[:parent_id]
|
||||
)
|
||||
@draft.assign_attributes(
|
||||
account: Current.account,
|
||||
content: draft_params[:content]
|
||||
)
|
||||
@draft.save!
|
||||
|
||||
render json: draft_response(@draft), status: :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
|
||||
|
||||
@draft = InternalChat::Draft.find_by!(user: Current.user, internal_chat_channel_id: current_channel.id, parent_id: params[:parent_id])
|
||||
@draft.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def draft_params
|
||||
params.permit(:content, :parent_id)
|
||||
end
|
||||
|
||||
def draft_response(draft)
|
||||
{
|
||||
id: draft.id,
|
||||
content: draft.content,
|
||||
internal_chat_channel_id: draft.internal_chat_channel_id,
|
||||
parent_id: draft.parent_id,
|
||||
created_at: draft.created_at,
|
||||
updated_at: draft.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,191 @@
|
||||
class Api::V1::Accounts::InternalChat::MessagesController < Api::V1::Accounts::InternalChat::BaseController
|
||||
include Events::Types
|
||||
|
||||
before_action :current_channel
|
||||
before_action :fetch_message, only: [:update, :destroy, :pin, :unpin, :thread]
|
||||
|
||||
MESSAGES_PER_PAGE = 50
|
||||
|
||||
def index
|
||||
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
|
||||
@messages = paginated_messages
|
||||
render json: {
|
||||
messages: @messages.map { |msg| message_response(msg) },
|
||||
meta: pagination_meta
|
||||
}
|
||||
end
|
||||
|
||||
def create
|
||||
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
|
||||
@message = InternalChat::MessageCreateService.new(
|
||||
channel: current_channel,
|
||||
sender: Current.user,
|
||||
params: message_params
|
||||
).perform
|
||||
render json: message_response(@message), status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @message, :update?, policy_class: InternalChat::MessagePolicy
|
||||
previous_content = @message.content
|
||||
@message.update!(
|
||||
content: update_params[:content],
|
||||
content_attributes: (@message.content_attributes || {}).merge('edited_at' => Time.current.iso8601, 'previous_content' => previous_content)
|
||||
)
|
||||
dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message)
|
||||
render json: message_response(@message)
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @message, :destroy?, policy_class: InternalChat::MessagePolicy
|
||||
message_data = {
|
||||
id: @message.id,
|
||||
channel_id: @message.internal_chat_channel_id,
|
||||
account_id: @message.account_id
|
||||
}
|
||||
@message.update!(content: I18n.t('internal_chat.messages.deleted'), content_attributes: { deleted: true })
|
||||
dispatch_message_event(INTERNAL_CHAT_MESSAGE_DELETED, message_data: message_data)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def pin
|
||||
authorize @message, :pin?, policy_class: InternalChat::MessagePolicy
|
||||
@message.skip_content_validation = true
|
||||
@message.update!(content_attributes: (@message.content_attributes || {}).merge('pinned' => true, 'pinned_by' => Current.user.id,
|
||||
'pinned_at' => Time.current.iso8601))
|
||||
dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message)
|
||||
render json: message_response(@message)
|
||||
end
|
||||
|
||||
def unpin
|
||||
authorize @message, :unpin?, policy_class: InternalChat::MessagePolicy
|
||||
@message.skip_content_validation = true
|
||||
attrs = (@message.content_attributes || {}).except('pinned', 'pinned_by', 'pinned_at')
|
||||
@message.update!(content_attributes: attrs)
|
||||
dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message)
|
||||
render json: message_response(@message)
|
||||
end
|
||||
|
||||
def thread
|
||||
authorize @message, :thread?, policy_class: InternalChat::MessagePolicy
|
||||
replies = @message.replies.includes(:sender, :reactions, :replies, :attachments, :poll).ordered
|
||||
render json: {
|
||||
parent: message_response(@message),
|
||||
replies: replies.map { |msg| message_response(msg) }
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_message
|
||||
@message = current_channel.messages.find(params[:id])
|
||||
end
|
||||
|
||||
def paginated_messages
|
||||
return fetch_around_messages if params[:around].present?
|
||||
|
||||
messages = apply_time_filters(base_messages_scope)
|
||||
if params[:after].present?
|
||||
messages.ordered.limit(MESSAGES_PER_PAGE)
|
||||
else
|
||||
messages.ordered.last(MESSAGES_PER_PAGE)
|
||||
end
|
||||
rescue ArgumentError
|
||||
base_messages_scope.ordered.last(MESSAGES_PER_PAGE)
|
||||
end
|
||||
|
||||
def fetch_around_messages
|
||||
target = current_channel.messages.find_by(id: params[:around])
|
||||
return base_messages_scope.ordered.last(MESSAGES_PER_PAGE) unless target
|
||||
|
||||
half = MESSAGES_PER_PAGE / 2
|
||||
before_msgs = base_messages_scope.where('internal_chat_messages.created_at <= ?', target.created_at)
|
||||
.ordered.last(half)
|
||||
after_msgs = base_messages_scope.where('internal_chat_messages.created_at > ?', target.created_at)
|
||||
.ordered.limit(half)
|
||||
(before_msgs + after_msgs).uniq(&:id).sort_by(&:created_at)
|
||||
end
|
||||
|
||||
def base_messages_scope
|
||||
current_channel.messages
|
||||
.includes(:sender, :reactions, :replies, :attachments, :poll)
|
||||
.where("parent_id IS NULL OR (content_attributes->>'also_send_in_channel')::boolean = true")
|
||||
end
|
||||
|
||||
def apply_time_filters(messages)
|
||||
messages = messages.where('internal_chat_messages.created_at < ?', Time.zone.parse(params[:before])) if params[:before].present?
|
||||
messages = messages.where('internal_chat_messages.created_at > ?', Time.zone.parse(params[:after])) if params[:after].present?
|
||||
messages
|
||||
end
|
||||
|
||||
def pagination_meta
|
||||
{
|
||||
has_more: @messages.size >= MESSAGES_PER_PAGE
|
||||
}
|
||||
end
|
||||
|
||||
def message_params
|
||||
params.permit(:content, :content_type, :parent_id, :echo_id, :also_send_in_channel, attachments: [:file, :file_type])
|
||||
end
|
||||
|
||||
def update_params
|
||||
params.permit(:content)
|
||||
end
|
||||
|
||||
def message_response(message) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
||||
deleted = message.content_attributes&.dig('deleted')
|
||||
response = {
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
content_type: message.content_type,
|
||||
content_attributes: message.content_attributes,
|
||||
internal_chat_channel_id: message.internal_chat_channel_id,
|
||||
sender: message.sender&.push_event_data,
|
||||
parent_id: message.parent_id,
|
||||
echo_id: message.echo_id,
|
||||
replies_count: message.replies_count,
|
||||
created_at: message.created_at,
|
||||
updated_at: message.updated_at,
|
||||
reactions: message.reactions.includes(:user).map { |r| { id: r.id, emoji: r.emoji, user_id: r.user_id, user: { name: r.user&.name } } },
|
||||
attachments: deleted ? [] : message.attachments.map { |a| attachment_response(a) }
|
||||
}
|
||||
response[:poll] = poll_data(message.poll) if !deleted && message.poll?
|
||||
response
|
||||
end
|
||||
|
||||
def poll_data(poll)
|
||||
return nil unless poll
|
||||
|
||||
{
|
||||
id: poll.id,
|
||||
question: poll.question,
|
||||
multiple_choice: poll.multiple_choice,
|
||||
public_results: poll.public_results,
|
||||
allow_revote: poll.allow_revote,
|
||||
expires_at: poll.expires_at,
|
||||
options: poll.options.ordered.includes(votes: :user).map { |o| poll_option_data(o, poll) },
|
||||
total_votes: poll.total_votes_count
|
||||
}
|
||||
end
|
||||
|
||||
def poll_option_data(option, poll)
|
||||
data = { id: option.id, text: option.text, emoji: option.emoji, votes_count: option.votes_count,
|
||||
voted: option.votes.any? { |v| v.user_id == Current.user.id } }
|
||||
data[:voters] = option.votes.map { |v| v.user.push_event_data } if poll.public_results
|
||||
data
|
||||
end
|
||||
|
||||
def attachment_response(attachment)
|
||||
{
|
||||
id: attachment.id,
|
||||
file_type: attachment.file_type,
|
||||
external_url: attachment.external_url,
|
||||
extension: attachment.extension,
|
||||
file_url: attachment.file.attached? ? url_for(attachment.file) : nil
|
||||
}
|
||||
end
|
||||
|
||||
def dispatch_message_event(event, data)
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, **data)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,177 @@
|
||||
class Api::V1::Accounts::InternalChat::PollsController < Api::V1::Accounts::InternalChat::BaseController
|
||||
include Events::Types
|
||||
|
||||
before_action :set_poll, only: [:vote]
|
||||
before_action :set_poll_for_unvote, only: [:unvote]
|
||||
|
||||
def create
|
||||
return render_pro_required('polls') unless InternalChat::Limits.polls_enabled?
|
||||
|
||||
@channel = Current.account.internal_chat_channels.find(params[:channel_id])
|
||||
authorize @channel, :show?, policy_class: InternalChat::ChannelPolicy
|
||||
raise ActionController::BadRequest, 'Options are required' if poll_params[:options].blank?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@message = create_poll_message
|
||||
@poll = build_poll
|
||||
create_poll_options
|
||||
end
|
||||
|
||||
dispatch_message_created_event
|
||||
|
||||
render json: message_with_poll_response(@message, @poll), status: :created
|
||||
end
|
||||
|
||||
def vote
|
||||
ActiveRecord::Base.transaction do
|
||||
validate_vote!
|
||||
@vote = @option.votes.create!(user: Current.user)
|
||||
end
|
||||
dispatch_poll_event
|
||||
|
||||
render json: message_with_poll_response(@poll.message, @poll.reload), status: :ok
|
||||
end
|
||||
|
||||
def unvote
|
||||
raise ActionController::BadRequest, 'Poll has expired' if @poll.expired?
|
||||
|
||||
@vote = if params[:option_id].present?
|
||||
option = @poll.options.find(params[:option_id])
|
||||
option.votes.find_by!(user_id: Current.user.id)
|
||||
else
|
||||
InternalChat::PollVote.joins(:option)
|
||||
.where(internal_chat_poll_options: { internal_chat_poll_id: @poll.id }, user_id: Current.user.id)
|
||||
.first!
|
||||
end
|
||||
@vote.destroy!
|
||||
dispatch_poll_event
|
||||
|
||||
render json: message_with_poll_response(@poll.message, @poll.reload), status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_poll
|
||||
@poll = InternalChat::Poll.joins(:message).where(internal_chat_messages: { account_id: Current.account.id }).find(params[:id])
|
||||
@option = @poll.options.find(params[:option_id])
|
||||
channel = @poll.message.channel
|
||||
authorize channel, :show?, policy_class: InternalChat::ChannelPolicy
|
||||
end
|
||||
|
||||
def set_poll_for_unvote
|
||||
@poll = InternalChat::Poll.joins(:message).where(internal_chat_messages: { account_id: Current.account.id }).find(params[:id])
|
||||
channel = @poll.message.channel
|
||||
authorize channel, :show?, policy_class: InternalChat::ChannelPolicy
|
||||
end
|
||||
|
||||
def create_poll_message
|
||||
@channel.messages.create!(
|
||||
account: Current.account,
|
||||
sender: Current.user,
|
||||
content: poll_params[:question],
|
||||
content_type: :poll
|
||||
)
|
||||
end
|
||||
|
||||
def build_poll
|
||||
@message.create_poll!(
|
||||
question: poll_params[:question],
|
||||
multiple_choice: poll_params[:multiple_choice] || false,
|
||||
public_results: poll_params.fetch(:public_results, true),
|
||||
allow_revote: poll_params.fetch(:allow_revote, true),
|
||||
expires_at: poll_params[:expires_at]
|
||||
)
|
||||
end
|
||||
|
||||
def validate_vote!
|
||||
raise ActionController::BadRequest, 'Poll has expired' if @poll.expired?
|
||||
|
||||
existing_votes = existing_user_votes
|
||||
return unless existing_votes.exists?
|
||||
|
||||
raise ActionController::BadRequest, 'Revoting is not allowed' unless @poll.allow_revote
|
||||
|
||||
if @poll.multiple_choice
|
||||
raise ActionController::BadRequest, 'Already voted for this option' if @option.votes.exists?(user_id: Current.user.id)
|
||||
else
|
||||
existing_votes.destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
def existing_user_votes
|
||||
InternalChat::PollVote.joins(:option).where(
|
||||
internal_chat_poll_options: { internal_chat_poll_id: @poll.id },
|
||||
user_id: Current.user.id
|
||||
)
|
||||
end
|
||||
|
||||
def create_poll_options
|
||||
poll_params[:options].each_with_index do |option_attrs, index|
|
||||
@poll.options.create!(
|
||||
text: option_attrs[:text],
|
||||
emoji: option_attrs[:emoji],
|
||||
image_url: option_attrs[:image_url],
|
||||
position: index
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def poll_params
|
||||
params.permit(:question, :multiple_choice, :public_results, :allow_revote, :expires_at, :channel_id,
|
||||
options: [:text, :emoji, :image_url])
|
||||
end
|
||||
|
||||
def message_with_poll_response(message, poll)
|
||||
{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
content_type: message.content_type,
|
||||
content_attributes: (message.content_attributes || {}).merge(poll: poll_response(poll)),
|
||||
internal_chat_channel_id: message.internal_chat_channel_id,
|
||||
sender: message.sender.push_event_data,
|
||||
parent_id: message.parent_id,
|
||||
created_at: message.created_at,
|
||||
updated_at: message.updated_at,
|
||||
attachments: [],
|
||||
reactions: []
|
||||
}
|
||||
end
|
||||
|
||||
def poll_response(poll)
|
||||
{
|
||||
id: poll.id,
|
||||
question: poll.question,
|
||||
multiple_choice: poll.multiple_choice,
|
||||
public_results: poll.public_results,
|
||||
allow_revote: poll.allow_revote,
|
||||
expires_at: poll.expires_at,
|
||||
internal_chat_message_id: poll.internal_chat_message_id,
|
||||
options: poll.options.ordered.includes(votes: :user).map { |option| option_response(option, poll) },
|
||||
total_votes: poll.total_votes_count,
|
||||
created_at: poll.created_at,
|
||||
updated_at: poll.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def option_response(option, poll)
|
||||
response = {
|
||||
id: option.id,
|
||||
text: option.text,
|
||||
emoji: option.emoji,
|
||||
image_url: option.image_url,
|
||||
position: option.position,
|
||||
votes_count: option.votes_count,
|
||||
voted: option.votes.any? { |v| v.user_id == Current.user.id }
|
||||
}
|
||||
response[:voters] = option.votes.map { |v| v.user.push_event_data } if poll.public_results
|
||||
response
|
||||
end
|
||||
|
||||
def dispatch_message_created_event
|
||||
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_MESSAGE_CREATED, Time.zone.now, message: @message)
|
||||
end
|
||||
|
||||
def dispatch_poll_event
|
||||
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_POLL_VOTED, Time.zone.now, poll: @poll, message: @poll.message)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,54 @@
|
||||
class Api::V1::Accounts::InternalChat::ReactionsController < Api::V1::Accounts::InternalChat::BaseController
|
||||
include Events::Types
|
||||
|
||||
before_action :fetch_message
|
||||
|
||||
def create
|
||||
@reaction = @message.reactions.build(user: Current.user, emoji: reaction_params[:emoji])
|
||||
authorize @reaction, :create?, policy_class: InternalChat::ReactionPolicy
|
||||
@reaction.save!
|
||||
dispatch_reaction_event(INTERNAL_CHAT_REACTION_CREATED, reaction: @reaction)
|
||||
render json: reaction_response(@reaction), status: :created
|
||||
end
|
||||
|
||||
def destroy
|
||||
@reaction = @message.reactions.find(params[:id])
|
||||
authorize @reaction, :destroy?, policy_class: InternalChat::ReactionPolicy
|
||||
reaction_data = {
|
||||
id: @reaction.id,
|
||||
message_id: @reaction.internal_chat_message_id,
|
||||
channel_id: @message.internal_chat_channel_id,
|
||||
account_id: @message.account_id,
|
||||
user_id: @reaction.user_id,
|
||||
emoji: @reaction.emoji
|
||||
}
|
||||
@reaction.destroy!
|
||||
dispatch_reaction_event(INTERNAL_CHAT_REACTION_DELETED, reaction_data: reaction_data)
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_message
|
||||
@message = InternalChat::Message.joins(:channel).where(internal_chat_channels: { account_id: Current.account.id }).find(params[:message_id])
|
||||
end
|
||||
|
||||
def reaction_response(reaction)
|
||||
{
|
||||
id: reaction.id,
|
||||
emoji: reaction.emoji,
|
||||
user_id: reaction.user_id,
|
||||
user: { name: reaction.user&.name },
|
||||
internal_chat_message_id: reaction.internal_chat_message_id,
|
||||
created_at: reaction.created_at
|
||||
}
|
||||
end
|
||||
|
||||
def reaction_params
|
||||
params.permit(:emoji)
|
||||
end
|
||||
|
||||
def dispatch_reaction_event(event, **data)
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, **data)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,19 @@
|
||||
class Api::V1::Accounts::InternalChat::SearchController < Api::V1::Accounts::BaseController
|
||||
def show
|
||||
authorize InternalChat::Channel, :index?
|
||||
|
||||
result = InternalChat::SearchService.new(
|
||||
current_user: Current.user,
|
||||
current_account: Current.account,
|
||||
params: search_params
|
||||
).perform
|
||||
|
||||
render json: result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_params
|
||||
params.permit(:q, :page)
|
||||
end
|
||||
end
|
||||
@ -15,6 +15,7 @@ class AsyncDispatcher < BaseDispatcher
|
||||
CsatSurveyListener.instance,
|
||||
HookListener.instance,
|
||||
InstallationWebhookListener.instance,
|
||||
InternalChatListener.instance,
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
ReportingEventListener.instance,
|
||||
|
||||
72
app/javascript/dashboard/api/internalChatChannels.js
Normal file
72
app/javascript/dashboard/api/internalChatChannels.js
Normal file
@ -0,0 +1,72 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class InternalChatChannelsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('internal_chat/channels', { accountScoped: true });
|
||||
}
|
||||
|
||||
getWithParams(params) {
|
||||
return axios.get(this.url, { params });
|
||||
}
|
||||
|
||||
getCategories() {
|
||||
return axios.get(`${this.url.replace('/channels', '/categories')}`);
|
||||
}
|
||||
|
||||
createCategory(data) {
|
||||
return axios.post(`${this.url.replace('/channels', '/categories')}`, data);
|
||||
}
|
||||
|
||||
deleteCategory(categoryId) {
|
||||
return axios.delete(
|
||||
`${this.url.replace('/channels', '/categories')}/${categoryId}`
|
||||
);
|
||||
}
|
||||
|
||||
archive(channelId) {
|
||||
return axios.post(`${this.url}/${channelId}/archive`);
|
||||
}
|
||||
|
||||
unarchive(channelId) {
|
||||
return axios.post(`${this.url}/${channelId}/unarchive`);
|
||||
}
|
||||
|
||||
getMembers(channelId) {
|
||||
return axios.get(`${this.url}/${channelId}/members`);
|
||||
}
|
||||
|
||||
addMember(channelId, userId) {
|
||||
return axios.post(`${this.url}/${channelId}/members`, { user_id: userId });
|
||||
}
|
||||
|
||||
removeMember(channelId, memberId) {
|
||||
return axios.delete(`${this.url}/${channelId}/members/${memberId}`);
|
||||
}
|
||||
|
||||
updateMember(channelId, memberId, data) {
|
||||
return axios.patch(`${this.url}/${channelId}/members/${memberId}`, data);
|
||||
}
|
||||
|
||||
toggleTypingStatus(channelId, typingStatus) {
|
||||
return axios.post(`${this.url}/${channelId}/toggle_typing_status`, {
|
||||
typing_status: typingStatus,
|
||||
});
|
||||
}
|
||||
|
||||
markRead(channelId) {
|
||||
return axios.post(`${this.url}/${channelId}/mark_read`);
|
||||
}
|
||||
|
||||
markUnread(channelId, messageId) {
|
||||
return axios.post(`${this.url}/${channelId}/mark_unread`, {
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
|
||||
search(params) {
|
||||
return axios.get(`${this.url.replace('/channels', '/search')}`, { params });
|
||||
}
|
||||
}
|
||||
|
||||
export default new InternalChatChannelsAPI();
|
||||
24
app/javascript/dashboard/api/internalChatDrafts.js
Normal file
24
app/javascript/dashboard/api/internalChatDrafts.js
Normal file
@ -0,0 +1,24 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class InternalChatDraftsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('internal_chat', { accountScoped: true });
|
||||
}
|
||||
|
||||
getDrafts() {
|
||||
return axios.get(`${this.url}/drafts`);
|
||||
}
|
||||
|
||||
saveDraft(channelId, data) {
|
||||
return axios.patch(`${this.url}/channels/${channelId}/draft`, data);
|
||||
}
|
||||
|
||||
deleteDraft(channelId, { parentId } = {}) {
|
||||
return axios.delete(`${this.url}/channels/${channelId}/draft`, {
|
||||
params: { parent_id: parentId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new InternalChatDraftsAPI();
|
||||
62
app/javascript/dashboard/api/internalChatMessages.js
Normal file
62
app/javascript/dashboard/api/internalChatMessages.js
Normal file
@ -0,0 +1,62 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class InternalChatMessagesAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('internal_chat/channels', { accountScoped: true });
|
||||
}
|
||||
|
||||
getMessages(channelId, params = {}) {
|
||||
return axios.get(`${this.url}/${channelId}/messages`, { params });
|
||||
}
|
||||
|
||||
createMessage(channelId, data, files = []) {
|
||||
if (files.length === 0) {
|
||||
return axios.post(`${this.url}/${channelId}/messages`, data);
|
||||
}
|
||||
const formData = new FormData();
|
||||
if (data.content) formData.append('content', data.content);
|
||||
if (data.parent_id) formData.append('parent_id', data.parent_id);
|
||||
if (data.echo_id) formData.append('echo_id', data.echo_id);
|
||||
files.forEach(file => {
|
||||
formData.append('attachments[][file]', file);
|
||||
});
|
||||
return axios.post(`${this.url}/${channelId}/messages`, formData);
|
||||
}
|
||||
|
||||
updateMessage(channelId, messageId, data) {
|
||||
return axios.patch(`${this.url}/${channelId}/messages/${messageId}`, data);
|
||||
}
|
||||
|
||||
deleteMessage(channelId, messageId) {
|
||||
return axios.delete(`${this.url}/${channelId}/messages/${messageId}`);
|
||||
}
|
||||
|
||||
getThread(channelId, messageId) {
|
||||
return axios.get(`${this.url}/${channelId}/messages/${messageId}/thread`);
|
||||
}
|
||||
|
||||
pinMessage(channelId, messageId) {
|
||||
return axios.post(`${this.url}/${channelId}/messages/${messageId}/pin`);
|
||||
}
|
||||
|
||||
unpinMessage(channelId, messageId) {
|
||||
return axios.delete(`${this.url}/${channelId}/messages/${messageId}/unpin`);
|
||||
}
|
||||
|
||||
addReaction(messageId, emoji) {
|
||||
const baseUrl = this.url.replace('/channels', '');
|
||||
return axios.post(`${baseUrl}/messages/${messageId}/reactions`, {
|
||||
emoji,
|
||||
});
|
||||
}
|
||||
|
||||
removeReaction(messageId, reactionId) {
|
||||
const baseUrl = this.url.replace('/channels', '');
|
||||
return axios.delete(
|
||||
`${baseUrl}/messages/${messageId}/reactions/${reactionId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InternalChatMessagesAPI();
|
||||
24
app/javascript/dashboard/api/internalChatPolls.js
Normal file
24
app/javascript/dashboard/api/internalChatPolls.js
Normal file
@ -0,0 +1,24 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class InternalChatPollsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('internal_chat/polls', { accountScoped: true });
|
||||
}
|
||||
|
||||
createPoll(data) {
|
||||
return axios.post(this.url, data);
|
||||
}
|
||||
|
||||
vote(pollId, optionId) {
|
||||
return axios.post(`${this.url}/${pollId}/vote`, { option_id: optionId });
|
||||
}
|
||||
|
||||
unvote(pollId, optionId) {
|
||||
return axios.delete(`${this.url}/${pollId}/vote`, {
|
||||
params: { option_id: optionId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new InternalChatPollsAPI();
|
||||
@ -118,22 +118,25 @@ defineExpose({ open, close });
|
||||
<TeleportWithDirection to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||
class="w-full max-h-[90vh] transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||
:class="[
|
||||
maxWidthClass,
|
||||
positionClass,
|
||||
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
||||
overflowYAuto ? 'overflow-y-auto' : 'overflow-hidden',
|
||||
]"
|
||||
@close.prevent="handleDialogClose"
|
||||
>
|
||||
<OnClickOutside @trigger="handleClickOutside">
|
||||
<form
|
||||
ref="dialogContentRef"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
class="flex flex-col w-full max-h-[90vh] gap-6 p-6 overflow-hidden text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
@submit.prevent="confirm"
|
||||
@click.stop
|
||||
>
|
||||
<div v-if="title || description" class="flex flex-col gap-2">
|
||||
<div
|
||||
v-if="title || description"
|
||||
class="flex flex-col gap-2 flex-shrink-0"
|
||||
>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ title }}
|
||||
</h3>
|
||||
@ -143,12 +146,14 @@ defineExpose({ open, close });
|
||||
</p>
|
||||
</slot>
|
||||
</div>
|
||||
<slot v-if="isOpen" />
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<slot v-if="isOpen" />
|
||||
</div>
|
||||
<!-- Dialog content will be injected here -->
|
||||
<slot name="footer">
|
||||
<div
|
||||
v-if="showCancelButton || showConfirmButton"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
class="flex items-center justify-between w-full gap-3 flex-shrink-0"
|
||||
>
|
||||
<Button
|
||||
v-if="showCancelButton"
|
||||
|
||||
@ -317,6 +317,23 @@ const menuItems = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'InternalChat',
|
||||
label: t('SIDEBAR.INTERNAL_CHAT'),
|
||||
icon: 'i-lucide-messages-square',
|
||||
to: accountScopedRoute('internal_chat_home'),
|
||||
activeOn: [
|
||||
'internal_chat',
|
||||
'internal_chat_home',
|
||||
'internal_chat_channel',
|
||||
'internal_chat_dm',
|
||||
'internal_chat_thread',
|
||||
'internal_chat_drafts',
|
||||
],
|
||||
getterKeys: {
|
||||
count: 'internalChat/getUnreadCount',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Kanban',
|
||||
label: t('SIDEBAR.KANBAN'),
|
||||
|
||||
@ -17,6 +17,7 @@ import TagAgents from '../conversation/TagAgents.vue';
|
||||
import TagGroupMembers from '../conversation/TagGroupMembers.vue';
|
||||
import VariableList from '../conversation/VariableList.vue';
|
||||
import TagTools from '../conversation/TagTools.vue';
|
||||
import TagConversations from '../conversation/TagConversations.vue';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
@ -101,6 +102,8 @@ const props = defineProps({
|
||||
isGroupConversation: { type: Boolean, default: false },
|
||||
groupContactId: { type: [Number, String], default: null },
|
||||
inboxPhoneNumber: { type: String, default: null },
|
||||
enableMentionDropdown: { type: Boolean, default: false },
|
||||
enableConversationMention: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@ -116,6 +119,7 @@ const emit = defineEmits([
|
||||
'input',
|
||||
'update:modelValue',
|
||||
'executeCopilotAction',
|
||||
'toggleConversationMention',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
@ -204,6 +208,8 @@ const toolSearchKey = ref('');
|
||||
const cannedSearchTerm = ref('');
|
||||
const variableSearchTerm = ref('');
|
||||
const emojiSearchTerm = ref('');
|
||||
const showConversationMenu = ref(false);
|
||||
const conversationSearchKey = ref('');
|
||||
const range = ref(null);
|
||||
const isImageNodeSelected = ref(false);
|
||||
const toolbarPosition = ref({ top: 0, left: 0 });
|
||||
@ -325,6 +331,13 @@ const plugins = computed(() => {
|
||||
showMenu: showEmojiMenu,
|
||||
searchTerm: emojiSearchTerm,
|
||||
}),
|
||||
createSuggestionPlugin({
|
||||
trigger: '#',
|
||||
minChars: 0,
|
||||
showMenu: showConversationMenu,
|
||||
searchTerm: conversationSearchKey,
|
||||
isAllowed: () => props.enableConversationMention,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
@ -365,7 +378,10 @@ const formattedSignature = computed(() => {
|
||||
watch(showUserMentions, updatedValue => {
|
||||
emit(
|
||||
'toggleUserMention',
|
||||
(props.isPrivate || props.isGroupConversation) && updatedValue
|
||||
(props.isPrivate ||
|
||||
props.isGroupConversation ||
|
||||
props.enableMentionDropdown) &&
|
||||
updatedValue
|
||||
);
|
||||
});
|
||||
watch(showCannedMenu, updatedValue => {
|
||||
@ -378,6 +394,13 @@ watch(showToolsMenu, updatedValue => {
|
||||
emit('toggleToolsMenu', props.enableCaptainTools && updatedValue);
|
||||
});
|
||||
|
||||
watch(showConversationMenu, updatedValue => {
|
||||
emit(
|
||||
'toggleConversationMention',
|
||||
props.enableConversationMention && updatedValue
|
||||
);
|
||||
});
|
||||
|
||||
function focusEditorInputField(pos = 'end') {
|
||||
const { tr } = editorView.state;
|
||||
|
||||
@ -825,6 +848,22 @@ onMounted(() => {
|
||||
// Components using this
|
||||
// 1. SearchPopover.vue
|
||||
useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
|
||||
function insertMentionTrigger(char) {
|
||||
if (!editorView) return;
|
||||
focusEditorInputField('end');
|
||||
const editorState = editorView.state;
|
||||
const { from, to } = editorState.selection;
|
||||
const textBefore =
|
||||
from > 0
|
||||
? editorState.doc.textBetween(Math.max(0, from - 1), from, '\0', '\0')
|
||||
: '';
|
||||
const prefix = textBefore && !/\s/.test(textBefore) ? ' ' : '';
|
||||
const tr = editorState.tr.insertText(`${prefix}${char}`, from, to);
|
||||
editorView.dispatch(tr);
|
||||
}
|
||||
|
||||
defineExpose({ insertMentionTrigger });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -843,8 +882,9 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@select-agent="content => insertSpecialContent('mention', content)"
|
||||
/>
|
||||
<TagAgents
|
||||
v-if="showUserMentions && isPrivate"
|
||||
v-if="showUserMentions && (isPrivate || enableMentionDropdown)"
|
||||
:search-key="mentionSearchKey"
|
||||
:exclude-user-id="enableMentionDropdown ? currentUser?.id : null"
|
||||
@select-agent="content => insertSpecialContent('mention', content)"
|
||||
/>
|
||||
<CannedResponse
|
||||
@ -867,6 +907,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
:search-key="toolSearchKey"
|
||||
@select-tool="content => insertSpecialContent('tool', content)"
|
||||
/>
|
||||
<TagConversations
|
||||
v-if="showConversationMenu && enableConversationMention"
|
||||
:search-key="conversationSearchKey"
|
||||
@select-conversation="content => insertSpecialContent('mention', content)"
|
||||
/>
|
||||
<CopilotMenuBar
|
||||
v-if="showSelectionMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
@ -1083,6 +1128,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
}
|
||||
}
|
||||
|
||||
.prosemirror-mention-node[mention-type='conversation'] {
|
||||
font-size: 0;
|
||||
|
||||
&::before {
|
||||
font-size: 0.875rem;
|
||||
content: '#' attr(mention-user-full-name);
|
||||
}
|
||||
}
|
||||
|
||||
.prosemirror-tools-node {
|
||||
@apply font-medium text-n-slate-12 py-0;
|
||||
}
|
||||
|
||||
@ -10,6 +10,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
excludeUserId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectAgent']);
|
||||
@ -27,6 +31,7 @@ const items = computed(() => {
|
||||
|
||||
const buildItems = (list, type, infoKey) =>
|
||||
list
|
||||
.filter(item => !props.excludeUserId || item.id !== props.excludeUserId)
|
||||
.map(item => ({
|
||||
...item,
|
||||
type,
|
||||
|
||||
@ -0,0 +1,159 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
import SearchAPI from 'dashboard/api/search';
|
||||
|
||||
const props = defineProps({
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectConversation']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const listRef = ref(null);
|
||||
const selectedIndex = ref(0);
|
||||
const loading = ref(false);
|
||||
const conversations = ref([]);
|
||||
|
||||
let debounceTimer = null;
|
||||
|
||||
const items = computed(() => {
|
||||
return conversations.value.map(conv => ({
|
||||
id: conv.id,
|
||||
type: 'conversation',
|
||||
displayName: String(conv.id),
|
||||
name: String(conv.id),
|
||||
contactName: conv.contact?.name || '',
|
||||
contactThumbnail: conv.contact?.thumbnail || '',
|
||||
inboxName: conv.inbox?.name || '',
|
||||
}));
|
||||
});
|
||||
|
||||
async function fetchConversations(query) {
|
||||
if (!query || !query.trim()) {
|
||||
conversations.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await SearchAPI.conversations({ q: query.trim() });
|
||||
conversations.value = data.payload?.conversations || [];
|
||||
} catch {
|
||||
conversations.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.searchKey,
|
||||
newKey => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => fetchConversations(newKey), 300);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(items, newItems => {
|
||||
if (newItems.length < selectedIndex.value + 1) {
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const adjustScroll = () => {
|
||||
nextTick(() => {
|
||||
if (listRef.value) {
|
||||
const el = listRef.value.querySelector(
|
||||
`#conversation-item-${selectedIndex.value}`
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
const item = items.value[selectedIndex.value];
|
||||
if (item) emit('selectConversation', item);
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
const onHover = index => {
|
||||
selectedIndex.value = index;
|
||||
};
|
||||
|
||||
const onItemSelect = index => {
|
||||
selectedIndex.value = index;
|
||||
onSelect();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ul
|
||||
v-if="items.length"
|
||||
ref="listRef"
|
||||
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-[28rem] max-w-[calc(100vw-2rem)] z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
|
||||
role="listbox"
|
||||
>
|
||||
<li
|
||||
class="px-2 py-1.5 text-xs font-medium tracking-wide capitalize text-n-slate-11"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.CONVERSATION_MENTION.HEADER') }}
|
||||
</li>
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:id="`conversation-item-${index}`"
|
||||
:key="item.id"
|
||||
>
|
||||
<div
|
||||
:class="{ 'bg-n-alpha-black2': index === selectedIndex }"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded-md cursor-pointer"
|
||||
role="option"
|
||||
@click="onItemSelect(index)"
|
||||
@mouseover="onHover(index)"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 font-medium text-n-brand"
|
||||
:class="{ 'text-n-brand': index === selectedIndex }"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.CONVERSATION_MENTION.PREFIX') }}{{ item.id }}
|
||||
</span>
|
||||
<span
|
||||
class="flex-1 truncate text-n-slate-11"
|
||||
:class="{ 'text-n-slate-12': index === selectedIndex }"
|
||||
>
|
||||
{{ item.contactName }}
|
||||
</span>
|
||||
<span class="flex-shrink-0 text-xs text-n-slate-10 truncate max-w-24">
|
||||
{{ item.inboxName }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<ul
|
||||
v-else-if="!loading"
|
||||
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-[28rem] max-w-[calc(100vw-2rem)] z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
|
||||
>
|
||||
<li class="px-2 py-2 text-xs text-n-slate-10">
|
||||
{{
|
||||
searchKey?.trim()
|
||||
? t('INTERNAL_CHAT.CONVERSATION_MENTION.NO_RESULTS')
|
||||
: t('INTERNAL_CHAT.CONVERSATION_MENTION.TYPE_TO_SEARCH')
|
||||
}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import TagConversations from '../TagConversations.vue';
|
||||
import SearchAPI from 'dashboard/api/search';
|
||||
|
||||
vi.mock('dashboard/api/search', () => ({
|
||||
default: {
|
||||
conversations: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useKeyboardNavigableList', () => ({
|
||||
useKeyboardNavigableList: vi.fn(),
|
||||
}));
|
||||
|
||||
const CONVERSATIONS = [
|
||||
{
|
||||
id: 42,
|
||||
contact: { name: 'Alice Silva', thumbnail: 'alice.jpg' },
|
||||
inbox: { name: 'Email Support' },
|
||||
},
|
||||
{
|
||||
id: 99,
|
||||
contact: { name: 'Bob Santos', thumbnail: 'bob.jpg' },
|
||||
inbox: { name: 'WhatsApp' },
|
||||
},
|
||||
];
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(TagConversations, {
|
||||
props: {
|
||||
searchKey: '',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: { Avatar: true },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('TagConversations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
SearchAPI.conversations.mockResolvedValue({
|
||||
data: { payload: { conversations: CONVERSATIONS } },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders no list when searchKey is empty', () => {
|
||||
const wrapper = mountComponent({ searchKey: '' });
|
||||
const list = wrapper.find('ul[role="listbox"]');
|
||||
expect(list.exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('fetches and renders conversations after debounced search', async () => {
|
||||
vi.useFakeTimers();
|
||||
const wrapper = mountComponent({ searchKey: '42' });
|
||||
vi.advanceTimersByTime(300);
|
||||
await flushPromises();
|
||||
|
||||
expect(SearchAPI.conversations).toHaveBeenCalledWith({ q: '42' });
|
||||
const items = wrapper.findAll('[role="option"]');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].text()).toContain('42');
|
||||
expect(items[0].text()).toContain('Alice Silva');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders conversation items with display id, contact name and inbox', async () => {
|
||||
vi.useFakeTimers();
|
||||
const wrapper = mountComponent({ searchKey: 'test' });
|
||||
vi.advanceTimersByTime(300);
|
||||
await flushPromises();
|
||||
|
||||
const items = wrapper.findAll('[role="option"]');
|
||||
expect(items[0].text()).toContain('42');
|
||||
expect(items[0].text()).toContain('Alice Silva');
|
||||
expect(items[0].text()).toContain('Email Support');
|
||||
expect(items[1].text()).toContain('99');
|
||||
expect(items[1].text()).toContain('Bob Santos');
|
||||
expect(items[1].text()).toContain('WhatsApp');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('emits selectConversation with correct payload on click', async () => {
|
||||
vi.useFakeTimers();
|
||||
const wrapper = mountComponent({ searchKey: 'test' });
|
||||
vi.advanceTimersByTime(300);
|
||||
await flushPromises();
|
||||
|
||||
const firstOption = wrapper.findAll('[role="option"]')[0];
|
||||
await firstOption.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('selectConversation')).toBeTruthy();
|
||||
expect(wrapper.emitted('selectConversation')[0][0]).toMatchObject({
|
||||
id: 42,
|
||||
type: 'conversation',
|
||||
displayName: '42',
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows no results message when search returns empty', async () => {
|
||||
SearchAPI.conversations.mockResolvedValue({
|
||||
data: { payload: { conversations: [] } },
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
const wrapper = mountComponent({ searchKey: 'nonexistent' });
|
||||
vi.advanceTimersByTime(300);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('No conversations found');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders a section header', async () => {
|
||||
vi.useFakeTimers();
|
||||
const wrapper = mountComponent({ searchKey: 'test' });
|
||||
vi.advanceTimersByTime(300);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Conversations');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
26
app/javascript/dashboard/composables/useInternalChatPro.js
Normal file
26
app/javascript/dashboard/composables/useInternalChatPro.js
Normal file
@ -0,0 +1,26 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
/**
|
||||
* Composable for internal chat Pro feature gating.
|
||||
*
|
||||
* In CE, the `internal_chat_pro` feature flag does not exist in features.yml,
|
||||
* so all Pro features remain locked. The Pro repo adds this flag and manages
|
||||
* it via subscription hub, unlocking features automatically.
|
||||
*/
|
||||
export function useInternalChatPro() {
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentRole = useMapGetter('getCurrentRole');
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const proEnabled = computed(() => isCloudFeatureEnabled('internal_chat_pro'));
|
||||
|
||||
return {
|
||||
pollsEnabled: proEnabled,
|
||||
maxPrivateChannels: computed(() => (proEnabled.value ? null : 2)),
|
||||
searchHistoryDays: computed(() => (proEnabled.value ? null : 90)),
|
||||
isSuperAdmin: computed(() => currentUser.value?.type === 'SuperAdmin'),
|
||||
isAdmin: computed(() => currentRole.value === 'administrator'),
|
||||
};
|
||||
}
|
||||
@ -44,6 +44,7 @@ export const FEATURE_FLAGS = {
|
||||
COMPANIES: 'companies',
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
||||
INTERNAL_CHAT: 'internal_chat',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
@ -45,6 +45,15 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.onRecurringScheduledMessageUpdated,
|
||||
'recurring_scheduled_message.deleted':
|
||||
this.onRecurringScheduledMessageDeleted,
|
||||
'internal_chat.channel.updated': this.onInternalChatChannelUpdated,
|
||||
'internal_chat.message.created': this.onInternalChatMessageCreated,
|
||||
'internal_chat.message.updated': this.onInternalChatMessageUpdated,
|
||||
'internal_chat.message.deleted': this.onInternalChatMessageDeleted,
|
||||
'internal_chat.typing_on': this.onInternalChatTypingOn,
|
||||
'internal_chat.typing_off': this.onInternalChatTypingOff,
|
||||
'internal_chat.reaction.created': this.onInternalChatReactionCreated,
|
||||
'internal_chat.reaction.deleted': this.onInternalChatReactionDeleted,
|
||||
'internal_chat.poll.voted': this.onInternalChatPollVoted,
|
||||
};
|
||||
}
|
||||
|
||||
@ -252,6 +261,111 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
};
|
||||
|
||||
onInternalChatMessageCreated = data => {
|
||||
this.app.$store.dispatch('internalChat/messages/addMessageFromCable', {
|
||||
channelId: data.internal_chat_channel_id,
|
||||
message: data,
|
||||
});
|
||||
const channel = this.app.$store.getters['internalChat/getChannelById'](
|
||||
data.internal_chat_channel_id
|
||||
);
|
||||
if (channel) {
|
||||
const currentUserId = this.app.$store.getters.getCurrentUser?.id;
|
||||
const isOwnMessage = data.sender?.id === currentUserId;
|
||||
const activeChannelId =
|
||||
this.app.$store.getters['internalChat/getActiveChannelId'];
|
||||
const isActiveChannel = activeChannelId === data.internal_chat_channel_id;
|
||||
const mentionedIds = data.content_attributes?.mentioned_user_ids || [];
|
||||
const isMentioned = mentionedIds.includes(currentUserId);
|
||||
this.app.$store.dispatch('internalChat/updateChannel', {
|
||||
id: data.internal_chat_channel_id,
|
||||
unread_count:
|
||||
isActiveChannel || isOwnMessage
|
||||
? channel.unread_count || 0
|
||||
: (channel.unread_count || 0) + 1,
|
||||
has_unread_mention:
|
||||
isActiveChannel || isOwnMessage
|
||||
? false
|
||||
: channel.has_unread_mention || isMentioned,
|
||||
last_activity_at: data.created_at,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onInternalChatMessageUpdated = data => {
|
||||
this.app.$store.dispatch('internalChat/messages/updateMessageFromCable', {
|
||||
channelId: data.internal_chat_channel_id,
|
||||
message: data,
|
||||
});
|
||||
};
|
||||
|
||||
onInternalChatMessageDeleted = data => {
|
||||
this.app.$store.dispatch('internalChat/messages/deleteMessageFromCable', {
|
||||
channelId: data.internal_chat_channel_id,
|
||||
messageId: data.id,
|
||||
});
|
||||
};
|
||||
|
||||
onInternalChatTypingOn = ({ channel, user }) => {
|
||||
this.app.$store.dispatch('internalChatTypingStatus/create', {
|
||||
channelId: channel.id,
|
||||
user,
|
||||
});
|
||||
};
|
||||
|
||||
onInternalChatTypingOff = ({ channel, user }) => {
|
||||
this.app.$store.dispatch('internalChatTypingStatus/destroy', {
|
||||
channelId: channel.id,
|
||||
user,
|
||||
});
|
||||
};
|
||||
|
||||
onInternalChatReactionCreated = data => {
|
||||
this.app.$store.dispatch('internalChat/messages/addReactionFromCable', {
|
||||
channelId: data.internal_chat_channel_id,
|
||||
messageId: data.message_id,
|
||||
reaction: data,
|
||||
});
|
||||
};
|
||||
|
||||
onInternalChatReactionDeleted = data => {
|
||||
this.app.$store.dispatch('internalChat/messages/removeReactionFromCable', {
|
||||
channelId: data.internal_chat_channel_id || data.channel_id,
|
||||
messageId: data.message_id,
|
||||
reactionId: data.id,
|
||||
});
|
||||
};
|
||||
|
||||
onInternalChatChannelUpdated = data => {
|
||||
const currentUserId = this.app.$store.getters.getCurrentUser?.id;
|
||||
const memberIds = data.member_user_ids;
|
||||
|
||||
if (memberIds && currentUserId && data.channel_type === 'private_channel') {
|
||||
if (!memberIds.includes(currentUserId)) {
|
||||
// Current user was removed from channel
|
||||
this.app.$store.commit('internalChat/DELETE_CHANNEL', data.id);
|
||||
return;
|
||||
}
|
||||
// Current user was added: if channel not in store, refetch channels
|
||||
const existing = this.app.$store.getters['internalChat/getChannelById'](
|
||||
data.id
|
||||
);
|
||||
if (!existing) {
|
||||
this.app.$store.dispatch('internalChat/get');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.app.$store.dispatch('internalChat/updateChannel', data);
|
||||
};
|
||||
|
||||
onInternalChatPollVoted = data => {
|
||||
this.app.$store.dispatch('internalChat/polls/updatePollFromCable', {
|
||||
channelId: data.internal_chat_channel_id,
|
||||
poll: data,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@ -25,6 +25,7 @@ import inbox from './inbox.json';
|
||||
import inboxMgmt from './inboxMgmt.json';
|
||||
import integrationApps from './integrationApps.json';
|
||||
import integrations from './integrations.json';
|
||||
import internalChat from './internalChat.json';
|
||||
import kanban from './kanban.json';
|
||||
import labelsMgmt from './labelsMgmt.json';
|
||||
import login from './login.json';
|
||||
@ -71,6 +72,7 @@ export default {
|
||||
...inboxMgmt,
|
||||
...integrationApps,
|
||||
...integrations,
|
||||
...internalChat,
|
||||
...kanban,
|
||||
...labelsMgmt,
|
||||
...login,
|
||||
|
||||
@ -60,7 +60,11 @@
|
||||
"CONTACT_UPDATED": "Contact updated",
|
||||
"CONVERSATION_TYPING_ON": "Conversation Typing On",
|
||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
|
||||
"PROVIDER_EVENT_RECEIVED": "Provider Event Received"
|
||||
"PROVIDER_EVENT_RECEIVED": "Provider Event Received",
|
||||
"INTERNAL_CHAT_MESSAGE_CREATED": "Internal chat message created",
|
||||
"INTERNAL_CHAT_MESSAGE_UPDATED": "Internal chat message updated",
|
||||
"INTERNAL_CHAT_MESSAGE_DELETED": "Internal chat message deleted",
|
||||
"INTERNAL_CHAT_CHANNEL_UPDATED": "Internal chat channel updated"
|
||||
}
|
||||
},
|
||||
"NAME": {
|
||||
|
||||
192
app/javascript/dashboard/i18n/locale/en/internalChat.json
Normal file
192
app/javascript/dashboard/i18n/locale/en/internalChat.json
Normal file
@ -0,0 +1,192 @@
|
||||
{
|
||||
"INTERNAL_CHAT": {
|
||||
"TITLE": "Internal Chat",
|
||||
"CHANNELS": "Channels",
|
||||
"DIRECT_MESSAGES": "Direct Messages",
|
||||
"FAVORITES": "Favorites",
|
||||
"NEW_CHANNEL": "New Channel",
|
||||
"NEW_DM": "New Message",
|
||||
"SEARCH_PLACEHOLDER": "Find in chat...",
|
||||
"SEARCH": {
|
||||
"CHANNELS": "Channels",
|
||||
"DIRECT_MESSAGES": "Direct Messages",
|
||||
"MESSAGES": "Messages",
|
||||
"NO_RESULTS": "No results found",
|
||||
"NO_RESULTS_SUBTITLE": "Try a different search term or check the spelling.",
|
||||
"LOAD_MORE": "Load more messages",
|
||||
"SEARCHING": "Searching...",
|
||||
"MIN_CHARS_HINT": "Type at least 3 characters to search."
|
||||
},
|
||||
"NO_CHANNELS": "No channels found",
|
||||
"CHANNEL": {
|
||||
"NAME": "Channel Name",
|
||||
"DESCRIPTION": "Description",
|
||||
"TYPE": "Channel Type",
|
||||
"PUBLIC": "Public",
|
||||
"PRIVATE": "Private",
|
||||
"MEMBERS": "Members",
|
||||
"ARCHIVE": "Archive Channel",
|
||||
"UNARCHIVE": "Unarchive Channel",
|
||||
"DELETE": "Delete Channel",
|
||||
"MUTE": "Mute Channel",
|
||||
"UNMUTE": "Unmute Channel",
|
||||
"FAVORITE": "Add to Favorites",
|
||||
"UNFAVORITE": "Remove from Favorites",
|
||||
"MARK_UNREAD": "Mark as Unread",
|
||||
"NO_MESSAGES": "No messages yet",
|
||||
"NO_MESSAGES_SUBTITLE": "Be the first to say something in this channel.",
|
||||
"ARCHIVED": "This channel is archived",
|
||||
"MEMBER_COUNT": "{count} member | {count} members",
|
||||
"SETTINGS": "Channel settings",
|
||||
"INFO": "Channel Info",
|
||||
"CREATED_AT": "Created",
|
||||
"CONFIRM_DELETE": "Are you sure you want to delete this channel?",
|
||||
"ACTIONS": "Actions",
|
||||
"CREATED": "Channel created successfully",
|
||||
"ADMIN": "Admin",
|
||||
"YOU": "(you)",
|
||||
"NO_MEMBERS": "No members",
|
||||
"ADD_MEMBER": "Add member...",
|
||||
"REMOVE_MEMBER": "Remove member",
|
||||
"EDIT_MEMBERS": "Edit members",
|
||||
"SAVE_MEMBERS": "Save",
|
||||
"MEMBERS_UPDATED": "Members updated successfully",
|
||||
"ALL_AGENTS_NOTE": "This channel will be visible to all users",
|
||||
"CLOSE_DM": "Close conversation",
|
||||
"SELECT_TEAMS": "Select Teams",
|
||||
"SELECT_AGENTS": "Select Agents"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"PLACEHOLDER": "Type a message...",
|
||||
"EDIT": "Edit",
|
||||
"DELETE": "Delete",
|
||||
"REPLY": "Reply",
|
||||
"REACT": "React",
|
||||
"EDITED": "(edited)",
|
||||
"DELETED": "This message was deleted",
|
||||
"SEND": "Send",
|
||||
"UPLOAD_FILE": "Upload File",
|
||||
"MENTION_USER": "Mention a user",
|
||||
"MENTION_CONVERSATION": "Mention a conversation",
|
||||
"CONFIRM_DELETE": "Are you sure you want to delete this message?",
|
||||
"EDITING": "Editing message",
|
||||
"CANCEL_EDIT": "Cancel editing",
|
||||
"BOLD": "Bold",
|
||||
"ITALIC": "Italic",
|
||||
"CODE": "Code",
|
||||
"COPY_LINK": "Copy link",
|
||||
"LINK_COPIED": "Message link copied to clipboard",
|
||||
"DELETED_USER": "Deleted User"
|
||||
},
|
||||
"THREAD": {
|
||||
"TITLE": "Thread",
|
||||
"CLOSE": "Close thread",
|
||||
"REPLIES": "{count} replies",
|
||||
"REPLY_PLACEHOLDER": "Reply in thread...",
|
||||
"ALSO_SEND_IN_CHANNEL": "Also send to conversation"
|
||||
},
|
||||
"POLL": {
|
||||
"CREATE": "Create Poll",
|
||||
"QUESTION": "Question",
|
||||
"OPTIONS": "Options",
|
||||
"MULTIPLE_CHOICE": "Multiple choice",
|
||||
"DURATION": "Duration",
|
||||
"DURATION_24H": "24 hours",
|
||||
"DURATION_7D": "7 days",
|
||||
"DURATION_14D": "14 days",
|
||||
"DURATION_30D": "30 days",
|
||||
"PUBLIC_RESULTS": "Public results",
|
||||
"VOTE": "Vote",
|
||||
"VOTES": "{count} votes",
|
||||
"EXPIRED": "Expired",
|
||||
"PIN": "Pin poll",
|
||||
"ADD_OPTION": "Add option",
|
||||
"CANCEL": "Cancel",
|
||||
"PERCENTAGE": "{value}%",
|
||||
"TIME_LEFT": {
|
||||
"DAYS": "{count}d left",
|
||||
"HOURS_MINUTES": "{hours}h {minutes}m left",
|
||||
"MINUTES": "{count}m left"
|
||||
},
|
||||
"DISCARD_TITLE": "Discard poll?",
|
||||
"DISCARD_DESCRIPTION": "You have unsaved changes. Are you sure you want to discard this poll?",
|
||||
"DISCARD": "Discard"
|
||||
},
|
||||
"DRAFT": {
|
||||
"TITLE": "Drafts",
|
||||
"LABEL": "Draft",
|
||||
"NO_DRAFTS": "No drafts yet",
|
||||
"NO_DRAFTS_SUBTITLE": "Drafts are saved automatically when you start typing in a channel.",
|
||||
"DELETE": "Delete draft",
|
||||
"SAVED_AGO": "Saved {time} ago",
|
||||
"CHANNEL_LABEL": "Channel #{channelId}"
|
||||
},
|
||||
"PIN": {
|
||||
"PINNED_MESSAGE": "Pinned message",
|
||||
"PIN": "Pin message",
|
||||
"UNPIN": "Unpin message"
|
||||
},
|
||||
"TYPING": {
|
||||
"SINGLE": "{name} is typing...",
|
||||
"MULTIPLE": "{names} are typing..."
|
||||
},
|
||||
"DM": {
|
||||
"NEW": "New Direct Message",
|
||||
"SELECT_AGENTS": "Select agents"
|
||||
},
|
||||
"CATEGORY": {
|
||||
"CREATE": "Create Category",
|
||||
"NAME": "Category Name",
|
||||
"NAME_PLACEHOLDER": "Enter category name",
|
||||
"NONE": "None",
|
||||
"CREATED": "Category created successfully",
|
||||
"DELETE": "Delete Category",
|
||||
"DELETE_DESCRIPTION": "Channels in this category will be moved to uncategorized."
|
||||
},
|
||||
"ERRORS": {
|
||||
"FETCH_CHANNELS": "Failed to load channels",
|
||||
"FETCH_MESSAGES": "Failed to load messages",
|
||||
"SEND_MESSAGE": "Failed to send message",
|
||||
"CREATE_CHANNEL": "Failed to create channel"
|
||||
},
|
||||
"DATE_SEPARATOR": {
|
||||
"TODAY": "Today",
|
||||
"YESTERDAY": "Yesterday"
|
||||
},
|
||||
"SCROLL_TO_BOTTOM": "Scroll to bottom",
|
||||
"NEW_MESSAGES": "New messages",
|
||||
"LOADING_MESSAGES": "Loading messages...",
|
||||
"MENTION_BADGE": "@",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "Welcome to Internal Chat",
|
||||
"SUBTITLE": "Pick a channel or conversation from the sidebar to get started."
|
||||
},
|
||||
"CONVERSATION_MENTION": {
|
||||
"NO_RESULTS": "No conversations found",
|
||||
"TYPE_TO_SEARCH": "Type a conversation ID or contact name",
|
||||
"HEADER": "Conversations",
|
||||
"PREFIX": "#",
|
||||
"NO_ACCESS": "You don't have access to this conversation",
|
||||
"STATUS": {
|
||||
"OPEN": "Open",
|
||||
"RESOLVED": "Resolved",
|
||||
"PENDING": "Pending",
|
||||
"SNOOZED": "Snoozed"
|
||||
}
|
||||
},
|
||||
"ARCHIVED": {
|
||||
"TITLE": "Archived",
|
||||
"EMPTY": "No archived channels"
|
||||
},
|
||||
"PRO": {
|
||||
"TITLE": "Upgrade to Pro",
|
||||
"POLLS_DESCRIPTION": "Poll creation is available in the Pro version.",
|
||||
"PRIVATE_CHANNELS_DESCRIPTION": "You've reached the limit of {limit} private channels. Upgrade to Pro for unlimited private channels.",
|
||||
"SEARCH_DESCRIPTION": "Search is limited to the last {days} days. Upgrade to Pro for unlimited search history.",
|
||||
"SEARCH_LIMITED_NOTE": "Results from the last {days} days",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"ADMIN_MESSAGE": "This feature is not available on your current plan. Please reach out to your platform administrator to upgrade.",
|
||||
"AGENT_MESSAGE": "This feature is not available on your current plan. Please reach out to your administrator to upgrade."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -338,6 +338,7 @@
|
||||
"ACTIVE": "Active",
|
||||
"COMPANIES": "Companies",
|
||||
"ALL_COMPANIES": "All Companies",
|
||||
"INTERNAL_CHAT": "Internal Chat",
|
||||
"KANBAN": "Kanban",
|
||||
"CAPTAIN": "Captain",
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
|
||||
@ -26,6 +26,7 @@ import inbox from './inbox.json';
|
||||
import inboxMgmt from './inboxMgmt.json';
|
||||
import integrationApps from './integrationApps.json';
|
||||
import integrations from './integrations.json';
|
||||
import internalChat from './internalChat.json';
|
||||
import kanban from './kanban.json';
|
||||
import labelsMgmt from './labelsMgmt.json';
|
||||
import login from './login.json';
|
||||
@ -73,6 +74,7 @@ export default {
|
||||
...inboxMgmt,
|
||||
...integrationApps,
|
||||
...integrations,
|
||||
...internalChat,
|
||||
...kanban,
|
||||
...labelsMgmt,
|
||||
...login,
|
||||
|
||||
@ -60,7 +60,11 @@
|
||||
"CONTACT_UPDATED": "Contato atualizado",
|
||||
"CONVERSATION_TYPING_ON": "Status de Digitação ativado",
|
||||
"CONVERSATION_TYPING_OFF": "Status de Digitação desativado",
|
||||
"PROVIDER_EVENT_RECEIVED": "Evento do Provedor Recebido"
|
||||
"PROVIDER_EVENT_RECEIVED": "Evento do Provedor Recebido",
|
||||
"INTERNAL_CHAT_MESSAGE_CREATED": "Mensagem do chat interno criada",
|
||||
"INTERNAL_CHAT_MESSAGE_UPDATED": "Mensagem do chat interno atualizada",
|
||||
"INTERNAL_CHAT_MESSAGE_DELETED": "Mensagem do chat interno excluída",
|
||||
"INTERNAL_CHAT_CHANNEL_UPDATED": "Canal do chat interno atualizado"
|
||||
}
|
||||
},
|
||||
"NAME": {
|
||||
|
||||
192
app/javascript/dashboard/i18n/locale/pt_BR/internalChat.json
Normal file
192
app/javascript/dashboard/i18n/locale/pt_BR/internalChat.json
Normal file
@ -0,0 +1,192 @@
|
||||
{
|
||||
"INTERNAL_CHAT": {
|
||||
"TITLE": "Chat Interno",
|
||||
"CHANNELS": "Canais",
|
||||
"DIRECT_MESSAGES": "Mensagens Diretas",
|
||||
"FAVORITES": "Favoritos",
|
||||
"NEW_CHANNEL": "Novo Canal",
|
||||
"NEW_DM": "Nova Mensagem",
|
||||
"SEARCH_PLACEHOLDER": "Buscar no chat...",
|
||||
"SEARCH": {
|
||||
"CHANNELS": "Canais",
|
||||
"DIRECT_MESSAGES": "Mensagens Diretas",
|
||||
"MESSAGES": "Mensagens",
|
||||
"NO_RESULTS": "Nenhum resultado encontrado",
|
||||
"NO_RESULTS_SUBTITLE": "Tente um termo diferente ou verifique a ortografia.",
|
||||
"LOAD_MORE": "Carregar mais mensagens",
|
||||
"SEARCHING": "Buscando...",
|
||||
"MIN_CHARS_HINT": "Digite pelo menos 3 caracteres para buscar."
|
||||
},
|
||||
"NO_CHANNELS": "Nenhum canal encontrado",
|
||||
"CHANNEL": {
|
||||
"NAME": "Nome do Canal",
|
||||
"DESCRIPTION": "Descri\u00e7\u00e3o",
|
||||
"TYPE": "Tipo do Canal",
|
||||
"PUBLIC": "P\u00fablico",
|
||||
"PRIVATE": "Privado",
|
||||
"MEMBERS": "Membros",
|
||||
"ARCHIVE": "Arquivar Canal",
|
||||
"UNARCHIVE": "Desarquivar Canal",
|
||||
"DELETE": "Excluir Canal",
|
||||
"MUTE": "Silenciar Canal",
|
||||
"UNMUTE": "Reativar Notifica\u00e7\u00f5es",
|
||||
"FAVORITE": "Adicionar aos Favoritos",
|
||||
"UNFAVORITE": "Remover dos Favoritos",
|
||||
"MARK_UNREAD": "Marcar como N\u00e3o Lida",
|
||||
"NO_MESSAGES": "Nenhuma mensagem ainda",
|
||||
"NO_MESSAGES_SUBTITLE": "Seja o primeiro a dizer algo neste canal.",
|
||||
"ARCHIVED": "Este canal est\u00e1 arquivado",
|
||||
"MEMBER_COUNT": "{count} membro | {count} membros",
|
||||
"SETTINGS": "Configura\u00e7\u00f5es do Canal",
|
||||
"INFO": "Informa\u00e7\u00f5es do Canal",
|
||||
"CREATED_AT": "Criado em",
|
||||
"CONFIRM_DELETE": "Tem certeza que deseja excluir este canal?",
|
||||
"ACTIONS": "A\u00e7\u00f5es",
|
||||
"CREATED": "Canal criado com sucesso",
|
||||
"ADMIN": "Admin",
|
||||
"YOU": "(voc\u00ea)",
|
||||
"NO_MEMBERS": "Sem membros",
|
||||
"ADD_MEMBER": "Adicionar membro...",
|
||||
"REMOVE_MEMBER": "Remover membro",
|
||||
"EDIT_MEMBERS": "Editar membros",
|
||||
"SAVE_MEMBERS": "Salvar",
|
||||
"MEMBERS_UPDATED": "Membros atualizados com sucesso",
|
||||
"ALL_AGENTS_NOTE": "Esse canal ser\u00e1 vis\u00edvel para todos os usu\u00e1rios.",
|
||||
"CLOSE_DM": "Fechar conversa",
|
||||
"SELECT_TEAMS": "Selecionar equipes",
|
||||
"SELECT_AGENTS": "Selecionar agentes"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"PLACEHOLDER": "Digite uma mensagem...",
|
||||
"EDIT": "Editar",
|
||||
"DELETE": "Excluir",
|
||||
"REPLY": "Responder",
|
||||
"REACT": "Reagir",
|
||||
"EDITED": "(editado)",
|
||||
"DELETED": "Esta mensagem foi exclu\u00edda",
|
||||
"SEND": "Enviar",
|
||||
"UPLOAD_FILE": "Enviar Arquivo",
|
||||
"MENTION_USER": "Mencionar um usu\u00e1rio",
|
||||
"MENTION_CONVERSATION": "Mencionar uma conversa",
|
||||
"CONFIRM_DELETE": "Tem certeza que deseja excluir esta mensagem?",
|
||||
"EDITING": "Editando mensagem",
|
||||
"CANCEL_EDIT": "Cancelar edi\u00e7\u00e3o",
|
||||
"BOLD": "Negrito",
|
||||
"ITALIC": "It\u00e1lico",
|
||||
"CODE": "C\u00f3digo",
|
||||
"COPY_LINK": "Copiar link",
|
||||
"LINK_COPIED": "Link da mensagem copiado",
|
||||
"DELETED_USER": "Usuário excluído"
|
||||
},
|
||||
"THREAD": {
|
||||
"TITLE": "Conversa",
|
||||
"CLOSE": "Fechar conversa",
|
||||
"REPLIES": "{count} respostas",
|
||||
"REPLY_PLACEHOLDER": "Responder na conversa...",
|
||||
"ALSO_SEND_IN_CHANNEL": "Enviar tamb\u00e9m na conversa"
|
||||
},
|
||||
"POLL": {
|
||||
"CREATE": "Criar Enquete",
|
||||
"QUESTION": "Pergunta",
|
||||
"OPTIONS": "Op\u00e7\u00f5es",
|
||||
"MULTIPLE_CHOICE": "M\u00faltipla escolha",
|
||||
"DURATION": "Dura\u00e7\u00e3o",
|
||||
"DURATION_24H": "24 horas",
|
||||
"DURATION_7D": "7 dias",
|
||||
"DURATION_14D": "14 dias",
|
||||
"DURATION_30D": "30 dias",
|
||||
"PUBLIC_RESULTS": "Resultados p\u00fablicos",
|
||||
"VOTE": "Votar",
|
||||
"VOTES": "{count} votos",
|
||||
"EXPIRED": "Expirada",
|
||||
"PIN": "Fixar enquete",
|
||||
"ADD_OPTION": "Adicionar op\u00e7\u00e3o",
|
||||
"CANCEL": "Cancelar",
|
||||
"PERCENTAGE": "{value}%",
|
||||
"TIME_LEFT": {
|
||||
"DAYS": "Faltam {count}d",
|
||||
"HOURS_MINUTES": "Faltam {hours}h {minutes}m",
|
||||
"MINUTES": "Faltam {count}m"
|
||||
},
|
||||
"DISCARD_TITLE": "Descartar enquete?",
|
||||
"DISCARD_DESCRIPTION": "Voc\u00ea tem altera\u00e7\u00f5es n\u00e3o salvas. Tem certeza que deseja descartar esta enquete?",
|
||||
"DISCARD": "Descartar"
|
||||
},
|
||||
"DRAFT": {
|
||||
"TITLE": "Rascunhos",
|
||||
"LABEL": "Rascunho",
|
||||
"NO_DRAFTS": "Nenhum rascunho ainda",
|
||||
"NO_DRAFTS_SUBTITLE": "Rascunhos são salvos automaticamente quando você começa a digitar em um canal.",
|
||||
"DELETE": "Excluir rascunho",
|
||||
"SAVED_AGO": "Salvo h\u00e1 {time}",
|
||||
"CHANNEL_LABEL": "Canal #{channelId}"
|
||||
},
|
||||
"PIN": {
|
||||
"PINNED_MESSAGE": "Mensagem fixada",
|
||||
"PIN": "Fixar mensagem",
|
||||
"UNPIN": "Desafixar mensagem"
|
||||
},
|
||||
"TYPING": {
|
||||
"SINGLE": "{name} est\u00e1 digitando...",
|
||||
"MULTIPLE": "{names} est\u00e3o digitando..."
|
||||
},
|
||||
"DM": {
|
||||
"NEW": "Nova Mensagem Direta",
|
||||
"SELECT_AGENTS": "Selecionar agentes"
|
||||
},
|
||||
"CATEGORY": {
|
||||
"CREATE": "Criar Categoria",
|
||||
"NAME": "Nome da Categoria",
|
||||
"NAME_PLACEHOLDER": "Digite o nome da categoria",
|
||||
"NONE": "Nenhuma",
|
||||
"CREATED": "Categoria criada com sucesso",
|
||||
"DELETE": "Excluir Categoria",
|
||||
"DELETE_DESCRIPTION": "Os canais desta categoria ser\u00e3o movidos para sem categoria."
|
||||
},
|
||||
"ERRORS": {
|
||||
"FETCH_CHANNELS": "Falha ao carregar canais",
|
||||
"FETCH_MESSAGES": "Falha ao carregar mensagens",
|
||||
"SEND_MESSAGE": "Falha ao enviar mensagem",
|
||||
"CREATE_CHANNEL": "Falha ao criar canal"
|
||||
},
|
||||
"DATE_SEPARATOR": {
|
||||
"TODAY": "Hoje",
|
||||
"YESTERDAY": "Ontem"
|
||||
},
|
||||
"SCROLL_TO_BOTTOM": "Ir para o final",
|
||||
"NEW_MESSAGES": "Novas mensagens",
|
||||
"LOADING_MESSAGES": "Carregando mensagens...",
|
||||
"MENTION_BADGE": "@",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "Bem-vindo ao Chat Interno",
|
||||
"SUBTITLE": "Escolha um canal ou conversa na barra lateral para começar."
|
||||
},
|
||||
"CONVERSATION_MENTION": {
|
||||
"NO_RESULTS": "Nenhuma conversa encontrada",
|
||||
"TYPE_TO_SEARCH": "Digite o ID da conversa ou nome do contato",
|
||||
"HEADER": "Conversas",
|
||||
"PREFIX": "#",
|
||||
"NO_ACCESS": "Você não tem acesso a esta conversa",
|
||||
"STATUS": {
|
||||
"OPEN": "Aberta",
|
||||
"RESOLVED": "Resolvida",
|
||||
"PENDING": "Pendente",
|
||||
"SNOOZED": "Adiada"
|
||||
}
|
||||
},
|
||||
"ARCHIVED": {
|
||||
"TITLE": "Arquivados",
|
||||
"EMPTY": "Nenhum canal arquivado"
|
||||
},
|
||||
"PRO": {
|
||||
"TITLE": "Faça upgrade para Pro",
|
||||
"POLLS_DESCRIPTION": "A criação de enquetes está disponível na versão Pro.",
|
||||
"PRIVATE_CHANNELS_DESCRIPTION": "Você atingiu o limite de {limit} canais privados. Faça upgrade para Pro e tenha canais privados ilimitados.",
|
||||
"SEARCH_DESCRIPTION": "A busca está limitada aos últimos {days} dias. Faça upgrade para Pro e tenha histórico de busca ilimitado.",
|
||||
"SEARCH_LIMITED_NOTE": "Resultados dos últimos {days} dias",
|
||||
"UPGRADE_NOW": "Fazer upgrade",
|
||||
"ADMIN_MESSAGE": "Este recurso não está disponível no seu plano atual. Entre em contato com o administrador da plataforma para fazer upgrade.",
|
||||
"AGENT_MESSAGE": "Este recurso não está disponível no seu plano atual. Entre em contato com o administrador para fazer upgrade."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -338,6 +338,7 @@
|
||||
"ACTIVE": "Ativo",
|
||||
"COMPANIES": "Empresas",
|
||||
"ALL_COMPANIES": "Todas as empresas",
|
||||
"INTERNAL_CHAT": "Chat Interno",
|
||||
"KANBAN": "Kanban",
|
||||
"CAPTAIN": "Capitão",
|
||||
"CAPTAIN_ASSISTANTS": "Assistentes",
|
||||
|
||||
@ -11,6 +11,7 @@ import campaignsRoutes from './campaigns/campaigns.routes';
|
||||
import { routes as captainRoutes } from './captain/captain.routes';
|
||||
import { routes as kanbanRoutes } from './kanban/kanban.routes';
|
||||
import dashboardAppsRoutes from './dashboardApps/dashboardApps.routes';
|
||||
import internalChatRoutes from './internalChat/internalChat.routes';
|
||||
import AppContainer from './Dashboard.vue';
|
||||
import Suspended from './suspended/Index.vue';
|
||||
import NoAccounts from './noAccounts/Index.vue';
|
||||
@ -33,6 +34,7 @@ export default {
|
||||
...helpcenterRoutes.routes,
|
||||
...campaignsRoutes.routes,
|
||||
...dashboardAppsRoutes.routes,
|
||||
...internalChatRoutes.routes,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const props = defineProps({
|
||||
channel: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pinnedMessages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['settings', 'scrollToPinned']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const isDM = computed(() => {
|
||||
return props.channel.channel_type === 'dm';
|
||||
});
|
||||
|
||||
const dmPeer = computed(() => {
|
||||
if (!isDM.value) return null;
|
||||
const members = props.channel.members || [];
|
||||
return members.find(m => m.user_id !== currentUser.value?.id) || null;
|
||||
});
|
||||
|
||||
// Member to use for avatar display: peer for regular DMs, self for self-DMs, null for deleted-user DMs
|
||||
const dmDisplayMember = computed(() => {
|
||||
if (dmPeer.value) return dmPeer.value;
|
||||
if (props.channel.name) return null;
|
||||
return (props.channel.members || [])[0] || null;
|
||||
});
|
||||
|
||||
const isDeletedUserDM = computed(() => {
|
||||
return isDM.value && !dmPeer.value && !!props.channel.name;
|
||||
});
|
||||
|
||||
const channelName = computed(() => {
|
||||
if (isDM.value) {
|
||||
if (dmPeer.value) return dmPeer.value.name;
|
||||
return (
|
||||
props.channel.name ||
|
||||
(props.channel.members || [])[0]?.name ||
|
||||
'Direct Message'
|
||||
);
|
||||
}
|
||||
return props.channel.name || '';
|
||||
});
|
||||
|
||||
const channelDescription = computed(() => {
|
||||
return props.channel.description || '';
|
||||
});
|
||||
|
||||
const memberCount = computed(() => {
|
||||
return props.channel.members_count || 0;
|
||||
});
|
||||
|
||||
const isArchived = computed(() => {
|
||||
return props.channel.status === 'archived';
|
||||
});
|
||||
|
||||
const channelIcon = computed(() => {
|
||||
if (isDM.value) return 'i-lucide-message-circle';
|
||||
if (props.channel.channel_type === 'private_channel') return 'i-lucide-lock';
|
||||
return 'i-lucide-hash';
|
||||
});
|
||||
|
||||
const pinnedContent = computed(() => {
|
||||
if (!props.pinnedMessages.length) return '';
|
||||
const content = props.pinnedMessages[0].content || '';
|
||||
return content.length > 100 ? `${content.substring(0, 100)}...` : content;
|
||||
});
|
||||
|
||||
const pinnedCountLabel = computed(() => {
|
||||
if (props.pinnedMessages.length <= 1) return '';
|
||||
return `(${props.pinnedMessages.length})`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="flex h-[53px] items-center gap-3 border-b border-n-slate-5 bg-n-solid-2 px-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Avatar
|
||||
v-if="isDM"
|
||||
:name="channelName"
|
||||
:src="dmDisplayMember?.avatar_url || ''"
|
||||
:status="dmDisplayMember?.availability_status"
|
||||
:size="28"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:icon="channelIcon"
|
||||
class="size-5 text-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="truncate text-sm font-semibold text-n-slate-12">
|
||||
{{ channelName }}
|
||||
</h2>
|
||||
<span
|
||||
v-if="isDeletedUserDM"
|
||||
class="flex-shrink-0 text-xs italic text-n-slate-9"
|
||||
>
|
||||
({{ t('INTERNAL_CHAT.MESSAGE.DELETED_USER') }})
|
||||
</span>
|
||||
<span
|
||||
v-if="isArchived"
|
||||
class="flex-shrink-0 rounded bg-n-slate-4 px-1.5 py-0.5 text-xs text-n-slate-10"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.ARCHIVED') }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="channelDescription" class="truncate text-xs text-n-slate-10">
|
||||
{{ channelDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
v-if="memberCount > 0"
|
||||
class="flex items-center gap-1 text-xs text-n-slate-10"
|
||||
>
|
||||
<Icon icon="i-lucide-users" class="size-3.5" />
|
||||
{{ memberCount }}
|
||||
</span>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-lg p-1.5 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12 transition-colors"
|
||||
:title="t('INTERNAL_CHAT.CHANNEL.SETTINGS')"
|
||||
:aria-label="t('INTERNAL_CHAT.CHANNEL.SETTINGS')"
|
||||
@click="emit('settings')"
|
||||
>
|
||||
<Icon icon="i-lucide-settings" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pinned message banner -->
|
||||
<button
|
||||
v-if="pinnedMessages.length > 0"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 border-b border-n-slate-5 bg-n-amber-2 px-4 py-2 cursor-pointer hover:bg-n-amber-3 transition-colors"
|
||||
@click="emit('scrollToPinned', pinnedMessages[0])"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-pin"
|
||||
class="size-3.5 text-n-amber-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-xs font-medium text-n-amber-11">
|
||||
{{ t('INTERNAL_CHAT.PIN.PINNED_MESSAGE') }}
|
||||
<span v-if="pinnedCountLabel" class="ml-1">
|
||||
{{ pinnedCountLabel }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="truncate text-xs text-n-slate-12">
|
||||
{{ pinnedContent }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,399 @@
|
||||
<script setup>
|
||||
import { computed, ref, nextTick, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import InternalChatChannelsAPI from 'dashboard/api/internalChatChannels';
|
||||
|
||||
const props = defineProps({
|
||||
channel: { type: Object, required: true },
|
||||
currentUserId: { type: Number, required: true },
|
||||
isAdmin: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'close',
|
||||
'archive',
|
||||
'unarchive',
|
||||
'delete',
|
||||
'mute',
|
||||
'unmute',
|
||||
'favorite',
|
||||
'unfavorite',
|
||||
'close-dm',
|
||||
'edit-members',
|
||||
]);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isDM = computed(() => props.channel.channel_type === 'dm');
|
||||
const isPrivate = computed(
|
||||
() => props.channel.channel_type === 'private_channel'
|
||||
);
|
||||
|
||||
const showDeleteConfirm = ref(false);
|
||||
const members = computed(() => props.channel.members || []);
|
||||
const isLoadingMembers = ref(false);
|
||||
const isEditingName = ref(false);
|
||||
const editedName = ref('');
|
||||
const nameInputRef = ref(null);
|
||||
|
||||
async function fetchMembers() {
|
||||
if (!props.channel.id) return;
|
||||
|
||||
if (!members.value.length) isLoadingMembers.value = true;
|
||||
try {
|
||||
const { data } = await InternalChatChannelsAPI.getMembers(props.channel.id);
|
||||
store.commit('internalChat/UPDATE_CHANNEL', {
|
||||
id: props.channel.id,
|
||||
members: data,
|
||||
});
|
||||
} catch {
|
||||
// silently handle
|
||||
} finally {
|
||||
isLoadingMembers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!members.value.length) fetchMembers();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.channel.id,
|
||||
() => {
|
||||
if (!members.value.length) fetchMembers();
|
||||
}
|
||||
);
|
||||
// Refetch members when ActionCable broadcasts updated member list
|
||||
watch(() => props.channel.member_user_ids, fetchMembers);
|
||||
|
||||
const isMuted = computed(() => props.channel.muted);
|
||||
const isFavorited = computed(() => props.channel.favorited);
|
||||
const isArchived = computed(() => props.channel.status === 'archived');
|
||||
|
||||
const channelTypeLabel = computed(() => {
|
||||
const type = props.channel.channel_type;
|
||||
if (type === 'dm') return t('INTERNAL_CHAT.DM.NEW');
|
||||
if (type === 'private_channel') return t('INTERNAL_CHAT.CHANNEL.PRIVATE');
|
||||
return t('INTERNAL_CHAT.CHANNEL.PUBLIC');
|
||||
});
|
||||
|
||||
const channelTypeIcon = computed(() => {
|
||||
const type = props.channel.channel_type;
|
||||
if (type === 'dm') return 'i-lucide-message-circle';
|
||||
if (type === 'private_channel') return 'i-lucide-lock';
|
||||
return 'i-lucide-hash';
|
||||
});
|
||||
|
||||
const createdAt = computed(() => {
|
||||
if (!props.channel.created_at) return '';
|
||||
return new Date(props.channel.created_at).toLocaleDateString();
|
||||
});
|
||||
|
||||
function handleMuteToggle() {
|
||||
if (isMuted.value) {
|
||||
emit('unmute');
|
||||
} else {
|
||||
emit('mute');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFavoriteToggle() {
|
||||
if (isFavorited.value) {
|
||||
emit('unfavorite');
|
||||
} else {
|
||||
emit('favorite');
|
||||
}
|
||||
}
|
||||
|
||||
function handleArchiveToggle() {
|
||||
if (isArchived.value) {
|
||||
emit('unarchive');
|
||||
} else {
|
||||
emit('archive');
|
||||
}
|
||||
}
|
||||
|
||||
async function startEditName() {
|
||||
editedName.value = props.channel.name || '';
|
||||
isEditingName.value = true;
|
||||
await nextTick();
|
||||
nameInputRef.value?.focus();
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
const trimmed = editedName.value.trim();
|
||||
if (!trimmed || trimmed === props.channel.name) {
|
||||
isEditingName.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await store.dispatch('internalChat/update', {
|
||||
channelId: props.channel.id,
|
||||
channel: { name: trimmed },
|
||||
});
|
||||
} catch {
|
||||
// silently handle
|
||||
}
|
||||
isEditingName.value = false;
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!showDeleteConfirm.value) {
|
||||
showDeleteConfirm.value = true;
|
||||
return;
|
||||
}
|
||||
emit('delete');
|
||||
showDeleteConfirm.value = false;
|
||||
}
|
||||
|
||||
defineExpose({ fetchMembers });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-80 flex-col border-l border-n-slate-5 bg-n-solid-1">
|
||||
<div
|
||||
class="flex h-[53px] items-center justify-between border-b border-n-slate-5 px-4"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.SETTINGS') }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded p-1 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12"
|
||||
:aria-label="t('INTERNAL_CHAT.THREAD.CLOSE')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<Icon icon="i-lucide-x" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Channel Info -->
|
||||
<div class="border-b border-n-slate-5 px-4 py-4">
|
||||
<h4 class="mb-3 text-xs font-semibold uppercase text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.INFO') }}
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon
|
||||
:icon="channelTypeIcon"
|
||||
class="size-4 flex-shrink-0 text-n-slate-10"
|
||||
/>
|
||||
<input
|
||||
v-if="isEditingName"
|
||||
ref="nameInputRef"
|
||||
v-model="editedName"
|
||||
type="text"
|
||||
class="reset-base flex-1 border-b border-n-brand bg-transparent text-sm text-n-slate-12 outline-none"
|
||||
@keydown.enter="saveName"
|
||||
@keydown.escape="isEditingName = false"
|
||||
@blur="saveName"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex-1 truncate text-sm text-n-slate-12"
|
||||
:class="{ 'cursor-pointer hover:text-n-brand': !isDM && isAdmin }"
|
||||
@click="!isDM && isAdmin && startEditName()"
|
||||
>
|
||||
{{ channel.name }}
|
||||
</span>
|
||||
<button
|
||||
v-if="isAdmin && !isDM && !isEditingName"
|
||||
type="button"
|
||||
class="flex-shrink-0 rounded p-0.5 text-n-slate-9 hover:text-n-slate-12"
|
||||
@click="startEditName"
|
||||
>
|
||||
<Icon icon="i-lucide-pencil" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="channel.description" class="text-sm text-n-slate-10">
|
||||
{{ channel.description }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-n-slate-10">
|
||||
<span>{{ channelTypeLabel }}</span>
|
||||
<span v-if="createdAt">
|
||||
· {{ t('INTERNAL_CHAT.CHANNEL.CREATED_AT') }}
|
||||
{{ createdAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members -->
|
||||
<div class="border-b border-n-slate-5 px-4 py-4">
|
||||
<h4 class="mb-3 text-xs font-semibold uppercase text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.MEMBERS') }}
|
||||
<span v-if="members.length" class="ml-1 text-n-slate-9">
|
||||
({{ members.length }})
|
||||
</span>
|
||||
</h4>
|
||||
<div v-if="isLoadingMembers" class="space-y-2">
|
||||
<div
|
||||
v-for="i in channel.members_count || 4"
|
||||
:key="i"
|
||||
class="flex animate-pulse items-center gap-2"
|
||||
>
|
||||
<div class="size-6 flex-shrink-0 rounded-full bg-n-alpha-2" />
|
||||
<div class="h-3.5 flex-1 rounded bg-n-alpha-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member.user_id"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<Avatar
|
||||
:src="member.avatar_url"
|
||||
:name="member.name || ''"
|
||||
:status="member.availability_status"
|
||||
:size="24"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm text-n-slate-12">
|
||||
{{ member.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="member.role === 'admin'"
|
||||
class="rounded bg-n-alpha-2 px-1.5 py-0.5 text-xs text-n-slate-10"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.ADMIN') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="member.user_id === currentUserId"
|
||||
class="text-xs text-n-slate-10"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.YOU') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="members.length === 0" class="text-sm text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.NO_MEMBERS') }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="isAdmin && isPrivate && !isArchived"
|
||||
type="button"
|
||||
class="mt-3 flex w-full items-center justify-center gap-2 rounded-lg border border-n-slate-6 px-3 py-1.5 text-sm text-n-slate-12 hover:bg-n-alpha-2"
|
||||
@click="emit('edit-members')"
|
||||
>
|
||||
<Icon icon="i-lucide-user-plus" class="size-4 text-n-slate-11" />
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.EDIT_MEMBERS') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-4 py-4">
|
||||
<h4 class="mb-3 text-xs font-semibold uppercase text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.ACTIONS') }}
|
||||
</h4>
|
||||
<div class="space-y-1">
|
||||
<template v-if="!isArchived">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-n-slate-12 hover:bg-n-alpha-2"
|
||||
@click="handleMuteToggle"
|
||||
>
|
||||
<Icon
|
||||
:icon="isMuted ? 'i-lucide-bell' : 'i-lucide-bell-off'"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
{{
|
||||
isMuted
|
||||
? t('INTERNAL_CHAT.CHANNEL.UNMUTE')
|
||||
: t('INTERNAL_CHAT.CHANNEL.MUTE')
|
||||
}}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-n-slate-12 hover:bg-n-alpha-2"
|
||||
@click="handleFavoriteToggle"
|
||||
>
|
||||
<Icon
|
||||
:icon="isFavorited ? 'i-lucide-star-off' : 'i-lucide-star'"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
{{
|
||||
isFavorited
|
||||
? t('INTERNAL_CHAT.CHANNEL.UNFAVORITE')
|
||||
: t('INTERNAL_CHAT.CHANNEL.FAVORITE')
|
||||
}}
|
||||
</button>
|
||||
|
||||
<!-- DM: Close conversation button -->
|
||||
<button
|
||||
v-if="isDM"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-n-slate-12 hover:bg-n-alpha-2"
|
||||
@click="emit('close-dm')"
|
||||
>
|
||||
<Icon icon="i-lucide-x-circle" class="size-4 text-n-slate-11" />
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.CLOSE_DM') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Non-DM: Archive and Delete -->
|
||||
<template v-if="!isDM">
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-n-slate-12 hover:bg-n-alpha-2"
|
||||
@click="handleArchiveToggle"
|
||||
>
|
||||
<Icon
|
||||
:icon="
|
||||
isArchived ? 'i-lucide-archive-restore' : 'i-lucide-archive'
|
||||
"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
{{
|
||||
isArchived
|
||||
? t('INTERNAL_CHAT.CHANNEL.UNARCHIVE')
|
||||
: t('INTERNAL_CHAT.CHANNEL.ARCHIVE')
|
||||
}}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-n-ruby-11 hover:bg-n-ruby-3"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<Icon icon="i-lucide-trash-2" class="size-4" />
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.DELETE') }}
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showDeleteConfirm"
|
||||
class="mt-2 rounded-lg border border-n-ruby-7 bg-n-ruby-2 p-3"
|
||||
>
|
||||
<p class="mb-2 text-sm text-n-ruby-11">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.CONFIRM_DELETE') }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-n-ruby-9 px-3 py-1.5 text-sm font-medium text-white hover:bg-n-ruby-10"
|
||||
@click="handleDelete"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.DELETE') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-1.5 text-sm text-n-slate-11 hover:bg-n-alpha-2"
|
||||
@click="showDeleteConfirm = false"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.POLL.CANCEL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,682 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import InternalChatChannelsAPI from 'dashboard/api/internalChatChannels';
|
||||
import ChannelHeader from './ChannelHeader.vue';
|
||||
import MessageList from './MessageList.vue';
|
||||
import MessageEditor from './MessageEditor.vue';
|
||||
import TypingIndicator from './TypingIndicator.vue';
|
||||
import ThreadPanel from './ThreadPanel.vue';
|
||||
import PollCreator from './PollCreator.vue';
|
||||
import ChannelSettings from './ChannelSettings.vue';
|
||||
import EditMembersModal from './EditMembersModal.vue';
|
||||
import ProFeatureNudge from './ProFeatureNudge.vue';
|
||||
import { useInternalChatPro } from 'dashboard/composables/useInternalChatPro';
|
||||
|
||||
const props = defineProps({
|
||||
channelId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const typingUsers = computed(() => {
|
||||
return (
|
||||
store.getters['internalChatTypingStatus/getUserList'](props.channelId) || []
|
||||
);
|
||||
});
|
||||
const editorRef = ref(null);
|
||||
const messageListRef = ref(null);
|
||||
const activeThread = ref(null);
|
||||
const threadHighlightMessageId = ref(null);
|
||||
const threadPanelRef = ref(null);
|
||||
const pollCreatorRef = ref(null);
|
||||
const editMembersRef = ref(null);
|
||||
const channelSettingsRef = ref(null);
|
||||
const proNudgeRef = ref(null);
|
||||
const proNudgeFeature = ref('polls');
|
||||
const { pollsEnabled } = useInternalChatPro();
|
||||
const editingMessage = ref(null);
|
||||
const showSettings = ref(
|
||||
localStorage.getItem('internal_chat_settings_open') === 'true'
|
||||
);
|
||||
const isLoadingMore = ref(false);
|
||||
const isViewingHistory = ref(false);
|
||||
const firstUnreadMessageId = ref(null);
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentRole = useMapGetter('getCurrentRole');
|
||||
|
||||
const channel = computed(() => {
|
||||
return store.getters['internalChat/getChannelById'](props.channelId);
|
||||
});
|
||||
|
||||
const messages = computed(() => {
|
||||
return store.getters['internalChat/messages/getMessages'](props.channelId);
|
||||
});
|
||||
|
||||
const messagesUIFlags = computed(() => {
|
||||
return store.getters['internalChat/messages/getUIFlags'];
|
||||
});
|
||||
|
||||
const currentUserId = computed(() => {
|
||||
return currentUser.value?.id;
|
||||
});
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return currentRole.value === 'administrator';
|
||||
});
|
||||
|
||||
const isArchived = computed(() => {
|
||||
return channel.value?.status === 'archived';
|
||||
});
|
||||
|
||||
const pinnedMessages = computed(() => {
|
||||
return messages.value.filter(m => m.content_attributes?.pinned);
|
||||
});
|
||||
|
||||
const threadDraftParentIds = computed(() => {
|
||||
return store.getters['internalChat/drafts/getThreadDraftParentIds'](
|
||||
props.channelId
|
||||
);
|
||||
});
|
||||
|
||||
function markRead() {
|
||||
store.dispatch('internalChat/markRead', props.channelId);
|
||||
}
|
||||
|
||||
async function fetchMessages() {
|
||||
try {
|
||||
await store.dispatch('internalChat/messages/fetchMessages', {
|
||||
channelId: props.channelId,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.FETCH_MESSAGES'));
|
||||
}
|
||||
}
|
||||
|
||||
function computeFirstUnreadId(unreadCount) {
|
||||
if (unreadCount > 0 && messages.value.length > 0) {
|
||||
const idx = Math.max(0, messages.value.length - unreadCount);
|
||||
firstUnreadMessageId.value = messages.value[idx]?.id || null;
|
||||
} else {
|
||||
firstUnreadMessageId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadDraft() {
|
||||
// Set editor content immediately from store (no network wait)
|
||||
const draft = store.getters['internalChat/drafts/getDraftByChannelId'](
|
||||
props.channelId
|
||||
);
|
||||
if (editorRef.value) {
|
||||
editorRef.value.setContent(draft ? draft.content : '');
|
||||
}
|
||||
}
|
||||
|
||||
function deleteDraftForChannel() {
|
||||
const draft = store.getters['internalChat/drafts/getDraftByChannelId'](
|
||||
props.channelId
|
||||
);
|
||||
if (draft) {
|
||||
store
|
||||
.dispatch('internalChat/drafts/deleteDraft', {
|
||||
channelId: props.channelId,
|
||||
draftId: draft.id,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(content, options = {}) {
|
||||
try {
|
||||
if (editingMessage.value) {
|
||||
await store.dispatch('internalChat/messages/updateMessage', {
|
||||
channelId: props.channelId,
|
||||
messageId: editingMessage.value.id,
|
||||
data: { content },
|
||||
});
|
||||
editingMessage.value = null;
|
||||
} else {
|
||||
await store.dispatch('internalChat/messages/sendMessage', {
|
||||
channelId: props.channelId,
|
||||
data: { content },
|
||||
files: options.files || [],
|
||||
});
|
||||
markRead();
|
||||
deleteDraftForChannel();
|
||||
}
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(message) {
|
||||
editingMessage.value = message;
|
||||
}
|
||||
|
||||
function handleCancelEdit() {
|
||||
editingMessage.value = null;
|
||||
}
|
||||
|
||||
async function handleDelete(message) {
|
||||
try {
|
||||
await store.dispatch('internalChat/messages/deleteMessage', {
|
||||
channelId: props.channelId,
|
||||
messageId: message.id,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddReaction({ messageId, emoji }) {
|
||||
try {
|
||||
await store.dispatch('internalChat/messages/addReaction', {
|
||||
channelId: props.channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore reaction errors
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveReaction({ messageId, reactionId }) {
|
||||
try {
|
||||
await store.dispatch('internalChat/messages/removeReaction', {
|
||||
channelId: props.channelId,
|
||||
messageId,
|
||||
reactionId,
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore reaction errors
|
||||
}
|
||||
}
|
||||
|
||||
let typingOffTimer = null;
|
||||
|
||||
function handleTyping() {
|
||||
InternalChatChannelsAPI.toggleTypingStatus(props.channelId, 'on');
|
||||
if (typingOffTimer) clearTimeout(typingOffTimer);
|
||||
typingOffTimer = setTimeout(() => {
|
||||
InternalChatChannelsAPI.toggleTypingStatus(props.channelId, 'off');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function handleReply(message) {
|
||||
activeThread.value = message;
|
||||
showSettings.value = false;
|
||||
localStorage.setItem('internal_chat_settings_open', 'false');
|
||||
}
|
||||
|
||||
function handleCreatePoll() {
|
||||
if (!pollsEnabled.value) {
|
||||
proNudgeFeature.value = 'polls';
|
||||
proNudgeRef.value?.open();
|
||||
return;
|
||||
}
|
||||
pollCreatorRef.value?.open();
|
||||
}
|
||||
|
||||
function handleOpenThread(message) {
|
||||
// If message has parent_id, find the parent message to open its thread
|
||||
const parentId = message.parent_id;
|
||||
if (parentId) {
|
||||
const parent = messages.value.find(m => m.id === parentId);
|
||||
if (parent) {
|
||||
activeThread.value = parent;
|
||||
} else {
|
||||
// Parent not in local messages, use the message itself as a stub
|
||||
activeThread.value = {
|
||||
id: parentId,
|
||||
content: '',
|
||||
sender: message.sender,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
activeThread.value = message;
|
||||
}
|
||||
showSettings.value = false;
|
||||
localStorage.setItem('internal_chat_settings_open', 'false');
|
||||
}
|
||||
|
||||
function closeThread() {
|
||||
activeThread.value = null;
|
||||
threadHighlightMessageId.value = null;
|
||||
}
|
||||
|
||||
async function handlePin(message) {
|
||||
try {
|
||||
await store.dispatch('internalChat/messages/pinMessage', {
|
||||
channelId: props.channelId,
|
||||
messageId: message.id,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnpin(message) {
|
||||
try {
|
||||
await store.dispatch('internalChat/messages/unpinMessage', {
|
||||
channelId: props.channelId,
|
||||
messageId: message.id,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVote({ messageId, optionId }) {
|
||||
const msg = store.getters['internalChat/messages/getMessageById'](
|
||||
props.channelId,
|
||||
messageId
|
||||
);
|
||||
const pollId = msg?.poll?.id || msg?.content_attributes?.poll?.id;
|
||||
if (!pollId) return;
|
||||
try {
|
||||
await store.dispatch('internalChat/polls/vote', {
|
||||
pollId,
|
||||
optionId,
|
||||
channelId: props.channelId,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnvote({ messageId, optionId }) {
|
||||
const msg = store.getters['internalChat/messages/getMessageById'](
|
||||
props.channelId,
|
||||
messageId
|
||||
);
|
||||
const pollId = msg?.poll?.id || msg?.content_attributes?.poll?.id;
|
||||
if (!pollId) return;
|
||||
try {
|
||||
await store.dispatch('internalChat/polls/unvote', {
|
||||
pollId,
|
||||
optionId,
|
||||
channelId: props.channelId,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePollSubmit(pollData) {
|
||||
try {
|
||||
await store.dispatch('internalChat/polls/createPoll', {
|
||||
channelId: props.channelId,
|
||||
data: pollData,
|
||||
});
|
||||
// Dialog closes itself after submit
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleScrollToPinned(message) {
|
||||
messageListRef.value?.scrollToMessage(message.id);
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
try {
|
||||
await store.dispatch('internalChat/archive', props.channelId);
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive() {
|
||||
try {
|
||||
await store.dispatch('internalChat/unarchive', props.channelId);
|
||||
} catch (error) {
|
||||
if (error?.response?.status === 402) {
|
||||
proNudgeFeature.value = 'private_channels';
|
||||
proNudgeRef.value?.open();
|
||||
} else {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteChannel() {
|
||||
try {
|
||||
await store.dispatch('internalChat/delete', props.channelId);
|
||||
showSettings.value = false;
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseDM() {
|
||||
try {
|
||||
const ch = channel.value;
|
||||
const membership = (ch?.members || []).find(
|
||||
m => m.user_id === currentUserId.value
|
||||
);
|
||||
if (membership) {
|
||||
await InternalChatChannelsAPI.updateMember(
|
||||
props.channelId,
|
||||
membership.id,
|
||||
{ hidden: true }
|
||||
);
|
||||
}
|
||||
store.commit('internalChat/UPDATE_CHANNEL', {
|
||||
id: props.channelId,
|
||||
hidden: true,
|
||||
});
|
||||
showSettings.value = false;
|
||||
router.push({
|
||||
name: 'internal_chat_home',
|
||||
params: { accountId: route.params.accountId },
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleMute() {
|
||||
try {
|
||||
await store.dispatch('internalChat/toggleMute', props.channelId);
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFavorite() {
|
||||
try {
|
||||
await store.dispatch('internalChat/toggleFavorite', props.channelId);
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDraftUpdate(content) {
|
||||
if (!content || !content.trim()) {
|
||||
deleteDraftForChannel();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await store.dispatch('internalChat/drafts/saveDraft', {
|
||||
channelId: props.channelId,
|
||||
content,
|
||||
});
|
||||
} catch {
|
||||
// Silently handle draft save error
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraftImmediately() {
|
||||
const content = editorRef.value?.getContent?.() || '';
|
||||
if (content.trim()) {
|
||||
store
|
||||
.dispatch('internalChat/drafts/saveDraft', {
|
||||
channelId: props.channelId,
|
||||
content,
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
deleteDraftForChannel();
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleSettings() {
|
||||
showSettings.value = !showSettings.value;
|
||||
if (showSettings.value) activeThread.value = null;
|
||||
localStorage.setItem(
|
||||
'internal_chat_settings_open',
|
||||
String(showSettings.value)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleLoadMore() {
|
||||
if (!messages.value.length) return;
|
||||
const oldestMessage = messages.value[0];
|
||||
isLoadingMore.value = true;
|
||||
try {
|
||||
await store.dispatch('internalChat/messages/fetchMessages', {
|
||||
channelId: props.channelId,
|
||||
params: { before: oldestMessage.created_at },
|
||||
});
|
||||
} catch {
|
||||
// silently ignore pagination errors
|
||||
} finally {
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.channelId,
|
||||
async (newId, oldId) => {
|
||||
if (oldId) saveDraftImmediately();
|
||||
activeThread.value = null;
|
||||
editingMessage.value = null;
|
||||
isViewingHistory.value = false;
|
||||
firstUnreadMessageId.value = null;
|
||||
const unreadCount = channel.value?.unread_count || 0;
|
||||
await fetchMessages();
|
||||
computeFirstUnreadId(unreadCount);
|
||||
markRead();
|
||||
loadDraft();
|
||||
}
|
||||
);
|
||||
|
||||
async function scrollToLinkedMessage(override = null) {
|
||||
const messageId = override?.messageId ?? route.query.messageId;
|
||||
const parentId = override?.parentId ?? route.query.parentId;
|
||||
if (!messageId) return;
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (parentId) {
|
||||
const numericParentId = Number(parentId);
|
||||
const numericMessageId = Number(messageId);
|
||||
if (activeThread.value?.id === numericParentId) {
|
||||
threadPanelRef.value?.jumpToReply(numericMessageId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await store.dispatch(
|
||||
'internalChat/messages/fetchThread',
|
||||
{
|
||||
channelId: props.channelId,
|
||||
messageId: numericParentId,
|
||||
}
|
||||
);
|
||||
if (response?.parent) {
|
||||
threadHighlightMessageId.value = numericMessageId;
|
||||
activeThread.value = response.parent;
|
||||
showSettings.value = false;
|
||||
localStorage.setItem('internal_chat_settings_open', 'false');
|
||||
}
|
||||
} catch {
|
||||
// Thread may not exist
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const scrolled = messageListRef.value?.scrollToMessage(Number(messageId));
|
||||
|
||||
if (!scrolled) {
|
||||
try {
|
||||
messageListRef.value?.scrollToMessageOnLoad(Number(messageId));
|
||||
await store.dispatch('internalChat/messages/fetchMessages', {
|
||||
channelId: props.channelId,
|
||||
params: { around: messageId },
|
||||
});
|
||||
isViewingHistory.value = true;
|
||||
} catch {
|
||||
// Message may not exist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadNewer() {
|
||||
if (!messages.value.length) return;
|
||||
const newestMessage = messages.value[messages.value.length - 1];
|
||||
try {
|
||||
const result = await store.dispatch('internalChat/messages/fetchMessages', {
|
||||
channelId: props.channelId,
|
||||
params: { after: newestMessage.created_at },
|
||||
});
|
||||
if (!result || result.length === 0) {
|
||||
isViewingHistory.value = false;
|
||||
}
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
function handleJumpToLatest() {
|
||||
isViewingHistory.value = false;
|
||||
fetchMessages();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
store.dispatch('internalChat/drafts/fetchDrafts').catch(() => {});
|
||||
const unreadCount = channel.value?.unread_count || 0;
|
||||
await fetchMessages();
|
||||
computeFirstUnreadId(unreadCount);
|
||||
markRead();
|
||||
loadDraft();
|
||||
scrollToLinkedMessage();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [route.query.messageId, route.query.parentId],
|
||||
([newMessageId]) => {
|
||||
if (!newMessageId) return;
|
||||
scrollToLinkedMessage();
|
||||
}
|
||||
);
|
||||
|
||||
function handleJumpToMessage(payload) {
|
||||
if (!payload || payload.channelId !== props.channelId) return;
|
||||
scrollToLinkedMessage({
|
||||
messageId: payload.messageId,
|
||||
parentId: payload.parentId,
|
||||
});
|
||||
}
|
||||
|
||||
emitter.on('internal-chat:jump-to-message', handleJumpToMessage);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
emitter.off('internal-chat:jump-to-message', handleJumpToMessage);
|
||||
if (typingOffTimer) {
|
||||
clearTimeout(typingOffTimer);
|
||||
typingOffTimer = null;
|
||||
}
|
||||
saveDraftImmediately();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full">
|
||||
<div class="flex flex-1 flex-col bg-n-solid-1 min-w-0">
|
||||
<ChannelHeader
|
||||
:channel="channel"
|
||||
:pinned-messages="pinnedMessages"
|
||||
@settings="handleToggleSettings"
|
||||
@scroll-to-pinned="handleScrollToPinned"
|
||||
/>
|
||||
<MessageList
|
||||
ref="messageListRef"
|
||||
:channel-id="channelId"
|
||||
:messages="messages"
|
||||
:current-user-id="currentUserId"
|
||||
:is-admin="isAdmin"
|
||||
:is-loading="messagesUIFlags.isFetching"
|
||||
:is-loading-more="isLoadingMore"
|
||||
:is-viewing-history="isViewingHistory"
|
||||
:first-unread-message-id="firstUnreadMessageId"
|
||||
:thread-draft-parent-ids="threadDraftParentIds"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@reply="handleReply"
|
||||
@open-thread="handleOpenThread"
|
||||
@add-reaction="handleAddReaction"
|
||||
@remove-reaction="handleRemoveReaction"
|
||||
@pin="handlePin"
|
||||
@unpin="handleUnpin"
|
||||
@vote="handleVote"
|
||||
@unvote="handleUnvote"
|
||||
@load-more="handleLoadMore"
|
||||
@load-newer="handleLoadNewer"
|
||||
@jump-to-latest="handleJumpToLatest"
|
||||
/>
|
||||
<TypingIndicator :typing-users="typingUsers" />
|
||||
<MessageEditor
|
||||
v-if="!isArchived"
|
||||
ref="editorRef"
|
||||
:disabled="messagesUIFlags.isSending"
|
||||
:editing-message="editingMessage"
|
||||
@send="handleSend"
|
||||
@typing="handleTyping"
|
||||
@draft-update="handleDraftUpdate"
|
||||
@create-poll="handleCreatePoll"
|
||||
@cancel-edit="handleCancelEdit"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="border-t border-n-slate-5 bg-n-solid-2 px-4 py-3 text-center text-sm text-n-slate-10"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.ARCHIVED') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThreadPanel
|
||||
v-if="activeThread"
|
||||
ref="threadPanelRef"
|
||||
:channel-id="channelId"
|
||||
:parent-message="activeThread"
|
||||
:current-user-id="currentUserId"
|
||||
:is-admin="isAdmin"
|
||||
:highlight-message-id="threadHighlightMessageId"
|
||||
@close="closeThread"
|
||||
/>
|
||||
|
||||
<ChannelSettings
|
||||
v-if="showSettings"
|
||||
ref="channelSettingsRef"
|
||||
:channel="channel"
|
||||
:current-user-id="currentUserId"
|
||||
:is-admin="isAdmin"
|
||||
@close="handleToggleSettings"
|
||||
@archive="handleArchive"
|
||||
@unarchive="handleUnarchive"
|
||||
@delete="handleDeleteChannel"
|
||||
@mute="handleToggleMute"
|
||||
@unmute="handleToggleMute"
|
||||
@favorite="handleToggleFavorite"
|
||||
@unfavorite="handleToggleFavorite"
|
||||
@close-dm="handleCloseDM"
|
||||
@edit-members="editMembersRef?.open()"
|
||||
/>
|
||||
|
||||
<PollCreator ref="pollCreatorRef" @submit="handlePollSubmit" />
|
||||
<ProFeatureNudge ref="proNudgeRef" :feature="proNudgeFeature" />
|
||||
<EditMembersModal
|
||||
ref="editMembersRef"
|
||||
:channel-id="channelId"
|
||||
@updated="channelSettingsRef?.fetchMembers()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,243 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatDistanceToNow, fromUnixTime } from 'date-fns';
|
||||
import { enUS, ptBR } from 'date-fns/locale';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import ConversationAPI from 'dashboard/api/conversations';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
displayId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
accountId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const allLabels = useMapGetter('labels/getLabels');
|
||||
const allInboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const dateFnsLocales = { pt_BR: ptBR };
|
||||
|
||||
// Module-level cache shared across all instances
|
||||
const conversationCache = new Map();
|
||||
|
||||
const conversation = ref(null);
|
||||
const fetchFailed = ref(false);
|
||||
|
||||
const contactName = computed(
|
||||
() => conversation.value?.meta?.sender?.name || ''
|
||||
);
|
||||
const contactThumbnail = computed(
|
||||
() => conversation.value?.meta?.sender?.thumbnail || ''
|
||||
);
|
||||
const inboxName = computed(() => {
|
||||
const inboxId = conversation.value?.inbox_id;
|
||||
if (!inboxId) return '';
|
||||
return allInboxes.value?.find(inbox => inbox.id === inboxId)?.name || '';
|
||||
});
|
||||
const assigneeName = computed(
|
||||
() => conversation.value?.meta?.assignee?.name || ''
|
||||
);
|
||||
const status = computed(() => conversation.value?.status || '');
|
||||
const lastActivityAt = computed(() => {
|
||||
const ts = conversation.value?.last_activity_at;
|
||||
if (!ts) return '';
|
||||
return formatDistanceToNow(fromUnixTime(ts), {
|
||||
addSuffix: true,
|
||||
locale: dateFnsLocales[locale.value] || enUS,
|
||||
});
|
||||
});
|
||||
const lastMessageObj = computed(() => {
|
||||
return (
|
||||
conversation.value?.last_non_activity_message ||
|
||||
conversation.value?.messages?.[0] ||
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
const lastMessage = computed(() => {
|
||||
if (!lastMessageObj.value) return '';
|
||||
return lastMessageObj.value.content || '';
|
||||
});
|
||||
|
||||
const isLastMessageOutgoing = computed(() => {
|
||||
return lastMessageObj.value?.message_type === 1;
|
||||
});
|
||||
|
||||
const labels = computed(() => {
|
||||
const names = conversation.value?.labels || [];
|
||||
return names.map(name => {
|
||||
const record = allLabels.value.find(l => l.title === name);
|
||||
const color = record?.color || '#1f93ff';
|
||||
return { name, color, textColor: getContrastingTextColor(color) };
|
||||
});
|
||||
});
|
||||
const priority = computed(() => conversation.value?.priority || '');
|
||||
|
||||
const priorityConfig = computed(() => {
|
||||
const map = {
|
||||
urgent: { icon: 'i-lucide-alert-circle', class: 'text-n-ruby-11' },
|
||||
high: { icon: 'i-lucide-arrow-up', class: 'text-n-ruby-11' },
|
||||
medium: { icon: 'i-lucide-minus', class: 'text-n-amber-11' },
|
||||
low: { icon: 'i-lucide-arrow-down', class: 'text-n-blue-11' },
|
||||
};
|
||||
return map[priority.value] || null;
|
||||
});
|
||||
|
||||
const statusConfig = computed(() => {
|
||||
const map = {
|
||||
open: {
|
||||
label: t('INTERNAL_CHAT.CONVERSATION_MENTION.STATUS.OPEN'),
|
||||
class: 'bg-n-teal-3 text-n-teal-11',
|
||||
},
|
||||
resolved: {
|
||||
label: t('INTERNAL_CHAT.CONVERSATION_MENTION.STATUS.RESOLVED'),
|
||||
class: 'bg-n-slate-3 text-n-slate-11',
|
||||
},
|
||||
pending: {
|
||||
label: t('INTERNAL_CHAT.CONVERSATION_MENTION.STATUS.PENDING'),
|
||||
class: 'bg-n-amber-3 text-n-amber-11',
|
||||
},
|
||||
snoozed: {
|
||||
label: t('INTERNAL_CHAT.CONVERSATION_MENTION.STATUS.SNOOZED'),
|
||||
class: 'bg-n-blue-3 text-n-blue-11',
|
||||
},
|
||||
};
|
||||
return map[status.value] || map.open;
|
||||
});
|
||||
|
||||
const conversationLink = computed(() =>
|
||||
frontendURL(
|
||||
conversationUrl({ accountId: props.accountId, id: props.displayId })
|
||||
)
|
||||
);
|
||||
|
||||
function navigate() {
|
||||
router.push(conversationLink.value);
|
||||
}
|
||||
|
||||
async function fetchConversation() {
|
||||
const cached = conversationCache.get(props.displayId);
|
||||
if (cached) {
|
||||
conversation.value = cached;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await ConversationAPI.show(props.displayId);
|
||||
if (data) {
|
||||
conversationCache.set(props.displayId, data);
|
||||
conversation.value = data;
|
||||
}
|
||||
} catch {
|
||||
fetchFailed.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchConversation);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="fetchFailed"
|
||||
class="mt-2 flex items-center gap-2 rounded-lg border border-n-slate-5 bg-n-alpha-1 px-3 py-2.5 text-sm text-n-slate-10"
|
||||
>
|
||||
<Icon icon="i-lucide-lock" class="size-4 flex-shrink-0" />
|
||||
<span>
|
||||
{{ t('INTERNAL_CHAT.CONVERSATION_MENTION.NO_ACCESS') }}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
v-else-if="conversation"
|
||||
:href="conversationLink"
|
||||
class="mt-2 block rounded-lg border border-n-slate-5 bg-n-solid-2 px-3 py-2.5 text-sm no-underline hover:bg-n-alpha-1 transition-colors"
|
||||
@click.exact.prevent="navigate"
|
||||
>
|
||||
<!-- Top row: contact + id + status -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar
|
||||
:name="contactName"
|
||||
:src="contactThumbnail"
|
||||
:size="28"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="font-medium text-n-slate-12">{{ contactName }}</span>
|
||||
<span class="ml-1 text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.CONVERSATION_MENTION.PREFIX') }}{{ displayId }}
|
||||
</span>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="priorityConfig"
|
||||
:icon="priorityConfig.icon"
|
||||
class="size-3.5 flex-shrink-0"
|
||||
:class="priorityConfig.class"
|
||||
/>
|
||||
<span
|
||||
class="flex-shrink-0 rounded-full px-1.5 py-0.5 text-xs font-medium"
|
||||
:class="statusConfig.class"
|
||||
>
|
||||
{{ statusConfig.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Labels -->
|
||||
<div v-if="labels.length" class="mt-1.5 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="label in labels"
|
||||
:key="label.name"
|
||||
:style="{
|
||||
backgroundColor: label.color,
|
||||
color: label.textColor,
|
||||
}"
|
||||
class="rounded-full px-1.5 py-0.5 text-[10px] font-medium"
|
||||
>
|
||||
{{ label.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Last message preview -->
|
||||
<div
|
||||
v-if="lastMessage"
|
||||
class="mt-1.5 flex items-start gap-1 text-xs text-n-slate-10 leading-relaxed"
|
||||
>
|
||||
<Icon
|
||||
:icon="
|
||||
isLastMessageOutgoing
|
||||
? 'i-lucide-arrow-right'
|
||||
: 'i-lucide-arrow-left'
|
||||
"
|
||||
class="mt-0.5 size-3 flex-shrink-0"
|
||||
:class="isLastMessageOutgoing ? 'text-n-blue-11' : 'text-n-slate-10'"
|
||||
/>
|
||||
<span class="line-clamp-2">{{ lastMessage }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Bottom row: inbox + assignee + time -->
|
||||
<div class="mt-1.5 flex items-center gap-2 text-xs text-n-slate-10">
|
||||
<span v-if="inboxName" class="truncate max-w-32">{{ inboxName }}</span>
|
||||
<span v-if="inboxName && assigneeName" class="h-3 w-px bg-n-slate-7" />
|
||||
<span v-if="assigneeName" class="truncate max-w-32">
|
||||
{{ assigneeName }}
|
||||
</span>
|
||||
<span class="ml-auto flex-shrink-0">{{ lastActivityAt }}</span>
|
||||
<Icon
|
||||
icon="i-lucide-external-link"
|
||||
class="size-3 text-n-slate-9 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const categoryName = ref('');
|
||||
const isCreating = ref(false);
|
||||
|
||||
const isFormValid = computed(() => categoryName.value.trim().length > 0);
|
||||
|
||||
function open() {
|
||||
categoryName.value = '';
|
||||
dialogRef.value?.open();
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!isFormValid.value) return;
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await store.dispatch('internalChat/createCategory', {
|
||||
category: { name: categoryName.value.trim() },
|
||||
});
|
||||
useAlert(t('INTERNAL_CHAT.CATEGORY.CREATED'));
|
||||
dialogRef.value?.close();
|
||||
} catch {
|
||||
// error is handled by throwErrorMessage in the action
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('INTERNAL_CHAT.CATEGORY.CREATE')"
|
||||
:confirm-button-label="t('INTERNAL_CHAT.CATEGORY.CREATE')"
|
||||
:disable-confirm-button="!isFormValid"
|
||||
:is-loading="isCreating"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.CATEGORY.NAME') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="categoryName"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-2 text-sm text-n-slate-12 placeholder-n-slate-10 outline-none focus:border-n-brand"
|
||||
:placeholder="t('INTERNAL_CHAT.CATEGORY.NAME_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -0,0 +1,237 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import NextSelect from 'dashboard/components-next/select/Select.vue';
|
||||
import ProFeatureNudge from './ProFeatureNudge.vue';
|
||||
import { useInternalChatPro } from 'dashboard/composables/useInternalChatPro';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { maxPrivateChannels } = useInternalChatPro();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const channelName = ref('');
|
||||
const channelDescription = ref('');
|
||||
const channelType = ref('public_channel');
|
||||
const categoryId = ref('');
|
||||
const isCreating = ref(false);
|
||||
const selectedAgentIds = ref([]);
|
||||
const memberSearchQuery = ref('');
|
||||
|
||||
const categories = computed(
|
||||
() => store.getters['internalChat/getCategories'] || []
|
||||
);
|
||||
|
||||
const currentUserId = computed(() => store.getters.getCurrentUser?.id);
|
||||
|
||||
const agents = computed(() => {
|
||||
const allAgents = store.getters['agents/getAgents'] || [];
|
||||
return allAgents.filter(agent => agent.id !== currentUserId.value);
|
||||
});
|
||||
|
||||
const filteredAgents = computed(() => {
|
||||
if (!memberSearchQuery.value) return agents.value;
|
||||
const query = memberSearchQuery.value.toLowerCase();
|
||||
return agents.value.filter(agent =>
|
||||
(agent.name || '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const isPrivate = computed({
|
||||
get: () => channelType.value === 'private_channel',
|
||||
set: val => {
|
||||
channelType.value = val ? 'private_channel' : 'public_channel';
|
||||
},
|
||||
});
|
||||
|
||||
const privateChannelCount = computed(() => {
|
||||
const channels = store.getters['internalChat/getChannels'] || [];
|
||||
return channels.filter(
|
||||
c => c.channel_type === 'private_channel' && c.status !== 'archived'
|
||||
).length;
|
||||
});
|
||||
|
||||
const canCreatePrivate = computed(() => {
|
||||
if (!maxPrivateChannels.value) return true;
|
||||
return privateChannelCount.value < maxPrivateChannels.value;
|
||||
});
|
||||
|
||||
const privateLimitReached = computed(
|
||||
() => isPrivate.value && !canCreatePrivate.value
|
||||
);
|
||||
|
||||
const categoryOptions = computed(() => [
|
||||
{ value: '', label: t('INTERNAL_CHAT.CATEGORY.NONE') },
|
||||
...categories.value.map(cat => ({ value: cat.id, label: cat.name })),
|
||||
]);
|
||||
|
||||
const isFormValid = computed(
|
||||
() => channelName.value.trim().length > 0 && !privateLimitReached.value
|
||||
);
|
||||
|
||||
function toggleAgent(agentId) {
|
||||
const idx = selectedAgentIds.value.indexOf(agentId);
|
||||
if (idx === -1) {
|
||||
selectedAgentIds.value.push(agentId);
|
||||
} else {
|
||||
selectedAgentIds.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
channelName.value = '';
|
||||
channelDescription.value = '';
|
||||
channelType.value = 'public_channel';
|
||||
categoryId.value = '';
|
||||
selectedAgentIds.value = [];
|
||||
memberSearchQuery.value = '';
|
||||
store.dispatch('agents/get');
|
||||
dialogRef.value?.open();
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!isFormValid.value) return;
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await store.dispatch('internalChat/create', {
|
||||
channel: {
|
||||
name: channelName.value.trim(),
|
||||
description: channelDescription.value.trim(),
|
||||
channel_type: channelType.value,
|
||||
category_id: categoryId.value || null,
|
||||
},
|
||||
member_ids: isPrivate.value ? selectedAgentIds.value : [],
|
||||
});
|
||||
useAlert(t('INTERNAL_CHAT.CHANNEL.CREATED'));
|
||||
dialogRef.value?.close();
|
||||
} catch (error) {
|
||||
if (error?.response?.status === 402) {
|
||||
// Backend rejected: private channel limit reached. Refresh UI state.
|
||||
channelType.value = 'public_channel';
|
||||
}
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('INTERNAL_CHAT.NEW_CHANNEL')"
|
||||
:confirm-button-label="t('INTERNAL_CHAT.NEW_CHANNEL')"
|
||||
:disable-confirm-button="!isFormValid"
|
||||
:is-loading="isCreating"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.NAME') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="channelName"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-2 text-sm text-n-slate-12 placeholder-n-slate-10 outline-none focus:border-n-brand"
|
||||
:placeholder="t('INTERNAL_CHAT.CHANNEL.NAME')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.DESCRIPTION') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="channelDescription"
|
||||
rows="3"
|
||||
class="w-full rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-2 text-sm text-n-slate-12 placeholder-n-slate-10 outline-none focus:border-n-brand resize-none"
|
||||
:placeholder="t('INTERNAL_CHAT.CHANNEL.DESCRIPTION')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="categories.length > 0" class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.CATEGORY.NAME') }}
|
||||
</label>
|
||||
<NextSelect
|
||||
v-model="categoryId"
|
||||
class="w-full"
|
||||
:options="categoryOptions"
|
||||
:placeholder="t('INTERNAL_CHAT.CATEGORY.NONE')"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex cursor-pointer items-center justify-between">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.PRIVATE') }}
|
||||
</span>
|
||||
<Switch v-model="isPrivate" />
|
||||
</label>
|
||||
|
||||
<!-- Public channel info note -->
|
||||
<div
|
||||
v-if="!isPrivate"
|
||||
class="rounded-lg bg-n-alpha-1 px-3 py-2 text-sm text-n-slate-10"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.ALL_AGENTS_NOTE') }}
|
||||
</div>
|
||||
|
||||
<!-- Private channel limit reached -->
|
||||
<ProFeatureNudge
|
||||
v-if="privateLimitReached"
|
||||
feature="private_channels"
|
||||
inline
|
||||
/>
|
||||
|
||||
<!-- Private channel: agent selection -->
|
||||
<template v-if="isPrivate && canCreatePrivate">
|
||||
<!-- Agent selection -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.SELECT_AGENTS') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="memberSearchQuery"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-2 text-sm text-n-slate-12 placeholder-n-slate-10 outline-none focus:border-n-brand"
|
||||
:placeholder="t('INTERNAL_CHAT.DM.SELECT_AGENTS')"
|
||||
/>
|
||||
<div
|
||||
class="flex max-h-48 flex-col gap-1 overflow-y-auto rounded-lg border border-n-slate-6 p-2"
|
||||
>
|
||||
<label
|
||||
v-for="agent in filteredAgents"
|
||||
:key="agent.id"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm text-n-slate-12 hover:bg-n-alpha-1"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedAgentIds.includes(agent.id)"
|
||||
class="rounded border-n-slate-6"
|
||||
@change="toggleAgent(agent.id)"
|
||||
/>
|
||||
<Avatar
|
||||
:name="agent.name || ''"
|
||||
:src="agent.thumbnail || ''"
|
||||
:size="24"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="truncate">{{ agent.name }}</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="filteredAgents.length === 0"
|
||||
class="px-2 py-3 text-center text-sm text-n-slate-10"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.DM.SELECT_AGENTS') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -0,0 +1,121 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const searchQuery = ref('');
|
||||
const selectedAgentId = ref(null);
|
||||
const isCreating = ref(false);
|
||||
|
||||
const currentUserId = computed(() => store.getters.getCurrentUser?.id);
|
||||
|
||||
const agents = computed(() => {
|
||||
return store.getters['agents/getAgents'] || [];
|
||||
});
|
||||
|
||||
const filteredAgents = computed(() => {
|
||||
if (!searchQuery.value) return agents.value;
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return agents.value.filter(agent =>
|
||||
(agent.name || '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const isFormValid = computed(() => selectedAgentId.value !== null);
|
||||
|
||||
function open() {
|
||||
searchQuery.value = '';
|
||||
selectedAgentId.value = null;
|
||||
store.dispatch('agents/get');
|
||||
dialogRef.value?.open();
|
||||
}
|
||||
|
||||
function selectAgent(agentId) {
|
||||
selectedAgentId.value = agentId;
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!isFormValid.value) return;
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
const result = await store.dispatch('internalChat/create', {
|
||||
channel: { channel_type: 'dm' },
|
||||
member_ids: [selectedAgentId.value],
|
||||
});
|
||||
dialogRef.value?.close();
|
||||
router.push({
|
||||
name: 'internal_chat_dm',
|
||||
params: { accountId: route.params.accountId, channelId: result.id },
|
||||
});
|
||||
} catch {
|
||||
// error is handled by throwErrorMessage in the action
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('INTERNAL_CHAT.DM.NEW')"
|
||||
:confirm-button-label="t('INTERNAL_CHAT.DM.NEW')"
|
||||
:disable-confirm-button="!isFormValid"
|
||||
:is-loading="isCreating"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-2 text-sm text-n-slate-12 placeholder-n-slate-10 outline-none focus:border-n-brand"
|
||||
:placeholder="t('INTERNAL_CHAT.DM.SELECT_AGENTS')"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||
<button
|
||||
v-for="agent in filteredAgents"
|
||||
:key="agent.id"
|
||||
type="button"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-left transition-colors"
|
||||
:class="
|
||||
selectedAgentId === agent.id
|
||||
? 'bg-n-brand/10 text-n-slate-12'
|
||||
: 'text-n-slate-11 hover:bg-n-alpha-1'
|
||||
"
|
||||
@click="selectAgent(agent.id)"
|
||||
>
|
||||
<Avatar
|
||||
:name="agent.name || ''"
|
||||
:src="agent.thumbnail || ''"
|
||||
:size="28"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="flex-1 truncate">
|
||||
{{ agent.name }}
|
||||
<span v-if="agent.id === currentUserId" class="text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.YOU') }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<p
|
||||
v-if="filteredAgents.length === 0"
|
||||
class="px-3 py-4 text-center text-sm text-n-slate-10"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.DM.SELECT_AGENTS') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const accountId = computed(() => route.params.accountId);
|
||||
const currentUserId = computed(() => store.getters.getCurrentUser?.id);
|
||||
|
||||
const drafts = computed(() => {
|
||||
return store.getters['internalChat/drafts/getDrafts'] || [];
|
||||
});
|
||||
|
||||
const uiFlags = computed(() => {
|
||||
return store.getters['internalChat/drafts/getUIFlags'];
|
||||
});
|
||||
|
||||
function timeSince(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (seconds < 60) return t('INTERNAL_CHAT.DRAFT.SAVED_AGO', { time: '< 1m' });
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60)
|
||||
return t('INTERNAL_CHAT.DRAFT.SAVED_AGO', { time: `${minutes}m` });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24)
|
||||
return t('INTERNAL_CHAT.DRAFT.SAVED_AGO', { time: `${hours}h` });
|
||||
const days = Math.floor(hours / 24);
|
||||
return t('INTERNAL_CHAT.DRAFT.SAVED_AGO', { time: `${days}d` });
|
||||
}
|
||||
|
||||
function getChannelName(draft) {
|
||||
const channel = store.getters['internalChat/getChannelById'](
|
||||
draft.internal_chat_channel_id
|
||||
);
|
||||
if (!channel) {
|
||||
return t('INTERNAL_CHAT.DRAFT.CHANNEL_LABEL', {
|
||||
channelId: draft.internal_chat_channel_id,
|
||||
});
|
||||
}
|
||||
if (channel.channel_type === 'dm') {
|
||||
const members = channel.members || [];
|
||||
const peer =
|
||||
members.find(m => m.user_id !== currentUserId.value) || members[0];
|
||||
return peer?.name || channel.name || t('INTERNAL_CHAT.DIRECT_MESSAGES');
|
||||
}
|
||||
return (
|
||||
channel.name ||
|
||||
t('INTERNAL_CHAT.DRAFT.CHANNEL_LABEL', {
|
||||
channelId: draft.internal_chat_channel_id,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function navigateToChannel(draft) {
|
||||
const channel = store.getters['internalChat/getChannelById'](
|
||||
draft.internal_chat_channel_id
|
||||
);
|
||||
const routeName =
|
||||
channel?.channel_type === 'dm'
|
||||
? 'internal_chat_dm'
|
||||
: 'internal_chat_channel';
|
||||
const query = draft.parent_id
|
||||
? { messageId: draft.parent_id, openThread: 1 }
|
||||
: {};
|
||||
router.push({
|
||||
name: routeName,
|
||||
params: {
|
||||
accountId: accountId.value,
|
||||
channelId: draft.internal_chat_channel_id,
|
||||
},
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(draft) {
|
||||
try {
|
||||
await store.dispatch('internalChat/drafts/deleteDraft', {
|
||||
channelId: draft.internal_chat_channel_id,
|
||||
draftId: draft.id,
|
||||
parentId: draft.parent_id,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('internalChat/drafts/fetchDrafts').catch(() => {
|
||||
// Silently handle fetch error
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col bg-n-solid-1">
|
||||
<div
|
||||
class="flex h-[53px] items-center border-b border-n-slate-5 bg-n-solid-2 px-4"
|
||||
>
|
||||
<Icon icon="i-lucide-file-edit" class="mr-2 size-5 text-n-slate-11" />
|
||||
<h2 class="text-sm font-semibold text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.DRAFT.TITLE') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-if="uiFlags.isFetching"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<Spinner :size="16" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="drafts.length === 0"
|
||||
class="flex h-full flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<Icon icon="i-lucide-file-edit" class="size-10 text-n-slate-8" />
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.DRAFT.NO_DRAFTS') }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.DRAFT.NO_DRAFTS_SUBTITLE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-n-slate-5">
|
||||
<div
|
||||
v-for="draft in drafts"
|
||||
:key="draft.id"
|
||||
class="flex items-start gap-3 px-4 py-3 hover:bg-n-alpha-1 transition-colors cursor-pointer"
|
||||
@click="navigateToChannel(draft)"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12 truncate">
|
||||
{{ getChannelName(draft) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="draft.parent_id"
|
||||
class="flex items-center gap-0.5 text-xs text-n-slate-10"
|
||||
>
|
||||
<Icon icon="i-lucide-message-square" class="size-3" />
|
||||
{{ t('INTERNAL_CHAT.THREAD.TITLE') }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ timeSince(draft.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-sm text-n-slate-10 truncate">
|
||||
{{ draft.content }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex-shrink-0 flex items-center justify-center rounded p-1 text-n-slate-11 hover:bg-n-ruby-3 hover:text-n-ruby-11"
|
||||
:title="t('INTERNAL_CHAT.DRAFT.DELETE')"
|
||||
@click.stop="handleDelete(draft)"
|
||||
>
|
||||
<Icon icon="i-lucide-trash-2" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,167 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import InternalChatChannelsAPI from 'dashboard/api/internalChatChannels';
|
||||
|
||||
const props = defineProps({
|
||||
channelId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updated']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const memberUserIds = ref(new Set());
|
||||
const searchQuery = ref('');
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const originalMemberIds = ref(new Set());
|
||||
// Maps user_id -> member record id (needed for removeMember API)
|
||||
const memberRecordMap = ref(new Map());
|
||||
|
||||
const currentUserId = computed(() => store.getters.getCurrentUser?.id);
|
||||
|
||||
const allAgents = computed(() => {
|
||||
const agents = store.getters['agents/getAgents'] || [];
|
||||
return agents.filter(a => a.id !== currentUserId.value);
|
||||
});
|
||||
|
||||
const filteredAgents = computed(() => {
|
||||
if (!searchQuery.value) return allAgents.value;
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return allAgents.value.filter(a =>
|
||||
(a.name || '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
async function fetchMembers() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const { data } = await InternalChatChannelsAPI.getMembers(props.channelId);
|
||||
const ids = new Set(data.map(m => m.user_id));
|
||||
const recordMap = new Map();
|
||||
data.forEach(m => recordMap.set(m.user_id, m.id));
|
||||
memberUserIds.value = ids;
|
||||
originalMemberIds.value = new Set(ids);
|
||||
memberRecordMap.value = recordMap;
|
||||
} catch {
|
||||
// silently handle
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAgent(agentId) {
|
||||
const ids = new Set(memberUserIds.value);
|
||||
if (ids.has(agentId)) {
|
||||
ids.delete(agentId);
|
||||
} else {
|
||||
ids.add(agentId);
|
||||
}
|
||||
memberUserIds.value = ids;
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
const toAdd = [...memberUserIds.value].filter(
|
||||
id => !originalMemberIds.value.has(id)
|
||||
);
|
||||
const toRemove = [...originalMemberIds.value].filter(
|
||||
id => !memberUserIds.value.has(id)
|
||||
);
|
||||
|
||||
if (toAdd.length === 0 && toRemove.length === 0) {
|
||||
dialogRef.value?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
// Serialize to avoid race conditions with ActionCable broadcasts
|
||||
const addChain = toAdd.reduce(
|
||||
(p, id) =>
|
||||
p.then(() => InternalChatChannelsAPI.addMember(props.channelId, id)),
|
||||
Promise.resolve()
|
||||
);
|
||||
await addChain;
|
||||
|
||||
const removeChain = toRemove.reduce((p, userId) => {
|
||||
const memberId = memberRecordMap.value.get(userId);
|
||||
return memberId
|
||||
? p.then(() =>
|
||||
InternalChatChannelsAPI.removeMember(props.channelId, memberId)
|
||||
)
|
||||
: p;
|
||||
}, Promise.resolve());
|
||||
await removeChain;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
dialogRef.value?.close();
|
||||
emit('updated');
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
searchQuery.value = '';
|
||||
store.dispatch('agents/get');
|
||||
fetchMembers();
|
||||
dialogRef.value?.open();
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('INTERNAL_CHAT.CHANNEL.EDIT_MEMBERS')"
|
||||
:confirm-button-label="t('INTERNAL_CHAT.CHANNEL.SAVE_MEMBERS')"
|
||||
:is-loading="isSaving"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-2 text-sm text-n-slate-12 placeholder-n-slate-10 outline-none focus:border-n-brand"
|
||||
:placeholder="t('INTERNAL_CHAT.DM.SELECT_AGENTS')"
|
||||
/>
|
||||
<div
|
||||
class="flex max-h-64 flex-col gap-1 overflow-y-auto rounded-lg border border-n-slate-6 p-2"
|
||||
>
|
||||
<label
|
||||
v-for="agent in filteredAgents"
|
||||
:key="agent.id"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm text-n-slate-12 hover:bg-n-alpha-1"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="memberUserIds.has(agent.id)"
|
||||
class="rounded border-n-slate-6"
|
||||
@change="toggleAgent(agent.id)"
|
||||
/>
|
||||
<Avatar
|
||||
:name="agent.name || ''"
|
||||
:src="agent.thumbnail || ''"
|
||||
:size="24"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="truncate">{{ agent.name }}</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="filteredAgents.length === 0"
|
||||
class="px-2 py-3 text-center text-sm text-n-slate-10"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.DM.SELECT_AGENTS') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
reactions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'remove', 'close']);
|
||||
|
||||
const QUICK_EMOJIS = [
|
||||
{ emoji: '\uD83D\uDC4D', label: 'thumbs up' },
|
||||
{ emoji: '\u2764\uFE0F', label: 'heart' },
|
||||
{ emoji: '\uD83D\uDE02', label: 'joy' },
|
||||
{ emoji: '\uD83D\uDE2E', label: 'surprised' },
|
||||
{ emoji: '\uD83D\uDE22', label: 'sad' },
|
||||
{ emoji: '\uD83D\uDE4F', label: 'pray' },
|
||||
{ emoji: '\uD83D\uDD25', label: 'fire' },
|
||||
{ emoji: '\uD83C\uDF89', label: 'party' },
|
||||
];
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
|
||||
function selectEmoji(emoji) {
|
||||
const existingReaction = props.reactions.find(
|
||||
r => r.emoji === emoji && r.user_id === props.currentUserId
|
||||
);
|
||||
if (existingReaction) {
|
||||
emit('remove', existingReaction.id);
|
||||
} else {
|
||||
emit('select', emoji);
|
||||
}
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false;
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex items-center justify-center rounded p-1 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12"
|
||||
@click="toggle"
|
||||
>
|
||||
<Icon icon="i-lucide-smile-plus" class="size-4" />
|
||||
</button>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
v-on-click-outside="close"
|
||||
class="absolute bottom-full right-0 z-50 mb-1 grid w-max max-w-[11rem] grid-cols-4 gap-1 rounded-lg border border-n-slate-6 bg-n-solid-2 p-2 shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="item in QUICK_EMOJIS"
|
||||
:key="item.label"
|
||||
class="flex items-center justify-center rounded p-1 text-base hover:bg-n-alpha-2"
|
||||
:title="item.label"
|
||||
@click="selectEmoji(item.emoji)"
|
||||
>
|
||||
{{ item.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ChannelSidebar from './ChannelSidebar.vue';
|
||||
import ChannelView from './ChannelView.vue';
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const activeChannelId = computed(() => {
|
||||
return Number(route.params.channelId) || null;
|
||||
});
|
||||
|
||||
const activeChannel = computed(() => {
|
||||
if (!activeChannelId.value) return null;
|
||||
return store.getters['internalChat/getChannelById'](activeChannelId.value);
|
||||
});
|
||||
|
||||
const isDraftsRoute = computed(() => {
|
||||
return route.name === 'internal_chat_drafts';
|
||||
});
|
||||
|
||||
async function fetchChannels() {
|
||||
try {
|
||||
await store.dispatch('internalChat/get');
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.FETCH_CHANNELS'));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchChannels();
|
||||
// If navigated directly to a channel not in the store (e.g. archived), fetch it
|
||||
if (activeChannelId.value && !activeChannel.value) {
|
||||
store.dispatch('internalChat/show', activeChannelId.value).catch(() => {});
|
||||
}
|
||||
store.dispatch('agents/get');
|
||||
store.dispatch('teams/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full">
|
||||
<ChannelSidebar />
|
||||
<div class="flex-1 min-w-0">
|
||||
<ChannelView
|
||||
v-if="activeChannelId && activeChannel"
|
||||
:key="activeChannelId"
|
||||
:channel-id="activeChannelId"
|
||||
/>
|
||||
<router-view v-else-if="isDraftsRoute" />
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full flex-col items-center justify-center gap-2 bg-n-solid-1"
|
||||
>
|
||||
<Icon icon="i-lucide-messages-square" class="size-10 text-n-slate-8" />
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.EMPTY_STATE.TITLE') }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.EMPTY_STATE.SUBTITLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,467 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ReactionDisplay from './ReactionDisplay.vue';
|
||||
import EmojiReactionPicker from './EmojiReactionPicker.vue';
|
||||
import PollDisplay from './PollDisplay.vue';
|
||||
import ConversationPreviewCard from './ConversationPreviewCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inThread: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
groupWithPrevious: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
groupWithNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasThreadDraft: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'edit',
|
||||
'delete',
|
||||
'reply',
|
||||
'openThread',
|
||||
'addReaction',
|
||||
'removeReaction',
|
||||
'pin',
|
||||
'unpin',
|
||||
'vote',
|
||||
'unvote',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const senderName = computed(() => {
|
||||
return props.message.sender?.name || t('INTERNAL_CHAT.MESSAGE.DELETED_USER');
|
||||
});
|
||||
|
||||
const senderAvatar = computed(() => {
|
||||
return props.message.sender?.avatar_url || '';
|
||||
});
|
||||
|
||||
const senderAvailability = computed(() => {
|
||||
const senderId = props.message.sender?.id;
|
||||
if (!senderId) return null;
|
||||
const agent = store.getters['agents/getAgentById'](senderId);
|
||||
return agent?.availability_status || null;
|
||||
});
|
||||
|
||||
const timestamp = computed(() => {
|
||||
const createdAt = props.message.created_at;
|
||||
if (!createdAt) return '';
|
||||
const unixTime =
|
||||
typeof createdAt === 'number'
|
||||
? createdAt
|
||||
: Math.floor(new Date(createdAt).getTime() / 1000);
|
||||
return messageTimestamp(unixTime, 'h:mm a');
|
||||
});
|
||||
|
||||
const isOwnMessage = computed(() => {
|
||||
return props.message.sender?.id === props.currentUserId;
|
||||
});
|
||||
|
||||
const isEdited = computed(() => {
|
||||
return !!props.message.content_attributes?.edited_at;
|
||||
});
|
||||
|
||||
const isDeleted = computed(() => {
|
||||
return !!props.message.content_attributes?.deleted;
|
||||
});
|
||||
|
||||
const isPoll = computed(() => {
|
||||
return props.message.content_type === 'poll';
|
||||
});
|
||||
|
||||
const isPinned = computed(() => {
|
||||
return !!props.message.content_attributes?.pinned;
|
||||
});
|
||||
|
||||
const threadReplyCount = computed(() => {
|
||||
return props.message.replies_count || 0;
|
||||
});
|
||||
|
||||
const canEdit = computed(() => {
|
||||
return isOwnMessage.value && !isDeleted.value && !isPoll.value;
|
||||
});
|
||||
|
||||
const canDelete = computed(() => {
|
||||
return (isOwnMessage.value || props.isAdmin) && !isDeleted.value;
|
||||
});
|
||||
|
||||
const canPin = computed(() => {
|
||||
return !isDeleted.value;
|
||||
});
|
||||
|
||||
const messageContent = computed(() => {
|
||||
if (isDeleted.value) {
|
||||
return t('INTERNAL_CHAT.MESSAGE.DELETED');
|
||||
}
|
||||
return props.message.content || '';
|
||||
});
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (isDeleted.value) return '';
|
||||
const formatter = new MessageFormatter(props.message.content || '');
|
||||
return formatter.formattedMessage;
|
||||
});
|
||||
|
||||
const reactions = computed(() => {
|
||||
return props.message.reactions || [];
|
||||
});
|
||||
|
||||
const conversationRefs = computed(() => {
|
||||
if (isDeleted.value || !props.message.content) return [];
|
||||
const matches = props.message.content.matchAll(
|
||||
/mention:\/\/conversation\/(\d+)\//g
|
||||
);
|
||||
return [...new Set([...matches].map(m => m[1]))];
|
||||
});
|
||||
|
||||
function handleContentClick(event) {
|
||||
const mention = event.target.closest('.prosemirror-mention-conversation');
|
||||
if (!mention) return;
|
||||
const displayId = mention.dataset.conversationId;
|
||||
if (!displayId) return;
|
||||
const url = frontendURL(
|
||||
conversationUrl({ accountId: accountId.value, id: displayId })
|
||||
);
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
function attachmentFileName(attachment) {
|
||||
if (attachment.file_url) {
|
||||
const url = attachment.file_url.split('?')[0];
|
||||
const name = decodeURIComponent(url.split('/').pop());
|
||||
if (name && name !== 'null') return name;
|
||||
}
|
||||
const ext = attachment.extension ? `.${attachment.extension}` : '';
|
||||
return `${attachment.file_type || 'file'}${ext}`;
|
||||
}
|
||||
|
||||
const attachments = computed(() => {
|
||||
if (isDeleted.value) return [];
|
||||
return props.message.attachments || [];
|
||||
});
|
||||
|
||||
const deleteDialogRef = ref(null);
|
||||
|
||||
function handleEdit() {
|
||||
emit('edit', props.message);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
deleteDialogRef.value?.open();
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
emit('delete', props.message);
|
||||
deleteDialogRef.value?.close();
|
||||
}
|
||||
|
||||
function handleReply() {
|
||||
emit('reply', props.message);
|
||||
}
|
||||
|
||||
function handleOpenThread() {
|
||||
emit('openThread', props.message);
|
||||
}
|
||||
|
||||
function handlePin() {
|
||||
if (isPinned.value) {
|
||||
emit('unpin', props.message);
|
||||
} else {
|
||||
emit('pin', props.message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyLink() {
|
||||
const baseUrl = window.chatwootConfig?.hostURL || window.location.origin;
|
||||
const path = window.location.pathname;
|
||||
const params = [`messageId=${props.message.id}`];
|
||||
if (props.message.parent_id) {
|
||||
params.push(`parentId=${props.message.parent_id}`);
|
||||
}
|
||||
const url = `${baseUrl}${path}?${params.join('&')}`;
|
||||
copyTextToClipboard(url);
|
||||
useAlert(t('INTERNAL_CHAT.MESSAGE.LINK_COPIED'));
|
||||
}
|
||||
|
||||
function handleAddReaction(emoji) {
|
||||
emit('addReaction', { messageId: props.message.id, emoji });
|
||||
}
|
||||
|
||||
function handleRemoveReaction(reactionId) {
|
||||
emit('removeReaction', {
|
||||
messageId: props.message.id,
|
||||
reactionId,
|
||||
});
|
||||
}
|
||||
|
||||
function handleVote(payload) {
|
||||
emit('vote', payload);
|
||||
}
|
||||
|
||||
function handleUnvote(payload) {
|
||||
emit('unvote', payload);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group relative flex items-start gap-3 px-4 hover:bg-n-alpha-1 transition-colors"
|
||||
:class="groupWithPrevious ? 'py-0.5' : 'py-1.5'"
|
||||
>
|
||||
<div v-if="!groupWithPrevious" class="flex-shrink-0 pt-0.5">
|
||||
<Avatar
|
||||
:name="senderName"
|
||||
:src="senderAvatar"
|
||||
:size="32"
|
||||
:status="senderAvailability"
|
||||
hide-offline-status
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="w-8 flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-if="!groupWithPrevious" class="flex items-baseline gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ senderName }}
|
||||
</span>
|
||||
<time class="text-xs text-n-slate-10">{{ timestamp }}</time>
|
||||
<span v-if="isEdited" class="text-xs text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.MESSAGE.EDITED') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isPinned"
|
||||
class="flex items-center gap-1 text-xs text-n-amber-11"
|
||||
:title="t('INTERNAL_CHAT.PIN.PINNED_MESSAGE')"
|
||||
>
|
||||
<Icon icon="i-lucide-pin" class="size-3" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Poll content -->
|
||||
<div v-if="isPoll && !isDeleted" class="mt-1">
|
||||
<PollDisplay
|
||||
:message="message"
|
||||
:current-user-id="currentUserId"
|
||||
:is-admin="isAdmin"
|
||||
@vote="handleVote"
|
||||
@unvote="handleUnvote"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Regular message content -->
|
||||
<div
|
||||
v-else
|
||||
class="text-sm text-n-slate-12 break-words"
|
||||
:class="groupWithPrevious ? '' : 'mt-0.5'"
|
||||
>
|
||||
<div
|
||||
v-if="isDeleted"
|
||||
class="flex items-center gap-1.5 rounded-lg bg-n-alpha-1 px-3 py-2 text-n-slate-10"
|
||||
>
|
||||
<Icon icon="i-lucide-trash-2" class="size-3.5 flex-shrink-0" />
|
||||
<span class="italic">{{ messageContent }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="inline [&_.prosemirror-mention-node]:font-semibold [&_.prosemirror-mention-node]:text-n-brand [&_.prosemirror-mention-conversation]:cursor-pointer [&_.prosemirror-mention-conversation]:underline"
|
||||
@click="handleContentClick"
|
||||
>
|
||||
<div
|
||||
v-dompurify-html="renderedContent"
|
||||
class="prose prose-bubble inline"
|
||||
/>
|
||||
<span
|
||||
v-if="groupWithPrevious && isEdited"
|
||||
class="ml-1 text-xs text-n-slate-10"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.MESSAGE.EDITED') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Conversation mention preview cards -->
|
||||
<ConversationPreviewCard
|
||||
v-for="displayId in conversationRefs"
|
||||
:key="`conv-${displayId}`"
|
||||
:display-id="displayId"
|
||||
:account-id="accountId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div v-if="attachments.length" class="mt-1.5 flex flex-wrap gap-2">
|
||||
<template v-for="attachment in attachments" :key="attachment.id">
|
||||
<a
|
||||
v-if="attachment.file_type === 'image'"
|
||||
:href="attachment.file_url || attachment.external_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block overflow-hidden rounded-lg border border-n-slate-6"
|
||||
>
|
||||
<img
|
||||
:src="attachment.file_url || attachment.external_url"
|
||||
class="max-h-60 max-w-xs object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
v-else
|
||||
:href="attachment.file_url || attachment.external_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-n-slate-6 bg-n-alpha-1 px-2.5 py-1.5 text-xs text-n-slate-12 hover:bg-n-alpha-2"
|
||||
>
|
||||
<Icon icon="i-lucide-paperclip" class="size-3.5 text-n-slate-10" />
|
||||
<span class="max-w-48 truncate">
|
||||
{{ attachmentFileName(attachment) }}
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ReactionDisplay
|
||||
:reactions="reactions"
|
||||
:current-user-id="currentUserId"
|
||||
@remove="handleRemoveReaction"
|
||||
/>
|
||||
|
||||
<!-- Thread link / reply count -->
|
||||
<div
|
||||
v-if="
|
||||
!inThread &&
|
||||
((message.parent_id && !groupWithNext) ||
|
||||
threadReplyCount > 0 ||
|
||||
hasThreadDraft)
|
||||
"
|
||||
class="mt-1 flex items-center gap-2"
|
||||
>
|
||||
<button
|
||||
v-if="message.parent_id && !groupWithNext"
|
||||
class="flex items-center gap-1 text-xs font-medium text-n-brand hover:underline"
|
||||
@click="handleOpenThread"
|
||||
>
|
||||
<Icon icon="i-lucide-message-square" class="size-3" />
|
||||
{{ t('INTERNAL_CHAT.THREAD.TITLE') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="threadReplyCount > 0"
|
||||
class="flex items-center gap-1 text-xs font-medium text-n-brand hover:underline"
|
||||
@click="handleOpenThread"
|
||||
>
|
||||
<Icon icon="i-lucide-message-square" class="size-3" />
|
||||
{{ t('INTERNAL_CHAT.THREAD.REPLIES', { count: threadReplyCount }) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasThreadDraft"
|
||||
class="flex items-center gap-1 text-xs font-medium text-n-amber-11 hover:underline"
|
||||
@click="handleOpenThread"
|
||||
>
|
||||
<Icon icon="i-lucide-file-edit" class="size-3" />
|
||||
{{ t('INTERNAL_CHAT.DRAFT.LABEL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isDeleted"
|
||||
class="absolute right-2 top-0 flex items-center gap-0.5 rounded-md bg-n-solid-2 border border-n-slate-5 shadow-sm px-0.5 py-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity z-10"
|
||||
>
|
||||
<EmojiReactionPicker
|
||||
:reactions="reactions"
|
||||
:current-user-id="currentUserId"
|
||||
@select="handleAddReaction"
|
||||
@remove="handleRemoveReaction"
|
||||
/>
|
||||
<button
|
||||
v-if="!inThread"
|
||||
class="flex items-center justify-center rounded p-1 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12"
|
||||
:title="t('INTERNAL_CHAT.MESSAGE.REPLY')"
|
||||
@click="handleReply"
|
||||
>
|
||||
<Icon icon="i-lucide-reply" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center rounded p-1 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12"
|
||||
:title="t('INTERNAL_CHAT.MESSAGE.COPY_LINK')"
|
||||
@click="handleCopyLink"
|
||||
>
|
||||
<Icon icon="i-lucide-link" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="canPin && !inThread"
|
||||
class="flex items-center justify-center rounded p-1 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12"
|
||||
:title="
|
||||
isPinned ? t('INTERNAL_CHAT.PIN.UNPIN') : t('INTERNAL_CHAT.PIN.PIN')
|
||||
"
|
||||
@click="handlePin"
|
||||
>
|
||||
<Icon
|
||||
:icon="isPinned ? 'i-lucide-pin-off' : 'i-lucide-pin'"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="flex items-center justify-center rounded p-1 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12"
|
||||
:title="t('INTERNAL_CHAT.MESSAGE.EDIT')"
|
||||
@click="handleEdit"
|
||||
>
|
||||
<Icon icon="i-lucide-pencil" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
class="flex items-center justify-center rounded p-1 text-n-slate-11 hover:bg-n-ruby-3 hover:text-n-ruby-11"
|
||||
:title="t('INTERNAL_CHAT.MESSAGE.DELETE')"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<Icon icon="i-lucide-trash-2" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
ref="deleteDialogRef"
|
||||
type="alert"
|
||||
:title="t('INTERNAL_CHAT.MESSAGE.DELETE')"
|
||||
:description="t('INTERNAL_CHAT.MESSAGE.CONFIRM_DELETE')"
|
||||
:confirm-button-label="t('INTERNAL_CHAT.MESSAGE.DELETE')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,326 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import WootWriter from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
initialContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
editingMessage: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
showPoll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showAlsoSendInChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'send',
|
||||
'typing',
|
||||
'draftUpdate',
|
||||
'create-poll',
|
||||
'cancelEdit',
|
||||
]);
|
||||
|
||||
const alsoSendInChannel = ref(false);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const editorRef = ref(null);
|
||||
const fileInputRef = ref(null);
|
||||
const editorContent = ref(props.initialContent);
|
||||
const attachedFiles = ref([]);
|
||||
const isMentionMenuOpen = ref(false);
|
||||
const isConversationMenuOpen = ref(false);
|
||||
|
||||
let draftTimer = null;
|
||||
|
||||
const canSend = computed(() => {
|
||||
return (
|
||||
(editorContent.value.trim().length > 0 || attachedFiles.value.length > 0) &&
|
||||
!props.disabled
|
||||
);
|
||||
});
|
||||
|
||||
function cancelEdit() {
|
||||
editorContent.value = '';
|
||||
attachedFiles.value = [];
|
||||
emit('cancelEdit');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.editingMessage,
|
||||
msg => {
|
||||
if (msg) {
|
||||
editorContent.value = msg.content || '';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(editorContent, newContent => {
|
||||
if (draftTimer) clearTimeout(draftTimer);
|
||||
draftTimer = setTimeout(() => {
|
||||
emit('draftUpdate', newContent);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
function focusEditor() {
|
||||
editorRef.value?.$el?.querySelector('.ProseMirror')?.focus();
|
||||
}
|
||||
|
||||
function insertMentionTrigger(char) {
|
||||
editorRef.value?.insertMentionTrigger?.(char);
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
if (!canSend.value) return;
|
||||
const content = editorContent.value.trim();
|
||||
const files = [...attachedFiles.value];
|
||||
editorContent.value = '';
|
||||
attachedFiles.value = [];
|
||||
if (draftTimer) {
|
||||
clearTimeout(draftTimer);
|
||||
draftTimer = null;
|
||||
}
|
||||
emit('draftUpdate', '');
|
||||
emit('send', content, {
|
||||
alsoSendInChannel: alsoSendInChannel.value,
|
||||
files,
|
||||
});
|
||||
setTimeout(() => focusEditor(), 200);
|
||||
}
|
||||
|
||||
function handleToggleUserMention(isOpen) {
|
||||
isMentionMenuOpen.value = isOpen;
|
||||
}
|
||||
|
||||
function handleToggleConversationMention(isOpen) {
|
||||
isConversationMenuOpen.value = isOpen;
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
if (isMentionMenuOpen.value || isConversationMenuOpen.value) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleSend();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && props.editingMessage) {
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTypingOn() {
|
||||
emit('typing');
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
function handleFileChange(event) {
|
||||
const files = Array.from(event.target.files || []);
|
||||
attachedFiles.value = [...attachedFiles.value, ...files];
|
||||
if (fileInputRef.value) fileInputRef.value.value = '';
|
||||
}
|
||||
|
||||
function removeFile(index) {
|
||||
attachedFiles.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function filePreviewUrl(file) {
|
||||
return URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
function formatFileSize(file) {
|
||||
const bytes = file.size || 0;
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Math.round(bytes / k ** i)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
focusEditor();
|
||||
}
|
||||
|
||||
function setContent(content) {
|
||||
editorContent.value = content;
|
||||
}
|
||||
|
||||
function getContent() {
|
||||
return editorContent.value;
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (draftTimer) {
|
||||
clearTimeout(draftTimer);
|
||||
draftTimer = null;
|
||||
// Flush pending draft before unmounting
|
||||
emit('draftUpdate', editorContent.value);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ focus, setContent, getContent });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-t border-n-slate-5 bg-n-solid-2 px-4 py-3">
|
||||
<div
|
||||
v-if="editingMessage"
|
||||
class="flex items-center justify-between border-b border-n-slate-5 px-3 py-1.5 text-xs text-n-brand"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon icon="i-lucide-pencil" class="size-3" />
|
||||
{{ t('INTERNAL_CHAT.MESSAGE.EDITING') }}
|
||||
</span>
|
||||
<button class="text-n-slate-11 hover:text-n-slate-12" @click="cancelEdit">
|
||||
<Icon icon="i-lucide-x" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<label
|
||||
v-if="showAlsoSendInChannel"
|
||||
class="flex cursor-pointer items-center gap-1.5 px-1 pb-1 text-xs text-n-slate-10"
|
||||
>
|
||||
<input
|
||||
v-model="alsoSendInChannel"
|
||||
type="checkbox"
|
||||
class="rounded border-n-slate-6"
|
||||
/>
|
||||
{{ t('INTERNAL_CHAT.THREAD.ALSO_SEND_IN_CHANNEL') }}
|
||||
</label>
|
||||
<!-- Attached files preview -->
|
||||
<div v-if="attachedFiles.length" class="mb-1 flex flex-col gap-1 px-1">
|
||||
<div
|
||||
v-for="(file, index) in attachedFiles"
|
||||
:key="index"
|
||||
class="flex w-60 items-center gap-1.5 rounded-md bg-n-slate-3 p-1.5"
|
||||
>
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
v-if="file.type?.startsWith('image/')"
|
||||
:src="filePreviewUrl(file)"
|
||||
class="size-8 rounded object-cover"
|
||||
/>
|
||||
<span v-else class="flex size-8 items-center justify-center text-lg">
|
||||
📄
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs font-medium text-n-slate-12">
|
||||
{{ file.name }}
|
||||
</div>
|
||||
<div class="text-[10px] text-n-slate-10">
|
||||
{{ formatFileSize(file) }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 rounded p-1 text-n-slate-10 hover:bg-n-alpha-2 hover:text-n-ruby-11"
|
||||
@click="removeFile(index)"
|
||||
>
|
||||
<Icon icon="i-lucide-x" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-end gap-2 rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-2"
|
||||
@keydown.capture="handleKeyDown"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<WootWriter
|
||||
ref="editorRef"
|
||||
v-model:model-value="editorContent"
|
||||
channel-type="Context::Default"
|
||||
:placeholder="placeholder || t('INTERNAL_CHAT.MESSAGE.PLACEHOLDER')"
|
||||
enable-suggestions
|
||||
enable-mention-dropdown
|
||||
enable-conversation-mention
|
||||
:enable-variables="false"
|
||||
:enable-canned-responses="false"
|
||||
:enable-captain-tools="false"
|
||||
:enable-copilot="false"
|
||||
:allow-signature="false"
|
||||
focus-on-mount
|
||||
@typing-on="handleTypingOn"
|
||||
@toggle-user-mention="handleToggleUserMention"
|
||||
@toggle-conversation-mention="handleToggleConversationMention"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 flex items-center justify-center rounded-lg p-1.5 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12 transition-colors"
|
||||
:title="t('INTERNAL_CHAT.MESSAGE.UPLOAD_FILE')"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<Icon icon="i-lucide-paperclip" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 flex items-center justify-center rounded-lg p-1.5 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12 transition-colors"
|
||||
:title="t('INTERNAL_CHAT.MESSAGE.MENTION_USER')"
|
||||
@click="insertMentionTrigger('@')"
|
||||
>
|
||||
<Icon icon="i-lucide-at-sign" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 flex items-center justify-center rounded-lg p-1.5 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12 transition-colors"
|
||||
:title="t('INTERNAL_CHAT.MESSAGE.MENTION_CONVERSATION')"
|
||||
@click="insertMentionTrigger('#')"
|
||||
>
|
||||
<Icon icon="i-lucide-hash" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="showPoll"
|
||||
type="button"
|
||||
class="flex-shrink-0 flex items-center justify-center rounded-lg p-1.5 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12 transition-colors"
|
||||
:title="t('INTERNAL_CHAT.POLL.CREATE')"
|
||||
@click="emit('create-poll')"
|
||||
>
|
||||
<Icon icon="i-lucide-bar-chart-2" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 flex items-center justify-center rounded-lg p-1.5 transition-colors"
|
||||
:class="
|
||||
canSend
|
||||
? 'bg-n-brand text-white hover:opacity-90'
|
||||
: 'text-n-slate-9 cursor-not-allowed'
|
||||
"
|
||||
:disabled="!canSend"
|
||||
:title="t('INTERNAL_CHAT.MESSAGE.SEND')"
|
||||
@click="handleSend"
|
||||
>
|
||||
<Icon icon="i-lucide-send-horizontal" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,445 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import MessageSkeleton from './MessageSkeleton.vue';
|
||||
import MessageBubble from './MessageBubble.vue';
|
||||
|
||||
const props = defineProps({
|
||||
channelId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoadingMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isViewingHistory: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
firstUnreadMessageId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
threadDraftParentIds: {
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'edit',
|
||||
'delete',
|
||||
'reply',
|
||||
'openThread',
|
||||
'addReaction',
|
||||
'removeReaction',
|
||||
'pin',
|
||||
'unpin',
|
||||
'vote',
|
||||
'unvote',
|
||||
'loadMore',
|
||||
'loadNewer',
|
||||
'jumpToLatest',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const listRef = ref(null);
|
||||
const showScrollToBottom = ref(false);
|
||||
const hasReachedOldest = ref(false);
|
||||
const isLoadingNewer = ref(false);
|
||||
const lastMessageCount = ref(0);
|
||||
|
||||
// Track edge message IDs to detect prepend (older loaded) vs append (newer loaded)
|
||||
// vs real-time new message. Set when loadMore/loadNewer triggers.
|
||||
let previousFirstMessageId = null;
|
||||
let previousLastMessageId = null;
|
||||
|
||||
// Suppresses the scroll handler briefly after scroll restoration,
|
||||
// preventing the restored position from immediately re-triggering loadMore.
|
||||
let suppressScrollUntil = 0;
|
||||
|
||||
// When set, the messages.length watcher scrolls to this message instead
|
||||
// of auto-scrolling to bottom. Used by linked message navigation.
|
||||
let pendingScrollTarget = null;
|
||||
|
||||
function scrollToMessageOnLoad(messageId) {
|
||||
pendingScrollTarget = messageId;
|
||||
}
|
||||
|
||||
function getMessageTimestamp(message) {
|
||||
const createdAt = message.created_at;
|
||||
if (typeof createdAt === 'number') return createdAt;
|
||||
return Math.floor(new Date(createdAt).getTime() / 1000);
|
||||
}
|
||||
|
||||
function shouldGroup(a, b) {
|
||||
if (!a || !b) return false;
|
||||
const sameSender = a.sender?.id && a.sender.id === b.sender?.id;
|
||||
if (!sameSender) return false;
|
||||
if ((a.parent_id || null) !== (b.parent_id || null)) return false;
|
||||
return (
|
||||
Math.floor(getMessageTimestamp(a) / 60) ===
|
||||
Math.floor(getMessageTimestamp(b) / 60)
|
||||
);
|
||||
}
|
||||
|
||||
const dateSeparatedMessages = computed(() => {
|
||||
const groups = [];
|
||||
let currentDate = null;
|
||||
let unreadInserted = false;
|
||||
|
||||
props.messages.forEach(message => {
|
||||
// Insert unread separator before the first unread message
|
||||
if (
|
||||
!unreadInserted &&
|
||||
props.firstUnreadMessageId &&
|
||||
message.id === props.firstUnreadMessageId
|
||||
) {
|
||||
groups.push({ type: 'unread', key: 'unread-separator' });
|
||||
unreadInserted = true;
|
||||
}
|
||||
|
||||
const createdAt = message.created_at;
|
||||
const msgDate =
|
||||
typeof createdAt === 'number'
|
||||
? new Date(createdAt * 1000)
|
||||
: new Date(createdAt);
|
||||
const dateKey = msgDate.toDateString();
|
||||
|
||||
if (dateKey !== currentDate) {
|
||||
currentDate = dateKey;
|
||||
groups.push({ type: 'date', date: msgDate, key: `date-${dateKey}` });
|
||||
}
|
||||
groups.push({ type: 'message', data: message, key: `msg-${message.id}` });
|
||||
});
|
||||
|
||||
// Compute grouping flags: consecutive messages from same sender within same minute
|
||||
const messageItems = groups.filter(g => g.type === 'message');
|
||||
for (let i = 0; i < messageItems.length; i += 1) {
|
||||
const prev = i > 0 ? messageItems[i - 1].data : null;
|
||||
const curr = messageItems[i].data;
|
||||
const next = i < messageItems.length - 1 ? messageItems[i + 1].data : null;
|
||||
messageItems[i].groupWithPrevious = shouldGroup(prev, curr);
|
||||
messageItems[i].groupWithNext = shouldGroup(curr, next);
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
function formatDateSeparator(date) {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return t('INTERNAL_CHAT.DATE_SEPARATOR.TODAY');
|
||||
}
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return t('INTERNAL_CHAT.DATE_SEPARATOR.YESTERDAY');
|
||||
}
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== today.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// With flex-col-reverse, scrollTop = 0 is the bottom (newest messages).
|
||||
function scrollToBottom() {
|
||||
if (!listRef.value) return;
|
||||
listRef.value.scrollTop = 0;
|
||||
showScrollToBottom.value = false;
|
||||
}
|
||||
|
||||
const HIGHLIGHT_CLASSES = [
|
||||
'bg-n-amber-3',
|
||||
'ring-1',
|
||||
'ring-n-amber-7',
|
||||
'rounded-lg',
|
||||
];
|
||||
|
||||
function scrollToMessage(messageId) {
|
||||
const el = listRef.value?.querySelector(`[data-message-id="${messageId}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add(...HIGHLIGHT_CLASSES);
|
||||
setTimeout(() => el.classList.remove(...HIGHLIGHT_CLASSES), 3000);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!listRef.value || Date.now() < suppressScrollUntil) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.value;
|
||||
|
||||
// flex-col-reverse: scrollTop is 0 at bottom (newest), negative when scrolled up
|
||||
const distanceFromBottom = Math.abs(scrollTop);
|
||||
const maxScroll = scrollHeight - clientHeight;
|
||||
const distanceFromTop = maxScroll - distanceFromBottom;
|
||||
|
||||
showScrollToBottom.value = distanceFromBottom > 100;
|
||||
|
||||
// Load older messages when near the top (oldest messages)
|
||||
if (
|
||||
distanceFromTop < 100 &&
|
||||
maxScroll > 0 &&
|
||||
props.messages.length > 0 &&
|
||||
!props.isLoading &&
|
||||
!props.isLoadingMore &&
|
||||
!hasReachedOldest.value
|
||||
) {
|
||||
lastMessageCount.value = props.messages.length;
|
||||
previousFirstMessageId = props.messages[0]?.id;
|
||||
emit('loadMore');
|
||||
}
|
||||
|
||||
// Load newer messages when near bottom while viewing history
|
||||
if (
|
||||
props.isViewingHistory &&
|
||||
distanceFromBottom < 100 &&
|
||||
props.messages.length > 0 &&
|
||||
!props.isLoading &&
|
||||
!props.isLoadingMore &&
|
||||
!isLoadingNewer.value
|
||||
) {
|
||||
isLoadingNewer.value = true;
|
||||
previousLastMessageId = props.messages[props.messages.length - 1]?.id;
|
||||
emit('loadNewer');
|
||||
}
|
||||
}
|
||||
|
||||
// Detect when loadMore completes with no new messages (reached oldest)
|
||||
watch(
|
||||
() => props.isLoadingMore,
|
||||
(loading, wasLoading) => {
|
||||
if (wasLoading && !loading) {
|
||||
if (props.messages.length === lastMessageCount.value) {
|
||||
hasReachedOldest.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Reset isLoadingNewer when isViewingHistory changes (load completed)
|
||||
watch(
|
||||
() => props.isViewingHistory,
|
||||
() => {
|
||||
isLoadingNewer.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
// Scroll to the unread separator when it appears (after channel load)
|
||||
watch(
|
||||
() => props.firstUnreadMessageId,
|
||||
async id => {
|
||||
if (!id || !listRef.value) return;
|
||||
await nextTick();
|
||||
const el = listRef.value.querySelector('[data-unread-separator]');
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
suppressScrollUntil = Date.now() + 200;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Reset state on channel switch
|
||||
watch(
|
||||
() => props.channelId,
|
||||
async () => {
|
||||
hasReachedOldest.value = false;
|
||||
isLoadingNewer.value = false;
|
||||
showScrollToBottom.value = false;
|
||||
lastMessageCount.value = 0;
|
||||
previousFirstMessageId = null;
|
||||
previousLastMessageId = null;
|
||||
// Ensure scroll resets to bottom (scrollTop = 0) for the new channel
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
}
|
||||
);
|
||||
|
||||
// Handle new messages arriving
|
||||
watch(
|
||||
() => props.messages.length,
|
||||
async (newLen, oldLen) => {
|
||||
if (newLen === 0) {
|
||||
hasReachedOldest.value = false;
|
||||
isLoadingNewer.value = false;
|
||||
previousFirstMessageId = null;
|
||||
previousLastMessageId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingNewer.value = false;
|
||||
|
||||
// Linked message navigation: scroll to target instead of bottom
|
||||
if (pendingScrollTarget) {
|
||||
const targetId = pendingScrollTarget;
|
||||
pendingScrollTarget = null;
|
||||
await nextTick();
|
||||
scrollToMessage(targetId);
|
||||
suppressScrollUntil = Date.now() + 200;
|
||||
return;
|
||||
}
|
||||
|
||||
if (newLen > oldLen && oldLen > 0) {
|
||||
const firstMessageId = props.messages[0]?.id;
|
||||
const lastMessageId = props.messages[props.messages.length - 1]?.id;
|
||||
|
||||
const wasPrepend =
|
||||
previousFirstMessageId && firstMessageId !== previousFirstMessageId;
|
||||
|
||||
const wasAppendFromHistory =
|
||||
previousLastMessageId && lastMessageId !== previousLastMessageId;
|
||||
|
||||
if (wasPrepend && listRef.value) {
|
||||
// Older messages loaded: anchor on the previously-first message
|
||||
const anchorId = previousFirstMessageId;
|
||||
await nextTick();
|
||||
const anchorEl = listRef.value.querySelector(
|
||||
`[data-message-id="${anchorId}"]`
|
||||
);
|
||||
if (anchorEl) {
|
||||
anchorEl.scrollIntoView({ block: 'start' });
|
||||
}
|
||||
previousFirstMessageId = firstMessageId;
|
||||
suppressScrollUntil = Date.now() + 200;
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasAppendFromHistory && listRef.value) {
|
||||
// Newer messages loaded (history navigation): anchor on the
|
||||
// previously-last message so the view doesn't jump to bottom
|
||||
const anchorId = previousLastMessageId;
|
||||
previousLastMessageId = null;
|
||||
await nextTick();
|
||||
const anchorEl = listRef.value.querySelector(
|
||||
`[data-message-id="${anchorId}"]`
|
||||
);
|
||||
if (anchorEl) {
|
||||
anchorEl.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
suppressScrollUntil = Date.now() + 200;
|
||||
return;
|
||||
}
|
||||
|
||||
// Real-time new message: auto-scroll if user was near bottom or sent it
|
||||
const lastMsg = props.messages[props.messages.length - 1];
|
||||
const isOwnMessage = lastMsg?.sender?.id === props.currentUserId;
|
||||
|
||||
if (isOwnMessage || !showScrollToBottom.value) {
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
defineExpose({ scrollToMessage, scrollToMessageOnLoad });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<div
|
||||
ref="listRef"
|
||||
class="flex h-full flex-col-reverse overflow-y-auto"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div>
|
||||
<div v-if="isLoading && messages.length === 0">
|
||||
<MessageSkeleton />
|
||||
</div>
|
||||
<div v-if="isLoadingMore">
|
||||
<MessageSkeleton />
|
||||
</div>
|
||||
<div
|
||||
v-if="messages.length === 0 && !isLoading"
|
||||
class="flex h-full flex-col items-center justify-center gap-2 py-16"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-message-square-plus"
|
||||
class="size-10 text-n-slate-8"
|
||||
/>
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.NO_MESSAGES') }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.CHANNEL.NO_MESSAGES_SUBTITLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-for="item in dateSeparatedMessages" :key="item.key">
|
||||
<div
|
||||
v-if="item.type === 'unread'"
|
||||
data-unread-separator
|
||||
class="flex items-center gap-3 px-4 py-2"
|
||||
>
|
||||
<div class="flex-1 border-t border-n-ruby-7" />
|
||||
<span class="text-xs font-medium text-n-ruby-11">
|
||||
{{ t('INTERNAL_CHAT.NEW_MESSAGES') }}
|
||||
</span>
|
||||
<div class="flex-1 border-t border-n-ruby-7" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type === 'date'"
|
||||
class="flex items-center gap-3 px-4 py-2"
|
||||
>
|
||||
<div class="flex-1 border-t border-n-slate-5" />
|
||||
<span class="text-xs font-medium text-n-slate-10">
|
||||
{{ formatDateSeparator(item.date) }}
|
||||
</span>
|
||||
<div class="flex-1 border-t border-n-slate-5" />
|
||||
</div>
|
||||
<div v-else :data-message-id="item.data.id">
|
||||
<MessageBubble
|
||||
:message="item.data"
|
||||
:current-user-id="currentUserId"
|
||||
:is-admin="isAdmin"
|
||||
:group-with-previous="item.groupWithPrevious"
|
||||
:group-with-next="item.groupWithNext"
|
||||
:has-thread-draft="threadDraftParentIds.has(item.data.id)"
|
||||
@edit="emit('edit', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
@reply="emit('reply', $event)"
|
||||
@open-thread="emit('openThread', $event)"
|
||||
@add-reaction="emit('addReaction', $event)"
|
||||
@remove-reaction="emit('removeReaction', $event)"
|
||||
@pin="emit('pin', $event)"
|
||||
@unpin="emit('unpin', $event)"
|
||||
@vote="emit('vote', $event)"
|
||||
@unvote="emit('unvote', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="showScrollToBottom || isViewingHistory"
|
||||
class="absolute bottom-4 right-4 z-20 flex items-center justify-center rounded-full bg-n-solid-3 p-2 shadow-md border border-n-slate-6 text-n-slate-11 hover:bg-n-solid-4 hover:text-n-slate-12 transition-colors"
|
||||
:title="t('INTERNAL_CHAT.SCROLL_TO_BOTTOM')"
|
||||
@click="isViewingHistory ? emit('jumpToLatest') : scrollToBottom()"
|
||||
>
|
||||
<Icon icon="i-lucide-arrow-down" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
// No props needed - simple presentational component
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 px-4 py-3">
|
||||
<div v-for="i in 2" :key="i" class="flex items-start gap-3 animate-pulse">
|
||||
<div class="size-8 flex-shrink-0 rounded-full bg-n-slate-4" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-24 rounded bg-n-slate-4" />
|
||||
<div class="h-2.5 w-12 rounded bg-n-slate-3" />
|
||||
</div>
|
||||
<div
|
||||
class="h-3 rounded bg-n-slate-4"
|
||||
:class="i % 2 === 0 ? 'w-3/4' : 'w-full'"
|
||||
/>
|
||||
<div v-if="i % 3 === 0" class="h-3 w-1/2 rounded bg-n-slate-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,215 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import NextSelect from 'dashboard/components-next/select/Select.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const confirmDiscardRef = ref(null);
|
||||
|
||||
const question = ref('');
|
||||
const options = ref([{ text: '' }, { text: '' }]);
|
||||
const multipleChoice = ref(false);
|
||||
const duration = ref('24h');
|
||||
const publicResults = ref(true);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const durationOptions = computed(() => [
|
||||
{ value: '24h', label: t('INTERNAL_CHAT.POLL.DURATION_24H') },
|
||||
{ value: '7d', label: t('INTERNAL_CHAT.POLL.DURATION_7D') },
|
||||
{ value: '14d', label: t('INTERNAL_CHAT.POLL.DURATION_14D') },
|
||||
{ value: '30d', label: t('INTERNAL_CHAT.POLL.DURATION_30D') },
|
||||
]);
|
||||
|
||||
const MAX_OPTIONS = 10;
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const filledOptions = options.value.filter(o => o.text.trim().length > 0);
|
||||
return (
|
||||
question.value.trim().length > 0 &&
|
||||
filledOptions.length >= 2 &&
|
||||
!isSubmitting.value
|
||||
);
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = computed(() => {
|
||||
const hasQuestion = question.value.trim().length > 0;
|
||||
const hasOptionText = options.value.some(o => o.text.trim().length > 0);
|
||||
const settingsChanged =
|
||||
multipleChoice.value !== false ||
|
||||
publicResults.value !== true ||
|
||||
duration.value !== '24h';
|
||||
return hasQuestion || hasOptionText || settingsChanged;
|
||||
});
|
||||
|
||||
function addOption() {
|
||||
if (options.value.length < MAX_OPTIONS) {
|
||||
options.value.push({ text: '' });
|
||||
}
|
||||
}
|
||||
|
||||
function removeOption(index) {
|
||||
if (options.value.length > 2) {
|
||||
options.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function computeExpiresAt(durationValue) {
|
||||
const now = new Date();
|
||||
const match = durationValue.match(/^(\d+)(h|d)$/);
|
||||
if (!match) return null;
|
||||
const [, amount, unit] = match;
|
||||
if (unit === 'h') now.setHours(now.getHours() + parseInt(amount, 10));
|
||||
else now.setDate(now.getDate() + parseInt(amount, 10));
|
||||
return now.toISOString();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
question.value = '';
|
||||
options.value = [{ text: '' }, { text: '' }];
|
||||
multipleChoice.value = false;
|
||||
duration.value = '24h';
|
||||
publicResults.value = true;
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
|
||||
function open() {
|
||||
resetForm();
|
||||
dialogRef.value?.open();
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (hasUnsavedChanges.value) {
|
||||
confirmDiscardRef.value?.open();
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function confirmDiscard() {
|
||||
confirmDiscardRef.value?.close();
|
||||
resetForm();
|
||||
dialogRef.value?.close();
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!canSubmit.value) return;
|
||||
isSubmitting.value = true;
|
||||
|
||||
const pollData = {
|
||||
question: question.value.trim(),
|
||||
options: options.value
|
||||
.filter(o => o.text.trim().length > 0)
|
||||
.map(o => ({ text: o.text.trim() })),
|
||||
multiple_choice: multipleChoice.value,
|
||||
public_results: publicResults.value,
|
||||
expires_at: computeExpiresAt(duration.value),
|
||||
};
|
||||
|
||||
emit('submit', pollData);
|
||||
resetForm();
|
||||
isSubmitting.value = false;
|
||||
dialogRef.value?.close();
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('INTERNAL_CHAT.POLL.CREATE')"
|
||||
:confirm-button-label="t('INTERNAL_CHAT.POLL.CREATE')"
|
||||
:disable-confirm-button="!canSubmit"
|
||||
:is-loading="isSubmitting"
|
||||
@confirm="handleSubmit"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.POLL.QUESTION') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="question"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-2 text-sm text-n-slate-12 placeholder-n-slate-10 outline-none focus:border-n-brand"
|
||||
:placeholder="t('INTERNAL_CHAT.POLL.QUESTION')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.POLL.OPTIONS') }}
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
class="flex gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="option.text"
|
||||
type="text"
|
||||
class="flex-1 rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-2 text-sm text-n-slate-12 placeholder-n-slate-10 outline-none focus:border-n-brand"
|
||||
:placeholder="`Option ${index + 1}`"
|
||||
/>
|
||||
<button
|
||||
v-if="options.length > 2"
|
||||
type="button"
|
||||
class="flex-shrink-0 flex h-[34px] w-[34px] items-center justify-center rounded-lg border border-transparent text-n-slate-11 hover:border-n-ruby-6 hover:bg-n-ruby-3 hover:text-n-ruby-11"
|
||||
@click="removeOption(index)"
|
||||
>
|
||||
<Icon icon="i-lucide-x" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="options.length < MAX_OPTIONS"
|
||||
type="button"
|
||||
class="mt-2 flex items-center gap-1 text-sm text-n-brand hover:opacity-80"
|
||||
@click="addOption"
|
||||
>
|
||||
<Icon icon="i-lucide-plus" class="size-3.5" />
|
||||
{{ t('INTERNAL_CHAT.POLL.ADD_OPTION') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="flex cursor-pointer items-center justify-between">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.POLL.MULTIPLE_CHOICE') }}
|
||||
</span>
|
||||
<Switch v-model="multipleChoice" />
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.POLL.DURATION') }}
|
||||
</label>
|
||||
<NextSelect v-model="duration" :options="durationOptions" />
|
||||
</div>
|
||||
|
||||
<label class="flex cursor-pointer items-center justify-between">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.POLL.PUBLIC_RESULTS') }}
|
||||
</span>
|
||||
<Switch v-model="publicResults" />
|
||||
</label>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
ref="confirmDiscardRef"
|
||||
type="alert"
|
||||
:title="t('INTERNAL_CHAT.POLL.DISCARD_TITLE')"
|
||||
:description="t('INTERNAL_CHAT.POLL.DISCARD_DESCRIPTION')"
|
||||
:confirm-button-label="t('INTERNAL_CHAT.POLL.DISCARD')"
|
||||
@confirm="confirmDiscard"
|
||||
/>
|
||||
</template>
|
||||
@ -0,0 +1,207 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['vote', 'unvote']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const now = ref(new Date());
|
||||
let timerInterval = null;
|
||||
|
||||
onMounted(() => {
|
||||
timerInterval = setInterval(() => {
|
||||
now.value = new Date();
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
});
|
||||
|
||||
const pollData = computed(() => {
|
||||
return props.message.content_attributes?.poll || props.message.poll || {};
|
||||
});
|
||||
|
||||
const pollItems = computed(() => {
|
||||
return pollData.value.options || [];
|
||||
});
|
||||
|
||||
const isMultipleChoice = computed(() => {
|
||||
return !!pollData.value.multiple_choice;
|
||||
});
|
||||
|
||||
const isPublicResults = computed(() => {
|
||||
return pollData.value.public_results !== false;
|
||||
});
|
||||
|
||||
const isExpired = computed(() => {
|
||||
const expiresAt = pollData.value.expires_at;
|
||||
if (!expiresAt) return false;
|
||||
return new Date(expiresAt) < now.value;
|
||||
});
|
||||
|
||||
const timeRemaining = computed(() => {
|
||||
const expiresAt = pollData.value.expires_at;
|
||||
if (!expiresAt) return '';
|
||||
const diff = new Date(expiresAt) - now.value;
|
||||
if (diff <= 0) return '';
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const minutes = Math.floor((diff % 3600000) / 60000);
|
||||
if (hours >= 24) {
|
||||
return t('INTERNAL_CHAT.POLL.TIME_LEFT.DAYS', {
|
||||
count: Math.floor(hours / 24),
|
||||
});
|
||||
}
|
||||
if (hours > 0) {
|
||||
return t('INTERNAL_CHAT.POLL.TIME_LEFT.HOURS_MINUTES', {
|
||||
hours,
|
||||
minutes,
|
||||
});
|
||||
}
|
||||
return t('INTERNAL_CHAT.POLL.TIME_LEFT.MINUTES', { count: minutes });
|
||||
});
|
||||
|
||||
const totalVotes = computed(() => {
|
||||
return pollItems.value.reduce(
|
||||
(sum, item) => sum + (item.votes_count || 0),
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
const canSeeResults = computed(() => {
|
||||
return isPublicResults.value || props.isAdmin;
|
||||
});
|
||||
|
||||
const canSeeVoters = computed(() => {
|
||||
return isPublicResults.value || props.isAdmin;
|
||||
});
|
||||
|
||||
function hasUserVoted(item) {
|
||||
return !!item.voted;
|
||||
}
|
||||
|
||||
function hasAnyVote() {
|
||||
return pollItems.value.some(item => item.voted);
|
||||
}
|
||||
|
||||
const shouldShowResults = computed(() => {
|
||||
return hasAnyVote() || isExpired.value;
|
||||
});
|
||||
|
||||
function votePercentage(item) {
|
||||
if (totalVotes.value === 0) return 0;
|
||||
return Math.round(((item.votes_count || 0) / totalVotes.value) * 100);
|
||||
}
|
||||
|
||||
function voterNames(item) {
|
||||
if (!item.voters || !item.voters.length) return '';
|
||||
return item.voters.map(v => v.name).join(', ');
|
||||
}
|
||||
|
||||
function handleVote(item) {
|
||||
if (isExpired.value) return;
|
||||
|
||||
if (hasUserVoted(item)) {
|
||||
emit('unvote', { messageId: props.message.id, optionId: item.id });
|
||||
} else {
|
||||
emit('vote', { messageId: props.message.id, optionId: item.id });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-n-slate-5 bg-n-solid-2 p-3">
|
||||
<div class="mb-3 flex items-start justify-between">
|
||||
<h4 class="text-sm font-semibold text-n-slate-12">
|
||||
{{ message.content }}
|
||||
</h4>
|
||||
<span
|
||||
v-if="isExpired"
|
||||
class="ml-2 flex-shrink-0 rounded bg-n-ruby-3 px-1.5 py-0.5 text-xs font-medium text-n-ruby-11"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.POLL.EXPIRED') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="timeRemaining"
|
||||
class="ml-2 flex-shrink-0 flex items-center gap-1 text-xs text-n-slate-10"
|
||||
>
|
||||
<Icon icon="i-lucide-clock" class="size-3" />
|
||||
{{ timeRemaining }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<component
|
||||
:is="isExpired ? 'div' : 'button'"
|
||||
v-for="item in pollItems"
|
||||
:key="item.id"
|
||||
class="group relative w-full overflow-hidden rounded-lg border p-2.5 text-left text-sm transition-colors"
|
||||
:class="[
|
||||
hasUserVoted(item)
|
||||
? 'border-n-brand bg-n-brand/5'
|
||||
: 'border-n-slate-6',
|
||||
isExpired
|
||||
? 'cursor-default'
|
||||
: 'cursor-pointer hover:border-n-slate-8',
|
||||
]"
|
||||
v-bind="isExpired ? {} : { type: 'button' }"
|
||||
@click="!isExpired && handleVote(item)"
|
||||
>
|
||||
<div
|
||||
v-if="shouldShowResults && canSeeResults"
|
||||
class="absolute inset-0 rounded-lg bg-n-brand/10 transition-all"
|
||||
:style="{ width: `${votePercentage(item)}%` }"
|
||||
/>
|
||||
<div class="relative flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="hasUserVoted(item)"
|
||||
class="flex size-4 items-center justify-center rounded-full bg-n-brand"
|
||||
>
|
||||
<Icon icon="i-lucide-check" class="size-3 text-white" />
|
||||
</span>
|
||||
<span class="text-n-slate-12">{{ item.text }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="shouldShowResults && canSeeResults"
|
||||
class="flex-shrink-0 text-xs text-n-slate-10"
|
||||
>
|
||||
{{
|
||||
t('INTERNAL_CHAT.POLL.PERCENTAGE', {
|
||||
value: votePercentage(item),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canSeeVoters && voterNames(item)"
|
||||
class="relative mt-1 text-xs text-n-slate-9"
|
||||
>
|
||||
{{ voterNames(item) }}
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-n-slate-10">
|
||||
<span>
|
||||
{{ t('INTERNAL_CHAT.POLL.VOTES', { count: totalVotes }) }}
|
||||
</span>
|
||||
<span v-if="isMultipleChoice" class="text-n-slate-9">
|
||||
{{ t('INTERNAL_CHAT.POLL.MULTIPLE_CHOICE') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useInternalChatPro } from 'dashboard/composables/useInternalChatPro';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
feature: {
|
||||
type: String,
|
||||
default: 'polls',
|
||||
validator: v => ['polls', 'private_channels', 'search'].includes(v),
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isSuperAdmin, isAdmin } = useInternalChatPro();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const upgradeUrl = 'https://fazer.ai/kanban';
|
||||
|
||||
const descriptionKey = computed(() => {
|
||||
const map = {
|
||||
polls: 'INTERNAL_CHAT.PRO.POLLS_DESCRIPTION',
|
||||
private_channels: 'INTERNAL_CHAT.PRO.PRIVATE_CHANNELS_DESCRIPTION',
|
||||
search: 'INTERNAL_CHAT.PRO.SEARCH_DESCRIPTION',
|
||||
};
|
||||
return map[props.feature];
|
||||
});
|
||||
|
||||
const descriptionParams = computed(() => {
|
||||
const map = {
|
||||
private_channels: { limit: 2 },
|
||||
search: { days: 90 },
|
||||
};
|
||||
return map[props.feature] || {};
|
||||
});
|
||||
|
||||
function open() {
|
||||
dialogRef.value?.open();
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Inline mode: render card directly -->
|
||||
<div
|
||||
v-if="inline"
|
||||
class="flex flex-col rounded-xl border border-n-weak bg-n-solid-1 px-4 py-4 shadow"
|
||||
>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<span
|
||||
class="flex size-6 items-center justify-center rounded-full bg-n-solid-blue"
|
||||
>
|
||||
<Icon
|
||||
class="flex-shrink-0 text-n-brand size-[14px]"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.PRO.TITLE') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="isSuperAdmin">
|
||||
<p class="mb-3 text-sm text-n-slate-11">
|
||||
{{ t(descriptionKey, descriptionParams) }}
|
||||
</p>
|
||||
<a
|
||||
:href="upgradeUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex w-full items-center justify-center rounded-xl bg-n-brand px-3 py-1.5 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.PRO.UPGRADE_NOW') }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else-if="isAdmin">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('INTERNAL_CHAT.PRO.ADMIN_MESSAGE') }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('INTERNAL_CHAT.PRO.AGENT_MESSAGE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Modal mode: render inside Dialog -->
|
||||
<Dialog
|
||||
v-else
|
||||
ref="dialogRef"
|
||||
:title="t('INTERNAL_CHAT.PRO.TITLE')"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
width="sm"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<span
|
||||
class="flex size-6 items-center justify-center rounded-full bg-n-solid-blue"
|
||||
>
|
||||
<Icon
|
||||
class="flex-shrink-0 text-n-brand size-[14px]"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-base font-medium text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.PRO.TITLE') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="isSuperAdmin">
|
||||
<p class="mb-4 text-sm text-n-slate-11">
|
||||
{{ t(descriptionKey, descriptionParams) }}
|
||||
</p>
|
||||
<a
|
||||
:href="upgradeUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex w-full items-center justify-center rounded-xl bg-n-brand px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
{{ t('INTERNAL_CHAT.PRO.UPGRADE_NOW') }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else-if="isAdmin">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('INTERNAL_CHAT.PRO.ADMIN_MESSAGE') }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('INTERNAL_CHAT.PRO.AGENT_MESSAGE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
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: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showPopover = ref(false);
|
||||
|
||||
const groupedReactions = computed(() => {
|
||||
const groups = {};
|
||||
props.reactions.forEach(reaction => {
|
||||
if (!groups[reaction.emoji]) {
|
||||
groups[reaction.emoji] = {
|
||||
emoji: reaction.emoji,
|
||||
count: 0,
|
||||
userReactionId: null,
|
||||
users: [],
|
||||
};
|
||||
}
|
||||
groups[reaction.emoji].count += 1;
|
||||
groups[reaction.emoji].users.push({
|
||||
name: reaction.user?.name || '',
|
||||
id: reaction.user_id,
|
||||
reactionId: reaction.id,
|
||||
});
|
||||
if (reaction.user_id === props.currentUserId) {
|
||||
groups[reaction.emoji].userReactionId = reaction.id;
|
||||
}
|
||||
});
|
||||
return Object.values(groups);
|
||||
});
|
||||
|
||||
function togglePopover() {
|
||||
showPopover.value = !showPopover.value;
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
showPopover.value = false;
|
||||
}
|
||||
|
||||
function handleRemove(reactionId) {
|
||||
emit('remove', reactionId);
|
||||
if (props.reactions.length <= 1) {
|
||||
showPopover.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="groupedReactions.length"
|
||||
class="relative mt-1 flex flex-wrap items-center gap-1"
|
||||
>
|
||||
<button
|
||||
v-for="group in groupedReactions"
|
||||
:key="group.emoji"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-xs transition-colors"
|
||||
:class="
|
||||
group.userReactionId
|
||||
? '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'
|
||||
"
|
||||
@click="togglePopover"
|
||||
>
|
||||
<span>{{ group.emoji }}</span>
|
||||
<span>{{ group.count }}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showPopover"
|
||||
v-on-click-outside="closePopover"
|
||||
class="absolute bottom-full left-0 z-50 mb-1 min-w-48 rounded-lg border border-n-slate-6 bg-n-solid-2 p-2 shadow-lg"
|
||||
>
|
||||
<div
|
||||
v-for="(group, groupIdx) in groupedReactions"
|
||||
:key="group.emoji"
|
||||
:class="{ 'mt-2 border-t border-n-slate-5 pt-2': groupIdx > 0 }"
|
||||
>
|
||||
<div
|
||||
v-for="user in group.users"
|
||||
:key="user.reactionId"
|
||||
class="flex h-7 items-center gap-2 rounded px-1"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,387 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import MessageBubble from './MessageBubble.vue';
|
||||
import MessageEditor from './MessageEditor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
channelId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
parentMessage: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
highlightMessageId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const isSending = ref(false);
|
||||
const editingMessage = ref(null);
|
||||
const threadEditorRef = ref(null);
|
||||
const scrollContainerRef = ref(null);
|
||||
let activeThreadRequestId = null;
|
||||
|
||||
const HIGHLIGHT_CLASSES = [
|
||||
'bg-n-amber-3',
|
||||
'ring-1',
|
||||
'ring-n-amber-7',
|
||||
'rounded-lg',
|
||||
];
|
||||
|
||||
function scrollToBottom() {
|
||||
const el = scrollContainerRef.value;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function scrollToMessage(messageId) {
|
||||
const container = scrollContainerRef.value;
|
||||
if (!container) return false;
|
||||
const el = container.querySelector(`[data-message-id="${messageId}"]`);
|
||||
if (!el) return false;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add(...HIGHLIGHT_CLASSES);
|
||||
setTimeout(() => el.classList.remove(...HIGHLIGHT_CLASSES), 3000);
|
||||
return true;
|
||||
}
|
||||
|
||||
const threadReplies = computed(() => {
|
||||
return store.getters['internalChat/messages/getThreadReplies'](
|
||||
props.parentMessage.id
|
||||
);
|
||||
});
|
||||
|
||||
const replyCount = computed(() => threadReplies.value.length);
|
||||
|
||||
async function fetchThread() {
|
||||
const requestId = props.parentMessage.id;
|
||||
activeThreadRequestId = requestId;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await store.dispatch('internalChat/messages/fetchThread', {
|
||||
channelId: props.channelId,
|
||||
messageId: props.parentMessage.id,
|
||||
});
|
||||
if (activeThreadRequestId !== requestId) return;
|
||||
isLoading.value = false;
|
||||
await nextTick();
|
||||
if (props.highlightMessageId) {
|
||||
scrollToMessage(props.highlightMessageId);
|
||||
} else {
|
||||
scrollToBottom();
|
||||
}
|
||||
} catch {
|
||||
if (activeThreadRequestId !== requestId) return;
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.FETCH_MESSAGES'));
|
||||
} finally {
|
||||
if (activeThreadRequestId === requestId) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deleteThreadDraft(parentId = props.parentMessage.id) {
|
||||
const draft = store.getters['internalChat/drafts/getThreadDraft'](
|
||||
props.channelId,
|
||||
parentId
|
||||
);
|
||||
if (draft) {
|
||||
store
|
||||
.dispatch('internalChat/drafts/deleteDraft', {
|
||||
channelId: props.channelId,
|
||||
draftId: draft.id,
|
||||
parentId,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendReply(content, options = {}) {
|
||||
isSending.value = true;
|
||||
try {
|
||||
if (editingMessage.value) {
|
||||
await store.dispatch('internalChat/messages/updateMessage', {
|
||||
channelId: props.channelId,
|
||||
messageId: editingMessage.value.id,
|
||||
data: { content },
|
||||
});
|
||||
editingMessage.value = null;
|
||||
} else {
|
||||
await store.dispatch('internalChat/messages/sendThreadReply', {
|
||||
channelId: props.channelId,
|
||||
parentMessageId: props.parentMessage.id,
|
||||
data: { content, also_send_in_channel: !!options.alsoSendInChannel },
|
||||
});
|
||||
deleteThreadDraft();
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
}
|
||||
} catch {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
} finally {
|
||||
isSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditReply(message) {
|
||||
editingMessage.value = message;
|
||||
}
|
||||
|
||||
function handleCancelEdit() {
|
||||
editingMessage.value = null;
|
||||
}
|
||||
|
||||
function handleDeleteReply(message) {
|
||||
store
|
||||
.dispatch('internalChat/messages/deleteMessage', {
|
||||
channelId: props.channelId,
|
||||
messageId: message.id,
|
||||
})
|
||||
.catch(() => {
|
||||
useAlert(t('INTERNAL_CHAT.ERRORS.SEND_MESSAGE'));
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddReaction({ messageId, emoji }) {
|
||||
store
|
||||
.dispatch('internalChat/messages/addReaction', {
|
||||
channelId: props.channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore reaction errors
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemoveReaction({ messageId, reactionId }) {
|
||||
store
|
||||
.dispatch('internalChat/messages/removeReaction', {
|
||||
channelId: props.channelId,
|
||||
messageId,
|
||||
reactionId,
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore reaction errors
|
||||
});
|
||||
}
|
||||
|
||||
function handleVote({ messageId, optionId }) {
|
||||
const msg = store.getters['internalChat/messages/getMessageById'](
|
||||
props.channelId,
|
||||
messageId
|
||||
);
|
||||
const pollId = msg?.poll?.id || msg?.content_attributes?.poll?.id;
|
||||
if (!pollId) return;
|
||||
store
|
||||
.dispatch('internalChat/polls/vote', {
|
||||
pollId,
|
||||
optionId,
|
||||
channelId: props.channelId,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function handleUnvote({ messageId, optionId }) {
|
||||
const msg = store.getters['internalChat/messages/getMessageById'](
|
||||
props.channelId,
|
||||
messageId
|
||||
);
|
||||
const pollId = msg?.poll?.id || msg?.content_attributes?.poll?.id;
|
||||
if (!pollId) return;
|
||||
store
|
||||
.dispatch('internalChat/polls/unvote', {
|
||||
pollId,
|
||||
optionId,
|
||||
channelId: props.channelId,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function loadThreadDraft() {
|
||||
const draft = store.getters['internalChat/drafts/getThreadDraft'](
|
||||
props.channelId,
|
||||
props.parentMessage.id
|
||||
);
|
||||
if (threadEditorRef.value) {
|
||||
threadEditorRef.value.setContent(draft ? draft.content : '');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleThreadDraftUpdate(content) {
|
||||
if (!content || !content.trim()) {
|
||||
deleteThreadDraft();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await store.dispatch('internalChat/drafts/saveDraft', {
|
||||
channelId: props.channelId,
|
||||
content,
|
||||
parentId: props.parentMessage.id,
|
||||
});
|
||||
} catch {
|
||||
// Silently handle
|
||||
}
|
||||
}
|
||||
|
||||
function saveThreadDraftImmediately(parentId = props.parentMessage.id) {
|
||||
const content = threadEditorRef.value?.getContent?.() || '';
|
||||
if (content.trim()) {
|
||||
store
|
||||
.dispatch('internalChat/drafts/saveDraft', {
|
||||
channelId: props.channelId,
|
||||
content,
|
||||
parentId,
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
deleteThreadDraft(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.parentMessage.id,
|
||||
(newId, oldId) => {
|
||||
if (oldId) saveThreadDraftImmediately(oldId);
|
||||
fetchThread();
|
||||
loadThreadDraft();
|
||||
}
|
||||
);
|
||||
|
||||
async function jumpToReply(messageId) {
|
||||
if (!messageId) return;
|
||||
await nextTick();
|
||||
if (!scrollToMessage(messageId)) {
|
||||
await fetchThread();
|
||||
await nextTick();
|
||||
scrollToMessage(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ jumpToReply });
|
||||
|
||||
onMounted(() => {
|
||||
fetchThread();
|
||||
loadThreadDraft();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
saveThreadDraftImmediately();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full w-96 flex-col overflow-x-clip border-l border-n-slate-5 bg-n-solid-1"
|
||||
>
|
||||
<div
|
||||
class="flex h-[53px] items-center justify-between border-b border-n-slate-5 px-4"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-n-slate-12">
|
||||
{{ t('INTERNAL_CHAT.THREAD.TITLE') }}
|
||||
</h3>
|
||||
<button
|
||||
:aria-label="t('INTERNAL_CHAT.THREAD.CLOSE')"
|
||||
class="flex items-center justify-center rounded p-1 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<Icon icon="i-lucide-x" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="scrollContainerRef" class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
class="border-b border-n-slate-5 pb-2"
|
||||
:data-message-id="parentMessage.id"
|
||||
>
|
||||
<MessageBubble
|
||||
:message="parentMessage"
|
||||
:current-user-id="currentUserId"
|
||||
:is-admin="isAdmin"
|
||||
in-thread
|
||||
@edit="handleEditReply"
|
||||
@delete="handleDeleteReply"
|
||||
@add-reaction="handleAddReaction"
|
||||
@remove-reaction="handleRemoveReaction"
|
||||
@vote="handleVote"
|
||||
@unvote="handleUnvote"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-2">
|
||||
<span class="text-xs font-medium text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.THREAD.REPLIES', { count: replyCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-4">
|
||||
<Spinner :size="16" />
|
||||
<span class="ml-2 text-xs text-n-slate-10">
|
||||
{{ t('INTERNAL_CHAT.LOADING_MESSAGES') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="reply in threadReplies"
|
||||
:key="reply.id"
|
||||
:data-message-id="reply.id"
|
||||
>
|
||||
<MessageBubble
|
||||
:message="reply"
|
||||
:current-user-id="currentUserId"
|
||||
:is-admin="isAdmin"
|
||||
in-thread
|
||||
@edit="handleEditReply"
|
||||
@delete="handleDeleteReply"
|
||||
@add-reaction="handleAddReaction"
|
||||
@remove-reaction="handleRemoveReaction"
|
||||
@vote="handleVote"
|
||||
@unvote="handleUnvote"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageEditor
|
||||
ref="threadEditorRef"
|
||||
:disabled="isSending"
|
||||
:editing-message="editingMessage"
|
||||
:placeholder="t('INTERNAL_CHAT.THREAD.REPLY_PLACEHOLDER')"
|
||||
:show-poll="false"
|
||||
show-also-send-in-channel
|
||||
@send="handleSendReply"
|
||||
@cancel-edit="handleCancelEdit"
|
||||
@draft-update="handleThreadDraftUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
typingUsers: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const typingText = computed(() => {
|
||||
if (props.typingUsers.length === 0) return '';
|
||||
if (props.typingUsers.length === 1) {
|
||||
return t('INTERNAL_CHAT.TYPING.SINGLE', {
|
||||
name: props.typingUsers[0].name,
|
||||
});
|
||||
}
|
||||
const names = props.typingUsers.map(u => u.name).join(', ');
|
||||
return t('INTERNAL_CHAT.TYPING.MULTIPLE', { names });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-show="typingUsers.length > 0"
|
||||
class="flex items-center gap-2 px-4 py-1 text-xs text-n-slate-11"
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span
|
||||
class="inline-block size-1.5 rounded-full bg-n-slate-9 animate-bounce [animation-delay:0ms]"
|
||||
/>
|
||||
<span
|
||||
class="inline-block size-1.5 rounded-full bg-n-slate-9 animate-bounce [animation-delay:150ms]"
|
||||
/>
|
||||
<span
|
||||
class="inline-block size-1.5 rounded-full bg-n-slate-9 animate-bounce [animation-delay:300ms]"
|
||||
/>
|
||||
</div>
|
||||
<span>{{ typingText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,64 @@
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
|
||||
const InternalChatLayout = () => import('./InternalChatLayout.vue');
|
||||
|
||||
const INTERNAL_CHAT_PERMISSIONS = ['administrator', 'agent'];
|
||||
|
||||
const EmptyComponent = {
|
||||
template: '<div />',
|
||||
};
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/internal-chat'),
|
||||
name: 'internal_chat',
|
||||
meta: {
|
||||
permissions: INTERNAL_CHAT_PERMISSIONS,
|
||||
},
|
||||
component: InternalChatLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'internal_chat_home',
|
||||
meta: {
|
||||
permissions: INTERNAL_CHAT_PERMISSIONS,
|
||||
},
|
||||
component: EmptyComponent,
|
||||
},
|
||||
{
|
||||
path: 'channels/:channelId',
|
||||
name: 'internal_chat_channel',
|
||||
meta: {
|
||||
permissions: INTERNAL_CHAT_PERMISSIONS,
|
||||
},
|
||||
component: EmptyComponent,
|
||||
},
|
||||
{
|
||||
path: 'channels/:channelId/thread/:messageId',
|
||||
name: 'internal_chat_thread',
|
||||
meta: {
|
||||
permissions: INTERNAL_CHAT_PERMISSIONS,
|
||||
},
|
||||
component: EmptyComponent,
|
||||
},
|
||||
{
|
||||
path: 'dm/:channelId',
|
||||
name: 'internal_chat_dm',
|
||||
meta: {
|
||||
permissions: INTERNAL_CHAT_PERMISSIONS,
|
||||
},
|
||||
component: EmptyComponent,
|
||||
},
|
||||
{
|
||||
path: 'drafts',
|
||||
name: 'internal_chat_drafts',
|
||||
meta: {
|
||||
permissions: INTERNAL_CHAT_PERMISSIONS,
|
||||
},
|
||||
component: () => import('./DraftsList.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import ConversationPreviewCard from '../ConversationPreviewCard.vue';
|
||||
import ConversationAPI from 'dashboard/api/conversations';
|
||||
|
||||
vi.mock('dashboard/api/conversations', () => ({
|
||||
default: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockPush = vi.fn();
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/helper/URLHelper', () => ({
|
||||
frontendURL: path => `/app/${path}`,
|
||||
conversationUrl: ({ accountId, id }) =>
|
||||
`accounts/${accountId}/conversations/${id}`,
|
||||
}));
|
||||
|
||||
vi.mock('date-fns', async () => {
|
||||
const actual = await vi.importActual('date-fns');
|
||||
return {
|
||||
...actual,
|
||||
formatDistanceToNow: date => `${Math.floor(date.getTime() / 1000)} ago`,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useMapGetter: getter => {
|
||||
if (getter === 'inboxes/getInboxes') {
|
||||
return { value: [{ id: 5, name: 'Support Inbox' }] };
|
||||
}
|
||||
return {
|
||||
value: [
|
||||
{ title: 'billing', color: '#ff6b6b' },
|
||||
{ title: 'vip', color: '#ffd43b' },
|
||||
],
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@chatwoot/utils', () => ({
|
||||
getContrastingTextColor: () => '#ffffff',
|
||||
}));
|
||||
|
||||
const CONVERSATION = {
|
||||
id: 42,
|
||||
status: 'open',
|
||||
inbox_id: 5,
|
||||
last_activity_at: 1234567890,
|
||||
priority: 'high',
|
||||
labels: ['billing', 'vip'],
|
||||
meta: {
|
||||
sender: { name: 'Alice Silva', thumbnail: 'alice.jpg' },
|
||||
assignee: { name: 'Agent Bob' },
|
||||
},
|
||||
messages: [{ content: 'Hello, I need help with my order' }],
|
||||
last_non_activity_message: {
|
||||
content: 'Sure, let me check that for you.',
|
||||
},
|
||||
};
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(ConversationPreviewCard, {
|
||||
props: {
|
||||
displayId: '42',
|
||||
accountId: 1,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: { Avatar: true, Icon: true },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('ConversationPreviewCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ConversationAPI.show.mockResolvedValue({ data: CONVERSATION });
|
||||
});
|
||||
|
||||
it('renders nothing before conversation data is fetched', () => {
|
||||
ConversationAPI.show.mockResolvedValue({ data: null });
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find('a').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('fetches and renders conversation data on mount', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(ConversationAPI.show).toHaveBeenCalledWith('42');
|
||||
expect(wrapper.find('a').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('Alice Silva');
|
||||
expect(wrapper.text()).toContain('42');
|
||||
});
|
||||
|
||||
it('shows status badge', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Open');
|
||||
});
|
||||
|
||||
it('shows last message preview', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Sure, let me check that for you.');
|
||||
});
|
||||
|
||||
it('shows assignee name', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Agent Bob');
|
||||
});
|
||||
|
||||
it('shows labels with colors', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const labelEls = wrapper.findAll('[style]').filter(el => {
|
||||
return el.attributes('style')?.includes('background-color');
|
||||
});
|
||||
expect(labelEls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(wrapper.text()).toContain('billing');
|
||||
expect(wrapper.text()).toContain('vip');
|
||||
});
|
||||
|
||||
it('shows priority icon', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const iconStub = wrapper.findAll('icon-stub').find(el => {
|
||||
return el.attributes('icon') === 'i-lucide-arrow-up';
|
||||
});
|
||||
expect(iconStub).toBeTruthy();
|
||||
});
|
||||
|
||||
it('generates correct conversation link', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const link = wrapper.find('a');
|
||||
expect(link.attributes('href')).toBe('/app/accounts/1/conversations/42');
|
||||
});
|
||||
|
||||
it('navigates via router on click', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('a').trigger('click');
|
||||
expect(mockPush).toHaveBeenCalledWith('/app/accounts/1/conversations/42');
|
||||
});
|
||||
});
|
||||
@ -25,6 +25,10 @@ const SUPPORTED_WEBHOOK_EVENTS = [
|
||||
'conversation_typing_on',
|
||||
'conversation_typing_off',
|
||||
'provider_event_received',
|
||||
'internal_chat_message_created',
|
||||
'internal_chat_message_updated',
|
||||
'internal_chat_message_deleted',
|
||||
'internal_chat_channel_updated',
|
||||
];
|
||||
|
||||
const localhostUrl = value => {
|
||||
|
||||
@ -61,6 +61,8 @@ import copilotMessages from './captain/copilotMessages';
|
||||
import captainScenarios from './captain/scenarios';
|
||||
import captainTools from './captain/tools';
|
||||
import captainCustomTools from './captain/customTools';
|
||||
import internalChat from './modules/internalChat';
|
||||
import internalChatTypingStatus from './modules/internalChat/typingStatus';
|
||||
|
||||
const plugins = [];
|
||||
|
||||
@ -127,6 +129,8 @@ export default createStore({
|
||||
captainScenarios,
|
||||
captainTools,
|
||||
captainCustomTools,
|
||||
internalChat,
|
||||
internalChatTypingStatus,
|
||||
},
|
||||
plugins,
|
||||
});
|
||||
|
||||
213
app/javascript/dashboard/store/modules/internalChat/actions.js
Normal file
213
app/javascript/dashboard/store/modules/internalChat/actions.js
Normal file
@ -0,0 +1,213 @@
|
||||
import InternalChatChannelsAPI from '../../../api/internalChatChannels';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export const actions = {
|
||||
get: async ({ commit }) => {
|
||||
commit('SET_UI_FLAG', { isFetching: true });
|
||||
try {
|
||||
const [channelsResponse, categoriesResponse] = await Promise.all([
|
||||
InternalChatChannelsAPI.get(),
|
||||
InternalChatChannelsAPI.getCategories(),
|
||||
]);
|
||||
commit('SET_CHANNELS', channelsResponse.data);
|
||||
commit('SET_CATEGORIES', categoriesResponse.data);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isFetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
show: async ({ commit }, channelId) => {
|
||||
try {
|
||||
const response = await InternalChatChannelsAPI.show(channelId);
|
||||
commit('ADD_CHANNEL', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
fetchArchived: async ({ commit }) => {
|
||||
commit('SET_UI_FLAG', { isFetchingArchived: true });
|
||||
try {
|
||||
const response = await InternalChatChannelsAPI.getWithParams({
|
||||
status: 'archived',
|
||||
});
|
||||
commit('SET_ARCHIVED_CHANNELS', response.data);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isFetchingArchived: false });
|
||||
}
|
||||
},
|
||||
|
||||
create: async ({ commit }, channelData) => {
|
||||
commit('SET_UI_FLAG', { isCreating: true });
|
||||
try {
|
||||
const response = await InternalChatChannelsAPI.create(channelData);
|
||||
commit('ADD_CHANNEL', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error?.response?.status === 402) throw error;
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isCreating: false });
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ commit, getters }, { channelId, ...data }) => {
|
||||
const previous = { ...getters.getChannelById(channelId) };
|
||||
commit('UPDATE_CHANNEL', { id: channelId, ...data.channel });
|
||||
try {
|
||||
const response = await InternalChatChannelsAPI.update(channelId, data);
|
||||
commit('UPDATE_CHANNEL', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit('UPDATE_CHANNEL', previous);
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ commit }, channelId) => {
|
||||
try {
|
||||
await InternalChatChannelsAPI.delete(channelId);
|
||||
commit('DELETE_CHANNEL', channelId);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
archive: async ({ commit }, channelId) => {
|
||||
try {
|
||||
const response = await InternalChatChannelsAPI.archive(channelId);
|
||||
commit('UPDATE_CHANNEL', response.data);
|
||||
commit('ADD_ARCHIVED_CHANNEL', response.data);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
unarchive: async ({ commit }, channelId) => {
|
||||
try {
|
||||
const response = await InternalChatChannelsAPI.unarchive(channelId);
|
||||
commit('ADD_CHANNEL', response.data);
|
||||
commit('REMOVE_ARCHIVED_CHANNEL', channelId);
|
||||
} catch (error) {
|
||||
if (error?.response?.status === 402) throw error;
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
toggleMute: async ({ commit, state, rootGetters }, channelId) => {
|
||||
const channel = state.records[channelId];
|
||||
if (!channel) return;
|
||||
const currentUserId = rootGetters.getCurrentUser?.id;
|
||||
const member = (channel.members || []).find(
|
||||
m => m.user_id === currentUserId
|
||||
);
|
||||
if (!member) return;
|
||||
|
||||
const newMuted = !channel.muted;
|
||||
commit('UPDATE_CHANNEL', { id: channelId, muted: newMuted });
|
||||
try {
|
||||
await InternalChatChannelsAPI.updateMember(channelId, member.id, {
|
||||
muted: newMuted,
|
||||
});
|
||||
} catch (error) {
|
||||
commit('UPDATE_CHANNEL', { id: channelId, muted: !newMuted });
|
||||
throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
toggleFavorite: async ({ commit, state, rootGetters }, channelId) => {
|
||||
const channel = state.records[channelId];
|
||||
if (!channel) return;
|
||||
const currentUserId = rootGetters.getCurrentUser?.id;
|
||||
const member = (channel.members || []).find(
|
||||
m => m.user_id === currentUserId
|
||||
);
|
||||
if (!member) return;
|
||||
|
||||
const newFavorited = !channel.favorited;
|
||||
commit('UPDATE_CHANNEL', { id: channelId, favorited: newFavorited });
|
||||
try {
|
||||
await InternalChatChannelsAPI.updateMember(channelId, member.id, {
|
||||
favorited: newFavorited,
|
||||
});
|
||||
} catch (error) {
|
||||
commit('UPDATE_CHANNEL', { id: channelId, favorited: !newFavorited });
|
||||
throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
markRead: async ({ commit }, channelId) => {
|
||||
try {
|
||||
await InternalChatChannelsAPI.markRead(channelId);
|
||||
commit('UPDATE_CHANNEL', {
|
||||
id: channelId,
|
||||
unread_count: 0,
|
||||
has_unread_mention: false,
|
||||
});
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
markUnread: async ({ commit }, { channelId, messageId }) => {
|
||||
try {
|
||||
await InternalChatChannelsAPI.markUnread(channelId, messageId);
|
||||
commit('UPDATE_CHANNEL', { id: channelId, unread_count: 1 });
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
createCategory: async ({ commit }, categoryData) => {
|
||||
try {
|
||||
const response =
|
||||
await InternalChatChannelsAPI.createCategory(categoryData);
|
||||
commit('ADD_CATEGORY', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteCategory: async ({ commit, state }, categoryId) => {
|
||||
try {
|
||||
await InternalChatChannelsAPI.deleteCategory(categoryId);
|
||||
commit('REMOVE_CATEGORY', categoryId);
|
||||
// Move channels from deleted category to uncategorized
|
||||
Object.values(state.records).forEach(channel => {
|
||||
if (channel.category_id === categoryId) {
|
||||
commit('UPDATE_CHANNEL', { id: channel.id, category_id: null });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setActiveChannel: ({ commit }, channelId) => {
|
||||
commit('SET_ACTIVE_CHANNEL', channelId);
|
||||
},
|
||||
|
||||
addChannel: ({ commit }, channel) => {
|
||||
commit('ADD_CHANNEL', channel);
|
||||
},
|
||||
|
||||
updateChannel: ({ commit }, channel) => {
|
||||
commit('UPDATE_CHANNEL', channel);
|
||||
},
|
||||
};
|
||||
|
||||
export default actions;
|
||||
129
app/javascript/dashboard/store/modules/internalChat/drafts.js
Normal file
129
app/javascript/dashboard/store/modules/internalChat/drafts.js
Normal file
@ -0,0 +1,129 @@
|
||||
import InternalChatDraftsAPI from '../../../api/internalChatDrafts';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
hasFetched: false,
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
},
|
||||
};
|
||||
|
||||
const getters = {
|
||||
getDrafts: _state => {
|
||||
return Object.values(_state.records);
|
||||
},
|
||||
|
||||
getDraftByChannelId: _state => channelId => {
|
||||
return (
|
||||
Object.values(_state.records).find(
|
||||
draft =>
|
||||
draft.internal_chat_channel_id === channelId && !draft.parent_id
|
||||
) || null
|
||||
);
|
||||
},
|
||||
|
||||
getThreadDraft: _state => (channelId, parentId) => {
|
||||
return (
|
||||
Object.values(_state.records).find(
|
||||
draft =>
|
||||
draft.internal_chat_channel_id === channelId &&
|
||||
draft.parent_id === parentId
|
||||
) || null
|
||||
);
|
||||
},
|
||||
|
||||
getThreadDraftParentIds: _state => channelId => {
|
||||
const ids = new Set();
|
||||
Object.values(_state.records).forEach(draft => {
|
||||
if (draft.internal_chat_channel_id === channelId && draft.parent_id) {
|
||||
ids.add(draft.parent_id);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
},
|
||||
|
||||
getUIFlags: _state => _state.uiFlags,
|
||||
};
|
||||
|
||||
const actions = {
|
||||
fetchDrafts: async ({ commit, state: _state }) => {
|
||||
if (_state.hasFetched) return Object.values(_state.records);
|
||||
|
||||
commit('SET_UI_FLAG', { isFetching: true });
|
||||
try {
|
||||
const response = await InternalChatDraftsAPI.getDrafts();
|
||||
const drafts = response.data;
|
||||
commit('SET_DRAFTS', drafts);
|
||||
commit('SET_HAS_FETCHED', true);
|
||||
return drafts;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isFetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
saveDraft: async ({ commit }, { channelId, content, parentId }) => {
|
||||
try {
|
||||
const response = await InternalChatDraftsAPI.saveDraft(channelId, {
|
||||
content,
|
||||
parent_id: parentId,
|
||||
});
|
||||
commit('SET_DRAFT', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteDraft: async ({ commit }, { channelId, draftId, parentId }) => {
|
||||
try {
|
||||
await InternalChatDraftsAPI.deleteDraft(channelId, { parentId });
|
||||
commit('DELETE_DRAFT', draftId);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
SET_DRAFTS(_state, drafts) {
|
||||
const records = {};
|
||||
drafts.forEach(draft => {
|
||||
records[draft.id] = draft;
|
||||
});
|
||||
_state.records = records;
|
||||
},
|
||||
|
||||
SET_DRAFT(_state, draft) {
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[draft.id]: draft,
|
||||
};
|
||||
},
|
||||
|
||||
DELETE_DRAFT(_state, draftId) {
|
||||
const { [draftId]: _, ...rest } = _state.records;
|
||||
_state.records = rest;
|
||||
},
|
||||
|
||||
SET_HAS_FETCHED(_state, value) {
|
||||
_state.hasFetched = value;
|
||||
},
|
||||
|
||||
SET_UI_FLAG(_state, flags) {
|
||||
_state.uiFlags = { ..._state.uiFlags, ...flags };
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@ -0,0 +1,87 @@
|
||||
export const getters = {
|
||||
getChannels: _state => {
|
||||
return Object.values(_state.records).sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
},
|
||||
|
||||
getChannelById: _state => channelId => {
|
||||
return (
|
||||
_state.records[channelId] || _state.archivedRecords[channelId] || null
|
||||
);
|
||||
},
|
||||
|
||||
getChannelsByCategory: _state => categoryId => {
|
||||
return Object.values(_state.records)
|
||||
.filter(
|
||||
channel =>
|
||||
channel.category_id === categoryId &&
|
||||
!channel.is_dm &&
|
||||
channel.channel_type !== 'dm' &&
|
||||
channel.status !== 'archived'
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
},
|
||||
|
||||
getDMChannels: _state => {
|
||||
return Object.values(_state.records)
|
||||
.filter(
|
||||
channel =>
|
||||
(channel.is_dm || channel.channel_type === 'dm') &&
|
||||
channel.status !== 'archived'
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const tsA = a.last_activity_at || 0;
|
||||
const tsB = b.last_activity_at || 0;
|
||||
if (tsA !== tsB) return tsB - tsA;
|
||||
return (a.id || 0) - (b.id || 0);
|
||||
});
|
||||
},
|
||||
|
||||
getFavoriteChannels: _state => {
|
||||
return Object.values(_state.records).filter(
|
||||
channel => channel.favorited && channel.status !== 'archived'
|
||||
);
|
||||
},
|
||||
|
||||
getMutedChannels: _state => {
|
||||
return Object.values(_state.records).filter(
|
||||
channel => channel.muted && channel.status !== 'archived'
|
||||
);
|
||||
},
|
||||
|
||||
getArchivedChannels: _state => {
|
||||
return Object.values(_state.archivedRecords).sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
},
|
||||
|
||||
getCategories: _state => {
|
||||
return _state.categories;
|
||||
},
|
||||
|
||||
getUnreadCount: _state => {
|
||||
return Object.values(_state.records).reduce((total, channel) => {
|
||||
if (channel.muted) return total;
|
||||
return total + (channel.unread_count || 0);
|
||||
}, 0);
|
||||
},
|
||||
|
||||
getUIFlags: _state => {
|
||||
return _state.uiFlags;
|
||||
},
|
||||
|
||||
getActiveChannelId: _state => {
|
||||
return _state.activeChannelId;
|
||||
},
|
||||
};
|
||||
|
||||
export default getters;
|
||||
33
app/javascript/dashboard/store/modules/internalChat/index.js
Normal file
33
app/javascript/dashboard/store/modules/internalChat/index.js
Normal file
@ -0,0 +1,33 @@
|
||||
import { getters } from './getters';
|
||||
import { actions } from './actions';
|
||||
import { mutations } from './mutations';
|
||||
import messages from './messages';
|
||||
import polls from './polls';
|
||||
import drafts from './drafts';
|
||||
import search from './search';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
archivedRecords: {},
|
||||
categories: [],
|
||||
activeChannelId: null,
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
isCreating: false,
|
||||
isFetchingArchived: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
modules: {
|
||||
messages,
|
||||
polls,
|
||||
drafts,
|
||||
search,
|
||||
},
|
||||
};
|
||||
460
app/javascript/dashboard/store/modules/internalChat/messages.js
Normal file
460
app/javascript/dashboard/store/modules/internalChat/messages.js
Normal file
@ -0,0 +1,460 @@
|
||||
import InternalChatMessagesAPI from '../../../api/internalChatMessages';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
threadReplies: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
isSending: false,
|
||||
},
|
||||
};
|
||||
|
||||
const getters = {
|
||||
getMessages: _state => channelId => {
|
||||
return _state.records[channelId] || [];
|
||||
},
|
||||
|
||||
getMessageById: _state => (channelId, messageId) => {
|
||||
const messages = _state.records[channelId] || [];
|
||||
return messages.find(m => m.id === messageId) || null;
|
||||
},
|
||||
|
||||
getThreadReplies: _state => parentMessageId => {
|
||||
return _state.threadReplies[parentMessageId] || [];
|
||||
},
|
||||
|
||||
getUIFlags: _state => {
|
||||
return _state.uiFlags;
|
||||
},
|
||||
};
|
||||
|
||||
const actions = {
|
||||
fetchMessages: async ({ commit }, { channelId, params = {} }) => {
|
||||
commit('SET_UI_FLAG', { isFetching: true });
|
||||
try {
|
||||
const response = await InternalChatMessagesAPI.getMessages(
|
||||
channelId,
|
||||
params
|
||||
);
|
||||
const messages = response.data.messages || response.data;
|
||||
if (params.around) {
|
||||
commit('SET_MESSAGES', { channelId, messages });
|
||||
} else if (params.before) {
|
||||
commit('PREPEND_MESSAGES', { channelId, messages });
|
||||
} else if (params.after) {
|
||||
commit('APPEND_MESSAGES', { channelId, messages });
|
||||
} else {
|
||||
commit('SET_MESSAGES', { channelId, messages });
|
||||
}
|
||||
return messages;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isFetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async ({ commit }, { channelId, data, files = [] }) => {
|
||||
commit('SET_UI_FLAG', { isSending: true });
|
||||
try {
|
||||
const response = await InternalChatMessagesAPI.createMessage(
|
||||
channelId,
|
||||
data,
|
||||
files
|
||||
);
|
||||
commit('ADD_MESSAGE', { channelId, message: response.data });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isSending: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateMessage: async ({ commit }, { channelId, messageId, data }) => {
|
||||
try {
|
||||
const response = await InternalChatMessagesAPI.updateMessage(
|
||||
channelId,
|
||||
messageId,
|
||||
data
|
||||
);
|
||||
const message = response.data;
|
||||
commit('UPDATE_MESSAGE', { channelId, message });
|
||||
if (message.parent_id) {
|
||||
commit('UPDATE_THREAD_REPLY', {
|
||||
parentMessageId: message.parent_id,
|
||||
reply: message,
|
||||
});
|
||||
}
|
||||
return message;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteMessage: async (
|
||||
{ commit, state: _state },
|
||||
{ channelId, messageId }
|
||||
) => {
|
||||
try {
|
||||
await InternalChatMessagesAPI.deleteMessage(channelId, messageId);
|
||||
commit('DELETE_MESSAGE', { channelId, messageId });
|
||||
// Also mark deleted in thread replies if applicable
|
||||
Object.keys(_state.threadReplies).forEach(parentId => {
|
||||
const replies = _state.threadReplies[parentId] || [];
|
||||
if (replies.some(r => r.id === messageId)) {
|
||||
commit('DELETE_THREAD_REPLY', {
|
||||
parentMessageId: Number(parentId),
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
addReaction: async ({ commit }, { channelId, messageId, emoji }) => {
|
||||
try {
|
||||
const response = await InternalChatMessagesAPI.addReaction(
|
||||
messageId,
|
||||
emoji
|
||||
);
|
||||
commit('ADD_REACTION', {
|
||||
channelId,
|
||||
messageId,
|
||||
reaction: response.data,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
removeReaction: async ({ commit }, { channelId, messageId, reactionId }) => {
|
||||
try {
|
||||
await InternalChatMessagesAPI.removeReaction(messageId, reactionId);
|
||||
commit('REMOVE_REACTION', { channelId, messageId, reactionId });
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
fetchThread: async ({ commit }, { channelId, messageId }) => {
|
||||
try {
|
||||
const response = await InternalChatMessagesAPI.getThread(
|
||||
channelId,
|
||||
messageId
|
||||
);
|
||||
const replies = response.data.replies || response.data || [];
|
||||
commit('SET_THREAD_REPLIES', { parentMessageId: messageId, replies });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
sendThreadReply: async ({ commit }, { channelId, parentMessageId, data }) => {
|
||||
commit('SET_UI_FLAG', { isSending: true });
|
||||
try {
|
||||
const response = await InternalChatMessagesAPI.createMessage(channelId, {
|
||||
...data,
|
||||
parent_id: parentMessageId,
|
||||
});
|
||||
const message = response.data;
|
||||
commit('ADD_THREAD_REPLY', {
|
||||
parentMessageId,
|
||||
reply: message,
|
||||
});
|
||||
if (message.content_attributes?.also_send_in_channel) {
|
||||
commit('ADD_MESSAGE', { channelId, message });
|
||||
}
|
||||
return message;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isSending: false });
|
||||
}
|
||||
},
|
||||
|
||||
pinMessage: async ({ commit }, { channelId, messageId }) => {
|
||||
try {
|
||||
const response = await InternalChatMessagesAPI.pinMessage(
|
||||
channelId,
|
||||
messageId
|
||||
);
|
||||
commit('UPDATE_MESSAGE', { channelId, message: response.data });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
unpinMessage: async ({ commit }, { channelId, messageId }) => {
|
||||
try {
|
||||
const response = await InternalChatMessagesAPI.unpinMessage(
|
||||
channelId,
|
||||
messageId
|
||||
);
|
||||
commit('UPDATE_MESSAGE', { channelId, message: response.data });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
addMessageFromCable: ({ commit }, { channelId, message }) => {
|
||||
if (message.parent_id) {
|
||||
commit('ADD_THREAD_REPLY', {
|
||||
parentMessageId: message.parent_id,
|
||||
reply: message,
|
||||
});
|
||||
commit('INCREMENT_REPLY_COUNT', {
|
||||
channelId,
|
||||
parentMessageId: message.parent_id,
|
||||
});
|
||||
if (message.content_attributes?.also_send_in_channel) {
|
||||
commit('ADD_MESSAGE', { channelId, message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
commit('ADD_MESSAGE', { channelId, message });
|
||||
},
|
||||
|
||||
updateMessageFromCable: ({ commit }, { channelId, message }) => {
|
||||
commit('UPDATE_MESSAGE', { channelId, message });
|
||||
},
|
||||
|
||||
deleteMessageFromCable: ({ commit }, { channelId, messageId }) => {
|
||||
commit('DELETE_MESSAGE', { channelId, messageId });
|
||||
},
|
||||
|
||||
addReactionFromCable: ({ commit }, { channelId, messageId, reaction }) => {
|
||||
commit('ADD_REACTION', { channelId, messageId, reaction });
|
||||
},
|
||||
|
||||
removeReactionFromCable: (
|
||||
{ commit },
|
||||
{ channelId, messageId, reactionId }
|
||||
) => {
|
||||
commit('REMOVE_REACTION', { channelId, messageId, reactionId });
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
SET_MESSAGES(_state, { channelId, messages }) {
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[channelId]: messages,
|
||||
};
|
||||
},
|
||||
|
||||
PREPEND_MESSAGES(_state, { channelId, messages }) {
|
||||
const existing = _state.records[channelId] || [];
|
||||
const existingIds = new Set(existing.map(m => m.id));
|
||||
const newMessages = messages.filter(m => !existingIds.has(m.id));
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[channelId]: [...newMessages, ...existing],
|
||||
};
|
||||
},
|
||||
|
||||
APPEND_MESSAGES(_state, { channelId, messages }) {
|
||||
const existing = _state.records[channelId] || [];
|
||||
const existingIds = new Set(existing.map(m => m.id));
|
||||
const newMessages = messages.filter(m => !existingIds.has(m.id));
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[channelId]: [...existing, ...newMessages],
|
||||
};
|
||||
},
|
||||
|
||||
ADD_MESSAGE(_state, { channelId, message }) {
|
||||
const existing = _state.records[channelId] || [];
|
||||
const alreadyExists = existing.some(m => m.id === message.id);
|
||||
if (!alreadyExists) {
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[channelId]: [...existing, message],
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
UPDATE_MESSAGE(_state, { channelId, message }) {
|
||||
const existing = _state.records[channelId] || [];
|
||||
const index = existing.findIndex(m => m.id === message.id);
|
||||
if (index > -1) {
|
||||
const updated = [...existing];
|
||||
updated[index] = { ...existing[index], ...message };
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[channelId]: updated,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
DELETE_MESSAGE(_state, { channelId, messageId }) {
|
||||
const messages = _state.records[channelId];
|
||||
if (!messages) return;
|
||||
const index = messages.findIndex(m => m.id === messageId);
|
||||
if (index !== -1) {
|
||||
const updated = [...messages];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
content_attributes: {
|
||||
...updated[index].content_attributes,
|
||||
deleted: true,
|
||||
},
|
||||
};
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[channelId]: updated,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
ADD_REACTION(_state, { channelId, messageId, reaction }) {
|
||||
const applyAdd = message => {
|
||||
const currentReactions = message.reactions || [];
|
||||
if (currentReactions.some(r => r.id === reaction.id)) return message;
|
||||
return { ...message, reactions: [...currentReactions, reaction] };
|
||||
};
|
||||
|
||||
const existing = _state.records[channelId] || [];
|
||||
const index = existing.findIndex(m => m.id === messageId);
|
||||
if (index > -1) {
|
||||
const updated = [...existing];
|
||||
updated[index] = applyAdd(existing[index]);
|
||||
_state.records = { ..._state.records, [channelId]: updated };
|
||||
}
|
||||
|
||||
const nextThreadReplies = { ..._state.threadReplies };
|
||||
let threadReplyChanged = false;
|
||||
Object.keys(nextThreadReplies).forEach(parentId => {
|
||||
const replies = nextThreadReplies[parentId] || [];
|
||||
const replyIndex = replies.findIndex(r => r.id === messageId);
|
||||
if (replyIndex === -1) return;
|
||||
const updatedReplies = [...replies];
|
||||
updatedReplies[replyIndex] = applyAdd(replies[replyIndex]);
|
||||
nextThreadReplies[parentId] = updatedReplies;
|
||||
threadReplyChanged = true;
|
||||
});
|
||||
if (threadReplyChanged) _state.threadReplies = nextThreadReplies;
|
||||
},
|
||||
|
||||
REMOVE_REACTION(_state, { channelId, messageId, reactionId }) {
|
||||
const applyRemove = message => ({
|
||||
...message,
|
||||
reactions: (message.reactions || []).filter(r => r.id !== reactionId),
|
||||
});
|
||||
|
||||
const existing = _state.records[channelId] || [];
|
||||
const index = existing.findIndex(m => m.id === messageId);
|
||||
if (index > -1) {
|
||||
const updated = [...existing];
|
||||
updated[index] = applyRemove(existing[index]);
|
||||
_state.records = { ..._state.records, [channelId]: updated };
|
||||
}
|
||||
|
||||
const nextThreadReplies = { ..._state.threadReplies };
|
||||
let threadReplyChanged = false;
|
||||
Object.keys(nextThreadReplies).forEach(parentId => {
|
||||
const replies = nextThreadReplies[parentId] || [];
|
||||
const replyIndex = replies.findIndex(r => r.id === messageId);
|
||||
if (replyIndex === -1) return;
|
||||
const updatedReplies = [...replies];
|
||||
updatedReplies[replyIndex] = applyRemove(replies[replyIndex]);
|
||||
nextThreadReplies[parentId] = updatedReplies;
|
||||
threadReplyChanged = true;
|
||||
});
|
||||
if (threadReplyChanged) _state.threadReplies = nextThreadReplies;
|
||||
},
|
||||
|
||||
SET_THREAD_REPLIES(_state, { parentMessageId, replies }) {
|
||||
_state.threadReplies = {
|
||||
..._state.threadReplies,
|
||||
[parentMessageId]: replies,
|
||||
};
|
||||
},
|
||||
|
||||
ADD_THREAD_REPLY(_state, { parentMessageId, reply }) {
|
||||
const existing = _state.threadReplies[parentMessageId] || [];
|
||||
if (existing.some(r => r.id === reply.id)) return;
|
||||
_state.threadReplies = {
|
||||
..._state.threadReplies,
|
||||
[parentMessageId]: [...existing, reply],
|
||||
};
|
||||
},
|
||||
|
||||
UPDATE_THREAD_REPLY(_state, { parentMessageId, reply }) {
|
||||
const existing = _state.threadReplies[parentMessageId] || [];
|
||||
const index = existing.findIndex(r => r.id === reply.id);
|
||||
if (index > -1) {
|
||||
const updated = [...existing];
|
||||
updated[index] = { ...updated[index], ...reply };
|
||||
_state.threadReplies = {
|
||||
..._state.threadReplies,
|
||||
[parentMessageId]: updated,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
DELETE_THREAD_REPLY(_state, { parentMessageId, messageId }) {
|
||||
const existing = _state.threadReplies[parentMessageId];
|
||||
if (!existing) return;
|
||||
const index = existing.findIndex(r => r.id === messageId);
|
||||
if (index !== -1) {
|
||||
const updated = [...existing];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
content_attributes: {
|
||||
...updated[index].content_attributes,
|
||||
deleted: true,
|
||||
},
|
||||
};
|
||||
_state.threadReplies = {
|
||||
..._state.threadReplies,
|
||||
[parentMessageId]: updated,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
INCREMENT_REPLY_COUNT(_state, { channelId, parentMessageId }) {
|
||||
const messages = _state.records[channelId] || [];
|
||||
const index = messages.findIndex(m => m.id === parentMessageId);
|
||||
if (index > -1) {
|
||||
const updated = [...messages];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
replies_count: (updated[index].replies_count || 0) + 1,
|
||||
};
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[channelId]: updated,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
SET_UI_FLAG(_state, flags) {
|
||||
_state.uiFlags = { ..._state.uiFlags, ...flags };
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@ -0,0 +1,84 @@
|
||||
export const mutations = {
|
||||
SET_CHANNELS(_state, channels) {
|
||||
const records = {};
|
||||
// Preserve archived channels already loaded (e.g. via show)
|
||||
Object.values(_state.records).forEach(existing => {
|
||||
if (existing.status === 'archived') {
|
||||
records[existing.id] = existing;
|
||||
}
|
||||
});
|
||||
channels.forEach(channel => {
|
||||
records[channel.id] = channel;
|
||||
});
|
||||
_state.records = records;
|
||||
},
|
||||
|
||||
ADD_CHANNEL(_state, channel) {
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[channel.id]: channel,
|
||||
};
|
||||
},
|
||||
|
||||
UPDATE_CHANNEL(_state, channel) {
|
||||
const existing = _state.records[channel.id];
|
||||
if (existing) {
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[channel.id]: { ...existing, ...channel },
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
DELETE_CHANNEL(_state, channelId) {
|
||||
const { [channelId]: _, ...rest } = _state.records;
|
||||
_state.records = rest;
|
||||
if (_state.activeChannelId === channelId) {
|
||||
_state.activeChannelId = null;
|
||||
}
|
||||
},
|
||||
|
||||
SET_ARCHIVED_CHANNELS(_state, channels) {
|
||||
const records = {};
|
||||
channels.forEach(channel => {
|
||||
records[channel.id] = channel;
|
||||
});
|
||||
_state.archivedRecords = records;
|
||||
},
|
||||
|
||||
ADD_ARCHIVED_CHANNEL(_state, channel) {
|
||||
_state.archivedRecords = {
|
||||
..._state.archivedRecords,
|
||||
[channel.id]: channel,
|
||||
};
|
||||
},
|
||||
|
||||
REMOVE_ARCHIVED_CHANNEL(_state, channelId) {
|
||||
const { [channelId]: _, ...rest } = _state.archivedRecords;
|
||||
_state.archivedRecords = rest;
|
||||
},
|
||||
|
||||
SET_CATEGORIES(_state, categories) {
|
||||
_state.categories = categories;
|
||||
},
|
||||
|
||||
ADD_CATEGORY(_state, category) {
|
||||
_state.categories = [...(_state.categories || []), category];
|
||||
},
|
||||
|
||||
REMOVE_CATEGORY(_state, categoryId) {
|
||||
_state.categories = (_state.categories || []).filter(
|
||||
c => c.id !== categoryId
|
||||
);
|
||||
},
|
||||
|
||||
SET_UI_FLAG(_state, flags) {
|
||||
_state.uiFlags = { ..._state.uiFlags, ...flags };
|
||||
},
|
||||
|
||||
SET_ACTIVE_CHANNEL(_state, channelId) {
|
||||
_state.activeChannelId = channelId;
|
||||
},
|
||||
};
|
||||
|
||||
export default mutations;
|
||||
123
app/javascript/dashboard/store/modules/internalChat/polls.js
Normal file
123
app/javascript/dashboard/store/modules/internalChat/polls.js
Normal file
@ -0,0 +1,123 @@
|
||||
import InternalChatPollsAPI from '../../../api/internalChatPolls';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
const state = {
|
||||
uiFlags: {
|
||||
isCreating: false,
|
||||
isVoting: false,
|
||||
},
|
||||
};
|
||||
|
||||
const getters = {
|
||||
getUIFlags: _state => _state.uiFlags,
|
||||
};
|
||||
|
||||
const actions = {
|
||||
createPoll: async ({ commit, dispatch }, { channelId, data }) => {
|
||||
commit('SET_UI_FLAG', { isCreating: true });
|
||||
try {
|
||||
const response = await InternalChatPollsAPI.createPoll({
|
||||
channel_id: channelId,
|
||||
...data,
|
||||
});
|
||||
dispatch(
|
||||
'internalChat/messages/addMessageFromCable',
|
||||
{ channelId, message: response.data },
|
||||
{ root: true }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isCreating: false });
|
||||
}
|
||||
},
|
||||
|
||||
vote: async ({ commit, dispatch }, { pollId, optionId, channelId }) => {
|
||||
commit('SET_UI_FLAG', { isVoting: true });
|
||||
try {
|
||||
const response = await InternalChatPollsAPI.vote(pollId, optionId);
|
||||
if (channelId && response.data) {
|
||||
dispatch(
|
||||
'internalChat/messages/updateMessageFromCable',
|
||||
{ channelId, message: response.data },
|
||||
{ root: true }
|
||||
);
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isVoting: false });
|
||||
}
|
||||
},
|
||||
|
||||
unvote: async ({ commit, dispatch }, { pollId, optionId, channelId }) => {
|
||||
commit('SET_UI_FLAG', { isVoting: true });
|
||||
try {
|
||||
const response = await InternalChatPollsAPI.unvote(pollId, optionId);
|
||||
if (channelId && response.data) {
|
||||
dispatch(
|
||||
'internalChat/messages/updateMessageFromCable',
|
||||
{ channelId, message: response.data },
|
||||
{ root: true }
|
||||
);
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isVoting: false });
|
||||
}
|
||||
},
|
||||
|
||||
updatePollFromCable: ({ dispatch, rootGetters }, { channelId, poll }) => {
|
||||
const messageId = poll.internal_chat_message_id;
|
||||
if (!messageId) return;
|
||||
|
||||
const existingMessage = rootGetters['internalChat/messages/getMessageById'](
|
||||
channelId,
|
||||
messageId
|
||||
);
|
||||
const existingAttrs = existingMessage?.content_attributes || {};
|
||||
const existingPoll = existingAttrs.poll || {};
|
||||
const existingOptions = existingPoll.options || [];
|
||||
|
||||
// Preserve per-user voted flags from local state (cable data is not user-specific)
|
||||
const mergedOptions = (poll.options || []).map(opt => {
|
||||
const existing = existingOptions.find(e => e.id === opt.id);
|
||||
return { ...opt, voted: existing?.voted ?? opt.voted };
|
||||
});
|
||||
|
||||
const mergedPoll = { ...poll, options: mergedOptions };
|
||||
|
||||
dispatch(
|
||||
'internalChat/messages/updateMessageFromCable',
|
||||
{
|
||||
channelId,
|
||||
message: {
|
||||
id: messageId,
|
||||
content_attributes: { ...existingAttrs, poll: mergedPoll },
|
||||
},
|
||||
},
|
||||
{ root: true }
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
SET_UI_FLAG(_state, flags) {
|
||||
_state.uiFlags = { ..._state.uiFlags, ...flags };
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@ -0,0 +1,86 @@
|
||||
import InternalChatChannelsAPI from 'dashboard/api/internalChatChannels';
|
||||
|
||||
const state = {
|
||||
query: '',
|
||||
channels: [],
|
||||
dms: [],
|
||||
messages: [],
|
||||
searchLimited: false,
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
hasMoreMessages: false,
|
||||
currentPage: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const getters = {
|
||||
getQuery: _state => _state.query,
|
||||
getChannels: _state => _state.channels,
|
||||
getDMs: _state => _state.dms,
|
||||
getMessages: _state => _state.messages,
|
||||
getUIFlags: _state => _state.uiFlags,
|
||||
isSearchLimited: _state => _state.searchLimited,
|
||||
};
|
||||
|
||||
const actions = {
|
||||
async search({ commit }, { query, page = 1 }) {
|
||||
commit('SET_QUERY', query);
|
||||
commit('SET_UI_FLAG', { isFetching: true });
|
||||
try {
|
||||
const { data } = await InternalChatChannelsAPI.search({
|
||||
q: query,
|
||||
page,
|
||||
});
|
||||
if (page === 1) {
|
||||
commit('SET_RESULTS', data);
|
||||
} else {
|
||||
commit('APPEND_MESSAGES', data.messages || []);
|
||||
}
|
||||
commit('SET_UI_FLAG', {
|
||||
hasMoreMessages: data.meta?.messages_has_more || false,
|
||||
currentPage: page,
|
||||
});
|
||||
} catch {
|
||||
// silently handle
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isFetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearSearch({ commit }) {
|
||||
commit('CLEAR_RESULTS');
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
SET_QUERY(_state, query) {
|
||||
_state.query = query;
|
||||
},
|
||||
SET_RESULTS(_state, data) {
|
||||
_state.channels = data.channels || [];
|
||||
_state.dms = data.dms || [];
|
||||
_state.messages = data.messages || [];
|
||||
_state.searchLimited = data.meta?.search_limited || false;
|
||||
},
|
||||
APPEND_MESSAGES(_state, messages) {
|
||||
_state.messages = [..._state.messages, ...messages];
|
||||
},
|
||||
CLEAR_RESULTS(_state) {
|
||||
_state.query = '';
|
||||
_state.channels = [];
|
||||
_state.dms = [];
|
||||
_state.messages = [];
|
||||
_state.searchLimited = false;
|
||||
},
|
||||
SET_UI_FLAG(_state, flags) {
|
||||
_state.uiFlags = { ..._state.uiFlags, ...flags };
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@ -0,0 +1,56 @@
|
||||
import InternalChatChannelsAPI from '../../../api/internalChatChannels';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getUserList: $state => channelId => {
|
||||
return $state.records[Number(channelId)] || [];
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
toggleTyping: async (_, { status, channelId }) => {
|
||||
try {
|
||||
await InternalChatChannelsAPI.toggleTypingStatus(channelId, status);
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
},
|
||||
create: ({ commit, state: _state }, { channelId, user }) => {
|
||||
const records = _state.records[channelId] || [];
|
||||
const hasUser = records.some(r => r.id === user.id);
|
||||
if (!hasUser) {
|
||||
commit('ADD_TYPING_USER', { channelId, user });
|
||||
}
|
||||
},
|
||||
destroy: ({ commit }, { channelId, user }) => {
|
||||
commit('REMOVE_TYPING_USER', { channelId, user });
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
ADD_TYPING_USER: ($state, { channelId, user }) => {
|
||||
const records = $state.records[channelId] || [];
|
||||
$state.records = {
|
||||
...$state.records,
|
||||
[channelId]: [...records, user],
|
||||
};
|
||||
},
|
||||
REMOVE_TYPING_USER: ($state, { channelId, user }) => {
|
||||
const records = $state.records[channelId] || [];
|
||||
$state.records = {
|
||||
...$state.records,
|
||||
[channelId]: records.filter(r => r.id !== user.id),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
// Process [@mention](mention://user/1/Pranav) and [@mention](mention://contact/1/Name)
|
||||
const USER_MENTIONS_REGEX = /mention:\/\/(user|team|contact)\/(\d+)\/(.+)/gm;
|
||||
// Process [@mention](mention://user/1/Pranav), [@mention](mention://contact/1/Name), and [#42](mention://conversation/42/42)
|
||||
const USER_MENTIONS_REGEX =
|
||||
/mention:\/\/(user|team|contact|conversation)\/(\d+)\/(.+)/gm;
|
||||
|
||||
const buildMentionTokens = () => (state, silent) => {
|
||||
var label;
|
||||
@ -51,7 +52,9 @@ const buildMentionTokens = () => (state, silent) => {
|
||||
token = state.push('mention', '');
|
||||
token.href = href;
|
||||
token.content = label;
|
||||
const mentionMatch = href.match(/mention:\/\/(user|team|contact)\//);
|
||||
const mentionMatch = href.match(
|
||||
/mention:\/\/(user|team|contact|conversation)\//
|
||||
);
|
||||
token.mentionType = mentionMatch ? mentionMatch[1] : 'user';
|
||||
}
|
||||
|
||||
@ -63,6 +66,10 @@ const buildMentionTokens = () => (state, silent) => {
|
||||
|
||||
const renderMentions = () => (tokens, idx) => {
|
||||
const token = tokens[idx];
|
||||
if (token.mentionType === 'conversation') {
|
||||
const displayId = token.content.replace(/^@/, '');
|
||||
return `<span class="prosemirror-mention-node prosemirror-mention-conversation" data-conversation-id="${displayId}" role="link" tabindex="0">#${displayId}</span>`;
|
||||
}
|
||||
if (token.mentionType === 'contact') {
|
||||
return `<span class="prosemirror-mention-node prosemirror-mention-contact">${token.content}</span>`;
|
||||
}
|
||||
|
||||
@ -129,4 +129,31 @@ describe('#MessageFormatter', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation mentions', () => {
|
||||
it('renders conversation mention with # prefix', () => {
|
||||
const message = '[@42](mention://conversation/42/42)';
|
||||
const result = new MessageFormatter(message).formattedMessage;
|
||||
expect(result).toContain('#42');
|
||||
expect(result).toContain('prosemirror-mention-conversation');
|
||||
expect(result).not.toContain('@42');
|
||||
});
|
||||
|
||||
it('includes data-conversation-id attribute', () => {
|
||||
const message = '[@99](mention://conversation/99/99)';
|
||||
const result = new MessageFormatter(message).formattedMessage;
|
||||
expect(result).toContain('data-conversation-id="99"');
|
||||
});
|
||||
|
||||
it('renders both user and conversation mentions in mixed content', () => {
|
||||
const message =
|
||||
'Hey [@John](mention://user/1/John) check [@42](mention://conversation/42/42)';
|
||||
const result = new MessageFormatter(message).formattedMessage;
|
||||
expect(result).toContain(
|
||||
'<span class="prosemirror-mention-node">@John</span>'
|
||||
);
|
||||
expect(result).toContain('#42');
|
||||
expect(result).toContain('prosemirror-mention-conversation');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,6 +7,7 @@ class Agents::DestroyJob < ApplicationJob
|
||||
remove_user_from_teams(account, user)
|
||||
remove_user_from_inboxes(account, user)
|
||||
unassign_conversations(account, user)
|
||||
preserve_internal_chat_dm_names(account, user)
|
||||
end
|
||||
end
|
||||
|
||||
@ -34,4 +35,12 @@ class Agents::DestroyJob < ApplicationJob
|
||||
user.assigned_conversations.where(account: account).in_batches.update_all(assignee_id: nil)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def preserve_internal_chat_dm_names(account, user)
|
||||
dm_channels = account.internal_chat_channels.where(channel_type: :dm)
|
||||
.joins(:channel_members).where(internal_chat_channel_members: { user_id: user.id })
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
dm_channels.where(name: [nil, '']).update_all(name: user.name)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
|
||||
11
app/jobs/internal/setup_default_channels_job.rb
Normal file
11
app/jobs/internal/setup_default_channels_job.rb
Normal file
@ -0,0 +1,11 @@
|
||||
class Internal::SetupDefaultChannelsJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform
|
||||
Account.find_each do |account|
|
||||
InternalChat::DefaultChannelSetupService.new(account: account).perform
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to setup internal chat for account #{account.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
201
app/listeners/internal_chat_listener.rb
Normal file
201
app/listeners/internal_chat_listener.rb
Normal file
@ -0,0 +1,201 @@
|
||||
class InternalChatListener < BaseListener
|
||||
include Events::Types
|
||||
|
||||
def internal_chat_message_created(event)
|
||||
message = event.data[:message]
|
||||
channel = message.channel
|
||||
account = message.account
|
||||
tokens = member_tokens(channel)
|
||||
|
||||
unhide_dm_members(channel) if channel.channel_type_dm?
|
||||
broadcast(account, tokens, INTERNAL_CHAT_MESSAGE_CREATED, message_event_data(message))
|
||||
broadcast_typing_off(account, channel, message.sender)
|
||||
end
|
||||
|
||||
def internal_chat_message_updated(event)
|
||||
message = event.data[:message]
|
||||
channel = message.channel
|
||||
account = message.account
|
||||
tokens = member_tokens(channel)
|
||||
|
||||
broadcast(account, tokens, INTERNAL_CHAT_MESSAGE_UPDATED, message_event_data(message))
|
||||
end
|
||||
|
||||
def internal_chat_message_deleted(event)
|
||||
message_data = event.data[:message_data]
|
||||
account = Account.find_by(id: message_data[:account_id])
|
||||
channel = InternalChat::Channel.find_by(id: message_data[:channel_id])
|
||||
return if account.blank? || channel.blank?
|
||||
return unless channel.account_id == account.id
|
||||
|
||||
tokens = member_tokens(channel)
|
||||
broadcast(account, tokens, INTERNAL_CHAT_MESSAGE_DELETED, message_data)
|
||||
end
|
||||
|
||||
def internal_chat_channel_updated(event)
|
||||
channel = event.data[:channel]
|
||||
account = channel.account
|
||||
# Use pre-captured tokens when available (e.g. after channel destroy)
|
||||
tokens = event.data[:member_tokens] || member_tokens(channel)
|
||||
|
||||
broadcast(account, tokens, INTERNAL_CHAT_CHANNEL_UPDATED,
|
||||
{
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
description: channel.description,
|
||||
channel_type: channel.channel_type,
|
||||
status: channel.status,
|
||||
category_id: channel.category_id,
|
||||
last_activity_at: channel.last_activity_at,
|
||||
member_user_ids: channel.channel_members.pluck(:user_id)
|
||||
})
|
||||
end
|
||||
|
||||
def internal_chat_typing_on(event)
|
||||
channel = event.data[:channel]
|
||||
user = event.data[:user]
|
||||
account = channel.account
|
||||
tokens = member_tokens(channel, exclude_user: user)
|
||||
|
||||
broadcast(account, tokens, INTERNAL_CHAT_TYPING_ON, { channel: { id: channel.id }, user: user.push_event_data })
|
||||
end
|
||||
|
||||
def internal_chat_typing_off(event)
|
||||
channel = event.data[:channel]
|
||||
user = event.data[:user]
|
||||
account = channel.account
|
||||
tokens = member_tokens(channel, exclude_user: user)
|
||||
|
||||
broadcast(account, tokens, INTERNAL_CHAT_TYPING_OFF, { channel: { id: channel.id }, user: user.push_event_data })
|
||||
end
|
||||
|
||||
def internal_chat_poll_voted(event)
|
||||
poll = event.data[:poll]
|
||||
message = event.data[:message]
|
||||
channel = message.channel
|
||||
account = message.account
|
||||
tokens = member_tokens(channel)
|
||||
|
||||
broadcast(account, tokens, INTERNAL_CHAT_POLL_VOTED,
|
||||
poll_event_data(poll).merge(internal_chat_channel_id: channel.id))
|
||||
end
|
||||
|
||||
def internal_chat_reaction_created(event)
|
||||
reaction = event.data[:reaction]
|
||||
message = reaction.message
|
||||
channel = message.channel
|
||||
account = message.account
|
||||
tokens = member_tokens(channel)
|
||||
|
||||
broadcast(account, tokens, INTERNAL_CHAT_REACTION_CREATED, reaction_event_data(reaction))
|
||||
end
|
||||
|
||||
def internal_chat_reaction_deleted(event)
|
||||
reaction_data = event.data[:reaction_data]
|
||||
account = Account.find_by(id: reaction_data[:account_id])
|
||||
channel = InternalChat::Channel.find_by(id: reaction_data[:channel_id])
|
||||
return if account.blank? || channel.blank?
|
||||
return unless channel.account_id == account.id
|
||||
|
||||
tokens = member_tokens(channel)
|
||||
broadcast(account, tokens, INTERNAL_CHAT_REACTION_DELETED, reaction_data)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcast_typing_off(account, channel, user)
|
||||
tokens = member_tokens(channel, exclude_user: user)
|
||||
broadcast(account, tokens, INTERNAL_CHAT_TYPING_OFF, { channel: { id: channel.id }, user: user.push_event_data })
|
||||
end
|
||||
|
||||
def unhide_dm_members(channel)
|
||||
channel.channel_members.where(hidden: true).find_each { |m| m.update(hidden: false) }
|
||||
end
|
||||
|
||||
def member_tokens(channel, exclude_user: nil)
|
||||
users = if channel.channel_type_public_channel?
|
||||
channel.account.users
|
||||
else
|
||||
channel.members
|
||||
end
|
||||
|
||||
tokens = users.pluck(:pubsub_token)
|
||||
tokens -= [exclude_user.pubsub_token] if exclude_user.present?
|
||||
tokens
|
||||
end
|
||||
|
||||
def message_event_data(message)
|
||||
data = base_message_data(message)
|
||||
data[:poll] = poll_event_data(message.poll) if message.poll.present?
|
||||
data
|
||||
end
|
||||
|
||||
def base_message_data(message)
|
||||
{
|
||||
id: message.id, content: message.content, content_type: message.content_type,
|
||||
content_attributes: message.content_attributes, internal_chat_channel_id: message.internal_chat_channel_id,
|
||||
sender: message.sender.push_event_data, parent_id: message.parent_id, echo_id: message.echo_id,
|
||||
replies_count: message.replies_count, created_at: message.created_at, updated_at: message.updated_at,
|
||||
reactions: message.reactions.includes(:user).map { |r| { id: r.id, emoji: r.emoji, user_id: r.user_id, user: { name: r.user&.name } } },
|
||||
attachments: message.attachments.map { |a| attachment_event_data(a) }
|
||||
}
|
||||
end
|
||||
|
||||
def poll_event_data(poll)
|
||||
{
|
||||
id: poll.id,
|
||||
question: poll.question,
|
||||
multiple_choice: poll.multiple_choice,
|
||||
public_results: poll.public_results,
|
||||
allow_revote: poll.allow_revote,
|
||||
expires_at: poll.expires_at,
|
||||
internal_chat_message_id: poll.internal_chat_message_id,
|
||||
options: poll.options.ordered.includes(votes: :user).map { |option| poll_option_event_data(option, poll) },
|
||||
total_votes: poll.total_votes_count,
|
||||
created_at: poll.created_at,
|
||||
updated_at: poll.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def poll_option_event_data(option, poll)
|
||||
data = {
|
||||
id: option.id,
|
||||
text: option.text,
|
||||
emoji: option.emoji,
|
||||
image_url: option.image_url,
|
||||
position: option.position,
|
||||
votes_count: option.votes_count
|
||||
}
|
||||
data[:voters] = option.votes.map { |v| v.user.push_event_data } if poll.public_results
|
||||
data
|
||||
end
|
||||
|
||||
def attachment_event_data(attachment)
|
||||
{
|
||||
id: attachment.id,
|
||||
file_type: attachment.file_type,
|
||||
external_url: attachment.external_url,
|
||||
extension: attachment.extension,
|
||||
file_url: attachment.file.attached? ? Rails.application.routes.url_helpers.url_for(attachment.file) : nil
|
||||
}
|
||||
end
|
||||
|
||||
def reaction_event_data(reaction)
|
||||
{
|
||||
id: reaction.id,
|
||||
emoji: reaction.emoji,
|
||||
user_id: reaction.user_id,
|
||||
user: { name: reaction.user.name },
|
||||
message_id: reaction.internal_chat_message_id,
|
||||
internal_chat_channel_id: reaction.message.internal_chat_channel_id,
|
||||
created_at: reaction.created_at
|
||||
}
|
||||
end
|
||||
|
||||
def broadcast(account, tokens, event_name, data)
|
||||
return if tokens.blank?
|
||||
|
||||
payload = data.merge(account_id: account.id)
|
||||
::ActionCableBroadcastJob.perform_later(tokens.uniq, event_name, payload)
|
||||
end
|
||||
end
|
||||
@ -114,6 +114,20 @@ class WebhookListener < BaseListener
|
||||
handle_typing_status(__method__.to_s, event)
|
||||
end
|
||||
|
||||
%i[internal_chat_message_created internal_chat_message_updated internal_chat_message_deleted].each do |event_name|
|
||||
define_method(event_name) do |event|
|
||||
message = event.data[:message]
|
||||
payload = internal_chat_message_payload(message).merge(event: event_name.to_s)
|
||||
deliver_account_webhooks(payload, message.account)
|
||||
end
|
||||
end
|
||||
|
||||
def internal_chat_channel_updated(event)
|
||||
channel = event.data[:channel]
|
||||
payload = internal_chat_channel_payload(channel).merge(event: __method__.to_s)
|
||||
deliver_account_webhooks(payload, channel.account)
|
||||
end
|
||||
|
||||
def provider_event_received(event)
|
||||
inbox, account = extract_inbox_and_account(event)
|
||||
|
||||
@ -143,6 +157,30 @@ class WebhookListener < BaseListener
|
||||
deliver_webhook_payloads(payload, inbox)
|
||||
end
|
||||
|
||||
def internal_chat_message_payload(message)
|
||||
{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
content_type: message.content_type,
|
||||
internal_chat_channel_id: message.internal_chat_channel_id,
|
||||
sender: message.sender&.push_event_data,
|
||||
account_id: message.account_id,
|
||||
created_at: message.created_at,
|
||||
updated_at: message.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def internal_chat_channel_payload(channel)
|
||||
{
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
channel_type: channel.channel_type,
|
||||
account_id: channel.account_id,
|
||||
created_at: channel.created_at,
|
||||
updated_at: channel.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def deliver_account_webhooks(payload, account)
|
||||
account.webhooks.account_type.each do |webhook|
|
||||
next unless webhook.subscriptions.include?(payload[:event])
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
# index_accounts_on_status (status)
|
||||
#
|
||||
|
||||
class Account < ApplicationRecord
|
||||
class Account < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||
# used for single column multi flags
|
||||
include FlagShihTzu
|
||||
include Reportable
|
||||
@ -120,6 +120,8 @@ class Account < ApplicationRecord
|
||||
has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok'
|
||||
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
||||
has_many :inboxes, dependent: :destroy_async
|
||||
has_many :internal_chat_categories, class_name: 'InternalChat::Category', dependent: :destroy_async
|
||||
has_many :internal_chat_channels, class_name: 'InternalChat::Channel', dependent: :destroy_async
|
||||
has_many :labels, dependent: :destroy_async
|
||||
has_many :line_channels, dependent: :destroy_async, class_name: '::Channel::Line'
|
||||
has_many :mentions, dependent: :destroy_async
|
||||
@ -150,6 +152,7 @@ class Account < ApplicationRecord
|
||||
|
||||
before_validation :validate_limit_keys
|
||||
after_create_commit :notify_creation
|
||||
after_create_commit :setup_internal_chat
|
||||
after_destroy :remove_account_sequences
|
||||
|
||||
def agents
|
||||
@ -207,6 +210,10 @@ class Account < ApplicationRecord
|
||||
Rails.configuration.dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self)
|
||||
end
|
||||
|
||||
def setup_internal_chat
|
||||
InternalChat::DefaultChannelSetupService.new(account: self).perform
|
||||
end
|
||||
|
||||
trigger.after(:insert).for_each(:row) do
|
||||
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
|
||||
end
|
||||
|
||||
@ -36,7 +36,7 @@ class AccountUser < ApplicationRecord
|
||||
|
||||
accepts_nested_attributes_for :account
|
||||
|
||||
after_create_commit :notify_creation, :create_notification_setting
|
||||
after_create_commit :notify_creation, :create_notification_setting, :add_to_public_internal_chat_channels
|
||||
after_destroy :notify_deletion, :remove_user_from_account
|
||||
after_save :update_presence_in_redis, if: :saved_change_to_availability?
|
||||
|
||||
@ -79,6 +79,14 @@ class AccountUser < ApplicationRecord
|
||||
def update_presence_in_redis
|
||||
OnlineStatusTracker.set_status(account.id, user.id, availability)
|
||||
end
|
||||
|
||||
def add_to_public_internal_chat_channels
|
||||
account.internal_chat_channels.where(channel_type: :public_channel).find_each do |channel|
|
||||
channel.channel_members.find_or_create_by!(user_id: user_id) do |m|
|
||||
m.role = administrator? ? :admin : :member
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
AccountUser.prepend_mod_with('AccountUser')
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
# Indexes
|
||||
#
|
||||
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
|
||||
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin
|
||||
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY (ARRAY[('baileys'::character varying)::text, ('zapi'::character varying)::text])) USING gin
|
||||
#
|
||||
# rubocop:enable Layout/LineLength
|
||||
|
||||
|
||||
@ -28,7 +28,6 @@
|
||||
# contact_inbox_id :bigint
|
||||
# display_id :integer not null
|
||||
# inbox_id :integer not null
|
||||
# kanban_task_id :bigint
|
||||
# sla_policy_id :bigint
|
||||
# team_id :bigint
|
||||
#
|
||||
@ -47,7 +46,6 @@
|
||||
# index_conversations_on_identifier_and_account_id (identifier,account_id)
|
||||
# index_conversations_on_inbox_id (inbox_id)
|
||||
# index_conversations_on_inbox_id_and_group_type (inbox_id,group_type)
|
||||
# index_conversations_on_kanban_task_id (kanban_task_id)
|
||||
# index_conversations_on_priority (priority)
|
||||
# index_conversations_on_status_and_account_id (status,account_id)
|
||||
# index_conversations_on_status_and_priority (status,priority)
|
||||
@ -55,10 +53,6 @@
|
||||
# index_conversations_on_uuid (uuid) UNIQUE
|
||||
# index_conversations_on_waiting_since (waiting_since)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (kanban_task_id => kanban_tasks.id)
|
||||
#
|
||||
|
||||
class Conversation < ApplicationRecord
|
||||
include Labelable
|
||||
|
||||
30
app/models/internal_chat/category.rb
Normal file
30
app/models/internal_chat/category.rb
Normal file
@ -0,0 +1,30 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: internal_chat_categories
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string not null
|
||||
# position :integer default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_internal_chat_categories_on_account_id (account_id)
|
||||
# index_internal_chat_categories_on_account_id_and_name (account_id,name) UNIQUE
|
||||
# index_internal_chat_categories_on_account_id_and_position (account_id,position)
|
||||
#
|
||||
class InternalChat::Category < ApplicationRecord
|
||||
self.table_name = 'internal_chat_categories'
|
||||
|
||||
belongs_to :account
|
||||
has_many :channels, class_name: 'InternalChat::Channel', dependent: :nullify, inverse_of: :category
|
||||
|
||||
validates :name, presence: true
|
||||
validates :name, uniqueness: { scope: :account_id }
|
||||
|
||||
scope :ordered, -> { order(:position) }
|
||||
end
|
||||
|
||||
InternalChat::Category.prepend_mod_with('InternalChat::Category')
|
||||
95
app/models/internal_chat/channel.rb
Normal file
95
app/models/internal_chat/channel.rb
Normal file
@ -0,0 +1,95 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: internal_chat_channels
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# channel_type :integer default("public_channel"), not null
|
||||
# description :text
|
||||
# last_activity_at :datetime not null
|
||||
# messages_count :integer default(0)
|
||||
# name :string
|
||||
# status :integer default("active"), not null
|
||||
# uuid :uuid not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# category_id :bigint
|
||||
# created_by_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_ic_channels_description_unaccent_trgm (f_unaccent(description) gin_trgm_ops) USING gin
|
||||
# idx_ic_channels_name_unaccent_trgm (f_unaccent((name)::text) gin_trgm_ops) USING gin
|
||||
# index_internal_chat_channels_on_account_id (account_id)
|
||||
# index_internal_chat_channels_on_account_id_and_category_id (account_id,category_id)
|
||||
# index_internal_chat_channels_on_account_id_and_channel_type (account_id,channel_type)
|
||||
# index_internal_chat_channels_on_account_id_and_status (account_id,status)
|
||||
# index_internal_chat_channels_on_category_id (category_id)
|
||||
# index_internal_chat_channels_on_uuid (uuid) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (category_id => internal_chat_categories.id)
|
||||
# fk_rails_... (created_by_id => users.id)
|
||||
#
|
||||
class InternalChat::Channel < ApplicationRecord
|
||||
self.table_name = 'internal_chat_channels'
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :category, class_name: 'InternalChat::Category', optional: true
|
||||
belongs_to :created_by, class_name: 'User', optional: true
|
||||
|
||||
has_many :channel_members,
|
||||
class_name: 'InternalChat::ChannelMember',
|
||||
foreign_key: :internal_chat_channel_id,
|
||||
dependent: :destroy,
|
||||
inverse_of: :channel
|
||||
has_many :members, through: :channel_members, source: :user
|
||||
has_many :channel_teams,
|
||||
class_name: 'InternalChat::ChannelTeam',
|
||||
foreign_key: :internal_chat_channel_id,
|
||||
dependent: :destroy,
|
||||
inverse_of: :channel
|
||||
has_many :teams, through: :channel_teams
|
||||
has_many :messages,
|
||||
class_name: 'InternalChat::Message',
|
||||
foreign_key: :internal_chat_channel_id,
|
||||
dependent: :destroy,
|
||||
inverse_of: :channel
|
||||
has_many :message_attachments, through: :messages, source: :attachments
|
||||
has_many :drafts,
|
||||
class_name: 'InternalChat::Draft',
|
||||
foreign_key: :internal_chat_channel_id,
|
||||
dependent: :destroy,
|
||||
inverse_of: :channel
|
||||
|
||||
enum :channel_type, { public_channel: 0, private_channel: 1, dm: 2 }, prefix: true
|
||||
enum :status, { active: 0, archived: 1 }
|
||||
|
||||
validates :name, presence: true, unless: :channel_type_dm?
|
||||
validates :uuid, uniqueness: true
|
||||
|
||||
before_validation :generate_uuid, on: :create
|
||||
before_validation :set_last_activity_at, on: :create
|
||||
|
||||
scope :active, -> { where(status: :active) }
|
||||
scope :archived, -> { where(status: :archived) }
|
||||
scope :text_channels, -> { where.not(channel_type: :dm) }
|
||||
scope :direct_messages, -> { where(channel_type: :dm) }
|
||||
|
||||
def dm?
|
||||
channel_type_dm?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_uuid
|
||||
self.uuid ||= SecureRandom.uuid
|
||||
end
|
||||
|
||||
def set_last_activity_at
|
||||
self.last_activity_at ||= Time.current
|
||||
end
|
||||
end
|
||||
|
||||
InternalChat::Channel.prepend_mod_with('InternalChat::Channel')
|
||||
48
app/models/internal_chat/channel_member.rb
Normal file
48
app/models/internal_chat/channel_member.rb
Normal file
@ -0,0 +1,48 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: internal_chat_channel_members
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# favorited :boolean default(FALSE), not null
|
||||
# hidden :boolean default(FALSE), not null
|
||||
# last_read_at :datetime
|
||||
# muted :boolean default(FALSE), not null
|
||||
# role :integer default("member"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# internal_chat_channel_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_ic_channel_members_channel_user (internal_chat_channel_id,user_id) UNIQUE
|
||||
# idx_ic_channel_members_user_favorited (user_id,favorited)
|
||||
# index_internal_chat_channel_members_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (internal_chat_channel_id => internal_chat_channels.id)
|
||||
# fk_rails_... (user_id => users.id) ON DELETE => cascade
|
||||
#
|
||||
class InternalChat::ChannelMember < ApplicationRecord
|
||||
self.table_name = 'internal_chat_channel_members'
|
||||
|
||||
belongs_to :channel, class_name: 'InternalChat::Channel', foreign_key: :internal_chat_channel_id, inverse_of: :channel_members
|
||||
belongs_to :user
|
||||
|
||||
enum :role, { member: 0, admin: 1 }
|
||||
|
||||
validates :user_id, uniqueness: { scope: :internal_chat_channel_id }
|
||||
|
||||
scope :not_muted, -> { where(muted: false) }
|
||||
scope :muted, -> { where(muted: true) }
|
||||
scope :favorited, -> { where(favorited: true) }
|
||||
|
||||
def unread_messages_count
|
||||
scope = channel.messages.where.not(sender_id: user_id)
|
||||
scope = scope.where('created_at > ?', last_read_at) if last_read_at.present?
|
||||
scope.count
|
||||
end
|
||||
end
|
||||
|
||||
InternalChat::ChannelMember.prepend_mod_with('InternalChat::ChannelMember')
|
||||
28
app/models/internal_chat/channel_team.rb
Normal file
28
app/models/internal_chat/channel_team.rb
Normal file
@ -0,0 +1,28 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: internal_chat_channel_teams
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# internal_chat_channel_id :bigint not null
|
||||
# team_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_ic_channel_teams_channel_team (internal_chat_channel_id,team_id) UNIQUE
|
||||
# index_internal_chat_channel_teams_on_team_id (team_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (internal_chat_channel_id => internal_chat_channels.id)
|
||||
# fk_rails_... (team_id => teams.id)
|
||||
#
|
||||
class InternalChat::ChannelTeam < ApplicationRecord
|
||||
self.table_name = 'internal_chat_channel_teams'
|
||||
|
||||
belongs_to :channel, class_name: 'InternalChat::Channel', foreign_key: :internal_chat_channel_id, inverse_of: :channel_teams
|
||||
belongs_to :team
|
||||
|
||||
validates :team_id, uniqueness: { scope: :internal_chat_channel_id }
|
||||
end
|
||||
42
app/models/internal_chat/draft.rb
Normal file
42
app/models/internal_chat/draft.rb
Normal file
@ -0,0 +1,42 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: internal_chat_drafts
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# content :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# internal_chat_channel_id :bigint not null
|
||||
# parent_id :bigint
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_ic_drafts_channel (internal_chat_channel_id)
|
||||
# idx_ic_drafts_user_channel_root (user_id,internal_chat_channel_id) UNIQUE WHERE (parent_id IS NULL)
|
||||
# idx_ic_drafts_user_channel_thread (user_id,internal_chat_channel_id,parent_id) UNIQUE WHERE (parent_id IS NOT NULL)
|
||||
# idx_ic_drafts_user_updated (user_id,updated_at)
|
||||
# index_internal_chat_drafts_on_account_id (account_id)
|
||||
# index_internal_chat_drafts_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (internal_chat_channel_id => internal_chat_channels.id)
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
class InternalChat::Draft < ApplicationRecord
|
||||
self.table_name = 'internal_chat_drafts'
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :user
|
||||
belongs_to :channel, class_name: 'InternalChat::Channel', foreign_key: :internal_chat_channel_id, inverse_of: :drafts
|
||||
belongs_to :parent, class_name: 'InternalChat::Message', optional: true
|
||||
|
||||
validates :content, presence: true
|
||||
validates :user_id, uniqueness: { scope: [:internal_chat_channel_id, :parent_id] }
|
||||
|
||||
scope :recent, -> { order(updated_at: :desc) }
|
||||
end
|
||||
|
||||
InternalChat::Draft.prepend_mod_with('InternalChat::Draft')
|
||||
17
app/models/internal_chat/limits.rb
Normal file
17
app/models/internal_chat/limits.rb
Normal file
@ -0,0 +1,17 @@
|
||||
class InternalChat::Limits
|
||||
def self.unlimited?
|
||||
false
|
||||
end
|
||||
|
||||
def self.polls_enabled?
|
||||
unlimited?
|
||||
end
|
||||
|
||||
def self.max_private_channels
|
||||
unlimited? ? nil : 2
|
||||
end
|
||||
|
||||
def self.search_history_days
|
||||
unlimited? ? nil : 90
|
||||
end
|
||||
end
|
||||
90
app/models/internal_chat/message.rb
Normal file
90
app/models/internal_chat/message.rb
Normal file
@ -0,0 +1,90 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: internal_chat_messages
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# content :text
|
||||
# content_attributes :jsonb
|
||||
# content_type :integer default("text"), not null
|
||||
# replies_count :integer default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# echo_id :string
|
||||
# internal_chat_channel_id :bigint not null
|
||||
# parent_id :bigint
|
||||
# sender_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_ic_messages_account_created (account_id,created_at)
|
||||
# idx_ic_messages_channel_created (internal_chat_channel_id,created_at)
|
||||
# idx_ic_messages_content_unaccent_trgm (f_unaccent(content) gin_trgm_ops) USING gin
|
||||
# index_internal_chat_messages_on_account_id (account_id)
|
||||
# index_internal_chat_messages_on_internal_chat_channel_id (internal_chat_channel_id)
|
||||
# index_internal_chat_messages_on_parent_id (parent_id)
|
||||
# index_internal_chat_messages_on_sender_id (sender_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id) ON DELETE => cascade
|
||||
# fk_rails_... (internal_chat_channel_id => internal_chat_channels.id)
|
||||
# fk_rails_... (parent_id => internal_chat_messages.id)
|
||||
# fk_rails_... (sender_id => users.id) ON DELETE => nullify
|
||||
#
|
||||
class InternalChat::Message < ApplicationRecord
|
||||
self.table_name = 'internal_chat_messages'
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :channel, class_name: 'InternalChat::Channel', foreign_key: :internal_chat_channel_id,
|
||||
counter_cache: :messages_count, inverse_of: :messages
|
||||
belongs_to :sender, class_name: 'User', optional: true
|
||||
belongs_to :parent, class_name: 'InternalChat::Message', optional: true, inverse_of: :replies, counter_cache: :replies_count
|
||||
|
||||
has_many :replies, class_name: 'InternalChat::Message', foreign_key: :parent_id,
|
||||
dependent: :destroy, inverse_of: :parent
|
||||
has_many :attachments, class_name: 'InternalChat::MessageAttachment', foreign_key: :internal_chat_message_id,
|
||||
dependent: :destroy, inverse_of: :message
|
||||
has_many :reactions, class_name: 'InternalChat::Reaction', foreign_key: :internal_chat_message_id,
|
||||
dependent: :destroy, inverse_of: :message
|
||||
has_one :poll, class_name: 'InternalChat::Poll', foreign_key: :internal_chat_message_id,
|
||||
dependent: :destroy, inverse_of: :message
|
||||
|
||||
enum :content_type, { text: 0, poll: 1, system: 2 }
|
||||
|
||||
attr_accessor :skip_content_validation
|
||||
|
||||
validates :content, presence: true, unless: -> { skip_content_validation || !text? }
|
||||
validates :content, length: { maximum: 150_000 }
|
||||
|
||||
scope :ordered, -> { order(created_at: :asc) }
|
||||
scope :recent, -> { order(created_at: :desc) }
|
||||
|
||||
after_create_commit :update_channel_activity
|
||||
|
||||
def edited?
|
||||
content_attributes&.dig('edited_at').present?
|
||||
end
|
||||
|
||||
def thread?
|
||||
parent_id.present?
|
||||
end
|
||||
|
||||
def thread_replies_count
|
||||
replies_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Atomic compare-and-set so concurrent message creates can never regress
|
||||
# last_activity_at to an older timestamp.
|
||||
def update_channel_activity
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
InternalChat::Channel.where(id: internal_chat_channel_id)
|
||||
.where('last_activity_at IS NULL OR last_activity_at < ?', created_at)
|
||||
.update_all(last_activity_at: created_at)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
|
||||
InternalChat::Message.prepend_mod_with('InternalChat::Message')
|
||||
37
app/models/internal_chat/message_attachment.rb
Normal file
37
app/models/internal_chat/message_attachment.rb
Normal file
@ -0,0 +1,37 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: internal_chat_message_attachments
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# extension :string
|
||||
# external_url :string
|
||||
# file_type :integer default("image"), not null
|
||||
# meta :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# internal_chat_message_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_ic_msg_attachments_message (internal_chat_message_id)
|
||||
# index_internal_chat_message_attachments_on_account_id (account_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (internal_chat_message_id => internal_chat_messages.id)
|
||||
#
|
||||
class InternalChat::MessageAttachment < ApplicationRecord
|
||||
self.table_name = 'internal_chat_message_attachments'
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :message, class_name: 'InternalChat::Message', foreign_key: :internal_chat_message_id, inverse_of: :attachments
|
||||
|
||||
has_one_attached :file
|
||||
|
||||
validates :file, presence: true
|
||||
|
||||
enum :file_type, { image: 0, audio: 1, video: 2, file: 3 }
|
||||
end
|
||||
|
||||
InternalChat::MessageAttachment.prepend_mod_with('InternalChat::MessageAttachment')
|
||||
46
app/models/internal_chat/poll.rb
Normal file
46
app/models/internal_chat/poll.rb
Normal file
@ -0,0 +1,46 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: internal_chat_polls
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# allow_revote :boolean default(TRUE), not null
|
||||
# expires_at :datetime
|
||||
# multiple_choice :boolean default(FALSE), not null
|
||||
# public_results :boolean default(TRUE), not null
|
||||
# question :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# internal_chat_message_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_ic_polls_message (internal_chat_message_id)
|
||||
# idx_ic_polls_message_unique (internal_chat_message_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (internal_chat_message_id => internal_chat_messages.id)
|
||||
#
|
||||
class InternalChat::Poll < ApplicationRecord
|
||||
self.table_name = 'internal_chat_polls'
|
||||
|
||||
belongs_to :message, class_name: 'InternalChat::Message', foreign_key: :internal_chat_message_id, inverse_of: :poll
|
||||
|
||||
has_many :options, class_name: 'InternalChat::PollOption', foreign_key: :internal_chat_poll_id,
|
||||
dependent: :destroy, inverse_of: :poll
|
||||
has_many :votes, through: :options
|
||||
|
||||
validates :question, presence: true
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at < Time.current
|
||||
end
|
||||
|
||||
def total_votes_count
|
||||
return options.sum(&:votes_count) if options.loaded?
|
||||
|
||||
options.sum(:votes_count)
|
||||
end
|
||||
end
|
||||
|
||||
InternalChat::Poll.prepend_mod_with('InternalChat::Poll')
|
||||
36
app/models/internal_chat/poll_option.rb
Normal file
36
app/models/internal_chat/poll_option.rb
Normal file
@ -0,0 +1,36 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: internal_chat_poll_options
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# emoji :string
|
||||
# image_url :string
|
||||
# position :integer default(0), not null
|
||||
# text :string not null
|
||||
# votes_count :integer default(0), not null
|
||||
# created_at :datetime not null
|
||||
# internal_chat_poll_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_ic_poll_options_poll (internal_chat_poll_id)
|
||||
# idx_ic_poll_options_poll_pos (internal_chat_poll_id,position)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (internal_chat_poll_id => internal_chat_polls.id)
|
||||
#
|
||||
class InternalChat::PollOption < ApplicationRecord
|
||||
self.table_name = 'internal_chat_poll_options'
|
||||
|
||||
belongs_to :poll, class_name: 'InternalChat::Poll', foreign_key: :internal_chat_poll_id, inverse_of: :options
|
||||
|
||||
has_many :votes, class_name: 'InternalChat::PollVote', foreign_key: :internal_chat_poll_option_id,
|
||||
dependent: :destroy, inverse_of: :option
|
||||
|
||||
validates :text, presence: true
|
||||
|
||||
scope :ordered, -> { order(position: :asc) }
|
||||
end
|
||||
|
||||
InternalChat::PollOption.prepend_mod_with('InternalChat::PollOption')
|
||||
31
app/models/internal_chat/poll_vote.rb
Normal file
31
app/models/internal_chat/poll_vote.rb
Normal file
@ -0,0 +1,31 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: internal_chat_poll_votes
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# created_at :datetime not null
|
||||
# internal_chat_poll_option_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_ic_poll_votes_option (internal_chat_poll_option_id)
|
||||
# idx_ic_poll_votes_option_user (internal_chat_poll_option_id,user_id) UNIQUE
|
||||
# index_internal_chat_poll_votes_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (internal_chat_poll_option_id => internal_chat_poll_options.id)
|
||||
# fk_rails_... (user_id => users.id) ON DELETE => cascade
|
||||
#
|
||||
class InternalChat::PollVote < ApplicationRecord
|
||||
self.table_name = 'internal_chat_poll_votes'
|
||||
|
||||
belongs_to :option, class_name: 'InternalChat::PollOption', foreign_key: :internal_chat_poll_option_id,
|
||||
inverse_of: :votes, counter_cache: :votes_count
|
||||
belongs_to :user
|
||||
|
||||
validates :user_id, uniqueness: { scope: :internal_chat_poll_option_id }
|
||||
end
|
||||
|
||||
InternalChat::PollVote.prepend_mod_with('InternalChat::PollVote')
|
||||
32
app/models/internal_chat/reaction.rb
Normal file
32
app/models/internal_chat/reaction.rb
Normal file
@ -0,0 +1,32 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: internal_chat_reactions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# emoji :string not null
|
||||
# created_at :datetime not null
|
||||
# internal_chat_message_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_ic_reactions_message (internal_chat_message_id)
|
||||
# idx_ic_reactions_message_user_emoji (internal_chat_message_id,user_id,emoji) UNIQUE
|
||||
# index_internal_chat_reactions_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (internal_chat_message_id => internal_chat_messages.id)
|
||||
# fk_rails_... (user_id => users.id) ON DELETE => cascade
|
||||
#
|
||||
class InternalChat::Reaction < ApplicationRecord
|
||||
self.table_name = 'internal_chat_reactions'
|
||||
|
||||
belongs_to :message, class_name: 'InternalChat::Message', foreign_key: :internal_chat_message_id, inverse_of: :reactions
|
||||
belongs_to :user
|
||||
|
||||
validates :emoji, presence: true
|
||||
validates :emoji, uniqueness: { scope: [:internal_chat_message_id, :user_id] }
|
||||
end
|
||||
|
||||
InternalChat::Reaction.prepend_mod_with('InternalChat::Reaction')
|
||||
@ -2,17 +2,17 @@
|
||||
#
|
||||
# Table name: reporting_events_rollups
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# count :bigint default(0), not null
|
||||
# date :date not null
|
||||
# dimension_id :bigint not null
|
||||
# dimension_type :string not null
|
||||
# metric :string not null
|
||||
# sum_value :float default(0.0), not null
|
||||
# sum_value_business_hours :float default(0.0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
# id :bigint not null, primary key
|
||||
# count :bigint default(0), not null
|
||||
# date :date not null
|
||||
# dimension_type :string not null
|
||||
# metric :string not null
|
||||
# sum_value :float default(0.0), not null
|
||||
# sum_value_business_hours :float default(0.0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
# dimension_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_users_name_unaccent_trgm (f_unaccent((name)::text) gin_trgm_ops) USING gin
|
||||
# index_users_on_email (email)
|
||||
# index_users_on_otp_required_for_login (otp_required_for_login)
|
||||
# index_users_on_otp_secret (otp_secret) UNIQUE
|
||||
|
||||
@ -18,6 +18,18 @@ class TeamMember < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :team
|
||||
validates :user_id, uniqueness: { scope: :team_id }
|
||||
|
||||
after_create :add_to_linked_internal_chat_channels
|
||||
|
||||
private
|
||||
|
||||
def add_to_linked_internal_chat_channels
|
||||
return unless InternalChat::ChannelTeam.table_exists?
|
||||
|
||||
InternalChat::ChannelTeam.where(team_id: team_id).find_each do |channel_team|
|
||||
channel_team.channel.channel_members.find_or_create_by!(user_id: user_id) { |m| m.role = :member }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
TeamMember.include_mod_with('Audit::TeamMember')
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_users_name_unaccent_trgm (f_unaccent((name)::text) gin_trgm_ops) USING gin
|
||||
# index_users_on_email (email)
|
||||
# index_users_on_otp_required_for_login (otp_required_for_login)
|
||||
# index_users_on_otp_secret (otp_secret) UNIQUE
|
||||
@ -98,6 +99,8 @@ class User < ApplicationRecord
|
||||
has_many :inbox_members, dependent: :destroy_async
|
||||
has_many :inbox_signatures, dependent: :destroy_async
|
||||
has_many :inboxes, through: :inbox_members, source: :inbox
|
||||
has_many :internal_chat_channel_memberships, class_name: 'InternalChat::ChannelMember', dependent: :destroy_async
|
||||
has_many :internal_chat_channels, through: :internal_chat_channel_memberships, source: :channel
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string
|
||||
# secret :string
|
||||
# subscriptions :jsonb
|
||||
# url :text
|
||||
# webhook_type :integer default("account_type")
|
||||
@ -31,7 +32,9 @@ class Webhook < ApplicationRecord
|
||||
|
||||
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
|
||||
message_created message_incoming message_outgoing message_updated webwidget_triggered
|
||||
inbox_created inbox_updated conversation_typing_on conversation_typing_off provider_event_received].freeze
|
||||
inbox_created inbox_updated conversation_typing_on conversation_typing_off provider_event_received
|
||||
internal_chat_message_created internal_chat_message_updated
|
||||
internal_chat_message_deleted internal_chat_channel_updated].freeze
|
||||
|
||||
private
|
||||
|
||||
|
||||
27
app/policies/internal_chat/category_policy.rb
Normal file
27
app/policies/internal_chat/category_policy.rb
Normal file
@ -0,0 +1,27 @@
|
||||
class InternalChat::CategoryPolicy < ApplicationPolicy
|
||||
def index?
|
||||
agent_or_admin?
|
||||
end
|
||||
|
||||
def create?
|
||||
administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
administrator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
administrator?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_or_admin?
|
||||
@account_user.present?
|
||||
end
|
||||
|
||||
def administrator?
|
||||
@account_user&.administrator?
|
||||
end
|
||||
end
|
||||
70
app/policies/internal_chat/channel_policy.rb
Normal file
70
app/policies/internal_chat/channel_policy.rb
Normal file
@ -0,0 +1,70 @@
|
||||
class InternalChat::ChannelPolicy < ApplicationPolicy
|
||||
def index?
|
||||
agent_or_admin?
|
||||
end
|
||||
|
||||
def show?
|
||||
return true if administrator?
|
||||
return agent_or_admin? if record.channel_type_public_channel?
|
||||
|
||||
channel_member?
|
||||
end
|
||||
|
||||
def create?
|
||||
return agent_or_admin? if record.channel_type_dm?
|
||||
|
||||
administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
administrator? || channel_admin?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
administrator?
|
||||
end
|
||||
|
||||
def archive?
|
||||
administrator?
|
||||
end
|
||||
|
||||
def unarchive?
|
||||
administrator?
|
||||
end
|
||||
|
||||
def toggle_typing_status?
|
||||
accessible?
|
||||
end
|
||||
|
||||
def mark_read?
|
||||
accessible?
|
||||
end
|
||||
|
||||
def mark_unread?
|
||||
accessible?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_or_admin?
|
||||
@account_user.present?
|
||||
end
|
||||
|
||||
def administrator?
|
||||
@account_user&.administrator?
|
||||
end
|
||||
|
||||
def channel_member?
|
||||
record.channel_members.exists?(user_id: @user.id)
|
||||
end
|
||||
|
||||
def channel_admin?
|
||||
record.channel_members.exists?(user_id: @user.id, role: :admin)
|
||||
end
|
||||
|
||||
def accessible?
|
||||
return agent_or_admin? if record.channel_type_public_channel?
|
||||
|
||||
channel_member?
|
||||
end
|
||||
end
|
||||
54
app/policies/internal_chat/message_policy.rb
Normal file
54
app/policies/internal_chat/message_policy.rb
Normal file
@ -0,0 +1,54 @@
|
||||
class InternalChat::MessagePolicy < ApplicationPolicy
|
||||
def index?
|
||||
channel_accessible?
|
||||
end
|
||||
|
||||
def create?
|
||||
channel_accessible?
|
||||
end
|
||||
|
||||
def update?
|
||||
channel_accessible? && (administrator? || sender?)
|
||||
end
|
||||
|
||||
def destroy?
|
||||
channel_accessible? && (administrator? || sender?)
|
||||
end
|
||||
|
||||
def pin?
|
||||
administrator? || channel_admin?
|
||||
end
|
||||
|
||||
def unpin?
|
||||
administrator? || channel_admin?
|
||||
end
|
||||
|
||||
def thread?
|
||||
channel_accessible?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def channel
|
||||
record.respond_to?(:channel) ? record.channel : record
|
||||
end
|
||||
|
||||
def administrator?
|
||||
@account_user&.administrator?
|
||||
end
|
||||
|
||||
def sender?
|
||||
record.sender_id == @user.id
|
||||
end
|
||||
|
||||
def channel_accessible?
|
||||
return true if administrator?
|
||||
return @account_user.present? if channel.channel_type_public_channel?
|
||||
|
||||
channel.channel_members.exists?(user_id: @user.id)
|
||||
end
|
||||
|
||||
def channel_admin?
|
||||
channel.channel_members.exists?(user_id: @user.id, role: :admin)
|
||||
end
|
||||
end
|
||||
32
app/policies/internal_chat/reaction_policy.rb
Normal file
32
app/policies/internal_chat/reaction_policy.rb
Normal file
@ -0,0 +1,32 @@
|
||||
class InternalChat::ReactionPolicy < ApplicationPolicy
|
||||
def create?
|
||||
channel_accessible?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
return false unless channel_accessible?
|
||||
return true if administrator?
|
||||
|
||||
record.user_id == @user.id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def channel
|
||||
record.respond_to?(:message) && record.message.present? ? record.message.channel : nil
|
||||
end
|
||||
|
||||
def administrator?
|
||||
@account_user&.administrator?
|
||||
end
|
||||
|
||||
def channel_accessible?
|
||||
return true if administrator?
|
||||
|
||||
ch = channel
|
||||
return false if ch.blank?
|
||||
return @account_user.present? if ch.channel_type_public_channel?
|
||||
|
||||
ch.channel_members.exists?(user_id: @user.id)
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user