Compare commits

..
Author SHA1 Message Date
Shivam Mishra 41670bfae9 refactor: extract agent runner service concerns
Split RunContextNormalizer and StateBuilder out of
Captain::Assistant::AgentRunnerService to keep the service within the
class length budget.
2026-05-22 21:25:28 +05:30
Shivam Mishra 1984bddb11 refactor: extract response builder job concerns
Split ResponseMetadataBuilder and MessageHistoryCollector out of
Captain::Conversation::ResponseBuilderJob to keep the job within the
class length budget.
2026-05-22 21:25:17 +05:30
Shivam Mishra bba230a1ac feat: snapshot and restore metadata from each run 2026-05-22 21:20:18 +05:30
Shivam Mishra 10f9d61d02 chore: update prompt 2026-05-22 19:36:54 +05:30
Tanmay Deep SharmaandGitHub b1db6c3e9b fix: make zadd function optimised to stay in rubocop limits (#14520)
## Description
Fixes rubocop for alfred.rb file on develop 

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-05-21 17:24:51 +05:30
Shivam MishraandGitHub 3d20a7b049 feat: generate Help Center for Onboarding (#14370)
## Manually triggering help center generation

Open a Rails console (`bundle exec rails console`):

```ruby
account = Account.find(<ACCOUNT_ID>)
user    = account.users.first

# Optional: refresh brand info from the customer's website
domain = 'example.com'
result = WebsiteBrandingService.new("noreply@#{domain}").perform
account.update!(
  name: result[:title].presence || account.name,
  custom_attributes: account.custom_attributes.merge('website' => domain, 'brand_info' => result)
)

# Optional: wipe existing portals so a fresh one is created
account.portals.destroy_all

Onboarding::HelpCenterCreationService.new(account, user).perform
```

Sidekiq must be running — articles are written by
`Onboarding::HelpCenterArticleGenerationJob`. Avoid running on
production; generation calls the LLM provider.


### Generation flow (Happy Path) 

```mermaid
sequenceDiagram
    autonumber

    participant Kickoff as HelpCenterCreationService
    participant DB as DB
    participant GenJob as HelpCenterArticleGenerationJob
    participant Curator as HelpCenterCurator
    participant Firecrawl as Firecrawl
    participant CuratorLLM as Curation LLM
    participant Redis as Redis Progress
    participant WriterJob as HelpCenterArticleWriterJob
    participant Builder as HelpCenterArticleBuilder
    participant WriterLLM as Writer LLM
    participant Cable as ActionCable

    Kickoff->>DB: Create portal for account<br/>homepage_link=https://chatwoot.com
    Kickoff->>DB: Attach brand logo if available
    Kickoff->>GenJob: Enqueue generation job<br/>account_id, portal_id, user_id, generation_id

    GenJob->>Curator: Curate help center plan
    Curator->>Firecrawl: map https://chatwoot.com<br/>search: docs help support faq
    Firecrawl-->>Curator: Return discovered links
    Curator->>CuratorLLM: Select categories + article plans<br/>from discovered links only
    CuratorLLM-->>Curator: Return categories, articles, allowed_urls

    GenJob->>DB: Create portal categories
    GenJob->>GenJob: Stamp articles with category_id
    GenJob->>GenJob: Filter article URLs against allowed_urls
    GenJob->>GenJob: Drop articles with no category<br/>or no approved source URLs

    GenJob->>Redis: Start progress<br/>status=generating, total=N, finished=0

    loop For each approved article
      GenJob->>WriterJob: Enqueue writer job<br/>title, category_id, approved URLs
    end

    par Writer jobs run independently
      WriterJob->>Builder: Build article from approved URLs
      Builder->>Firecrawl: batch_scrape approved URLs
      Firecrawl-->>Builder: Return Markdown source pages
      Builder->>WriterLLM: Rewrite sources into one article
      WriterLLM-->>Builder: Return title, description, Markdown content
      Builder->>DB: Create draft portal article<br/>meta.source_urls
      WriterJob->>Redis: Increment finished count
      WriterJob->>Cable: Broadcast help_center.article_generated
    end

    WriterJob->>Redis: If finished >= total<br/>mark status=completed
    WriterJob->>Cable: Broadcast help_center.generation_completed
```

### Redis State Management

```mermaid
 stateDiagram-v2
    [*] --> active_pointer_set
    active_pointer_set --> generating: generation job creates valid plan
    active_pointer_set --> skipped: curation skipped/failed

    generating --> generating: each writer job increments finished
    generating --> completed: finished == total
    generating --> ignored_completion: generation_id superseded

    skipped --> [*]
    completed --> [*]
    ignored_completion --> [*]
```
2026-05-21 16:25:01 +05:30
Tanmay Deep SharmaandGitHub 3cd8cf43ce fix: atomically claim conversation to prevent duplicate assignment (#14495)
## Description

Fixes a bug under Assignment V2 where a single conversation could be
reassigned dozens of times in a row by the system, producing long stacks
of "Assigned to X by Automation System via <policy>" activity messages
alternating between agents. After this change each unassigned
conversation is assigned exactly once, even on busy inboxes.

## Fixes # (issue)


## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

## How to reproduce
1. Enable `assignment_v2` on an account with at least 2 online agents in
an inbox.
2. Generate sustained resolve/snooze activity in the inbox (each one
enqueues `AutoAssignment::AssignmentJob` for the whole inbox).
3. Watch any one unassigned conversation while the jobs drain — pre-fix
it picks up multiple back-to-back "Assigned to …" activity rows
alternating between agents.


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-05-21 16:14:28 +05:30
f33e469e9a feat: Unread Count: Frontend changes for showing unread count badges (3/3)[CW-6851] (#14372)
# Pull Request Template

## Description
This is the third and final PR in a series of PRs for Introducing unread
counts in the sidebar for inboxes and labels.

In this PR:
* Added frontend changes to show the badges for unread counts for
Inboxes and Labels
* Added specs for the changes

Issue:
https://linear.app/chatwoot/issue/CW-6851/support-unread-conversation-counts



## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Tested this locally. Cases to test:
* Send a message from the widget and see if the count changes
* Mark a conversation as unread and see the count change for inbox
* Open an unread conversation as agent and see the count go down
* Add a label to an unread conversation from sidebar right click action
without opening the conversation and see the count of un-reads on the
label change

Added the screenshot of how it will look like

<img width="614" height="990" alt="Screenshot 2026-05-05 at 7 00 11 PM"
src="https://github.com/user-attachments/assets/99fbaa9f-bcf2-4d8d-86e2-5727f652a9dd"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-20 19:21:25 +05:30
27f2c2b392 feat: Unread Count: added api, store refresher, invalidation and events (2/3)[CW-6851] (#14369)
# Pull Request Template

## Description

This is the second PR in a series of PRs for Introducing unread counts
in the sidebar for inboxes and labels.

In this PR:

* added api for unread counts 
* Added the store refresher and invalidation with event listeners
* Added action cable event
* Added specs for the changes

Issue:
https://linear.app/chatwoot/issue/CW-6851/support-unread-conversation-counts

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-20 17:36:09 +05:30
40deaef458 feat: Store WhatsApp BSUID identifiers from inbound webhooks (#14436)
Adds storage support for WhatsApp business-scoped user identifiers
received from Meta Cloud API and Twilio WhatsApp webhooks. The change
keeps existing phone-based behavior intact, stores BSUID and parent
BSUID values as additional `contact_inboxes.source_id` rows for the same
contact, and allows BSUID-only inbound messages to create contacts,
conversations, and messages without requiring a phone number.

Related: https://github.com/chatwoot/chatwoot/issues/13837

**What changed**
- Extended WhatsApp source ID validation to accept regular BSUID and
parent BSUID formats.
- For Meta Cloud API, stores phone, `user_id`, and `parent_user_id`
identifiers as contact inbox source IDs when they are present.
- For Twilio WhatsApp, stores phone, `ExternalUserId`, and
`ParentExternalUserId` identifiers as contact inbox source IDs while
preserving the existing `whatsapp:` Twilio source ID shape.
- Supports BSUID-only inbound messages by creating a contact, contact
inbox, conversation, and message even when the phone number is missing.
- Links phone-first and later BSUID-only messages to the same contact
when the first payload contains both phone and BSUID.
- Stores WhatsApp usernames in contact `additional_attributes`, matching
existing social channel patterns.
- Keeps existing phone-based outbound and new-conversation behavior
unchanged for this milestone.

**How to test**
1. Send a Meta Cloud webhook payload with both `wa_id` and `user_id`.
2. Verify Chatwoot creates or finds the phone `contact_inbox` and also
creates a BSUID `contact_inbox` for the same contact.
3. Send a later Meta Cloud payload for the same user with only `user_id`
/ `from_user_id`.
4. Verify Chatwoot finds the BSUID `contact_inbox` and creates the
inbound message without requiring a phone number.
5. Send a Twilio WhatsApp webhook with `From: whatsapp:+E164`,
`ExternalUserId`, and optionally `ParentExternalUserId`.
6. Verify Chatwoot stores the Twilio phone and BSUID identifiers as
`whatsapp:`-prefixed source IDs for the same contact.
7. Send a Twilio WhatsApp webhook where `From` is `whatsapp:<BSUID>` and
there is no phone number.
8. Verify Chatwoot creates the contact, contact inbox, conversation, and
message without a phone number.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-05-20 13:36:43 +04:00
3fae800936 feat: base layer for unread counts (store, counter and builder) (1/3)[CW-6851] (#14368)
## Description

This is the first PR in a series of PRs for Introducing unread counts in
the sidebar for inboxes and labels.

In this PR:

* Added the unread store, counter and builder modules
* Added redis keys for unread count management
* Added specs for all 3 modules, some specs are for testing enterprise
only feature like specific roles and permissions which are added in the
respective enterprise folder itself.

**Note**
None of this changes affect anything else and nothing is wired to
existing modules.

Issue:
https://linear.app/chatwoot/issue/CW-6851/support-unread-conversation-counts

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-20 14:26:21 +05:30
1913ccadfa fix: [CW-7141] fix gem audit issue for sidekiq-cron and devise (#14497)
# Pull Request Template

## Description
* sidekiq-cron upgraded to 2.4.0
* Sidekiq constrained to stay on 7.3.x
* Devise advisory ignored in .bundler-audit.yml with the reason:
Chatwoot does not enable Timeoutable, so the timeout redirect path is
not reachable


### Details
The sidekiq-cron upgrade is from 1.12.0 to 2.4.0.

What changed that matters for us:

Fixes the reported Sidekiq Web UI reflected XSS in 2.4.0.
Adds namespace handling changes from the 2.x series. Chatwoot does not
use custom cron namespaces in config/schedule.yml, so the migration
guide says no action is needed for our usage.
Drops support for old Sidekiq/Redis versions. We are still on Sidekiq
7.3.1, which is supported.
Adds new dependencies: cronex and unicode.
Keeps the same APIs we use: Sidekiq::Cron::Job.load_from_hash!(schedule,
source: 'schedule'), Sidekiq::Cron::Job.destroy(name), and require
'sidekiq/cron/web' still exist.
Chance of breakage: low, based on the current Chatwoot usage.

The main thing I would watch after deploy is scheduled job registration
in Sidekiq. The one subtle area is namespace behavior: if production has
custom, manually-created cron jobs using non-default namespaces,
load_from_hash! cleanup behavior could matter. For the committed
config/schedule.yml jobs, which do not specify namespaces, they should
continue in the default namespace.

For concerns around Devise, it does not look exploitable in current
Chatwoot, because Chatwoot does not enable Devise :timeoutable.
I checked:
app/models/user.rb (line 59) lists the Devise modules, and :timeoutable
is not included.
config/initializers/devise.rb (line 164) has the timeoutable section,
but config.timeout_in is commented out.
SuperAdmin inherits from User, so it does not add a separate timeoutable
path either.
So from a practical security perspective: the vulnerable redirect path
requires warden_message == :timeout, which is only produced by Devise
Timeoutable. Since Chatwoot does not use Timeoutable, this warning is
not currently reachable.
Is the patch really needed? Strictly for current exploitability: no.

Fixes #CW-7141

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Spec and lints and change-log checks with codex.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-05-19 20:39:40 +05:30
Sivin VargheseandGitHub bca95efb82 feat: add image resize support in articles (#14293) 2026-05-19 19:34:43 +05:30
6560dbb68d feat: Add an option on the dashboard to allow switching help center layout (#14491)
<img width="633" height="431" alt="Screenshot 2026-05-18 at 12 32 55 PM"
src="https://github.com/user-attachments/assets/682d4c5f-4c76-465b-8d2f-92fbc2bb2a40"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-05-19 06:42:48 -07:00
Sivin VargheseandGitHub 497d34c097 fix: render markdown in CSAT survey messages (#14468) 2026-05-19 10:27:14 +05:30
4371793741 fix: captain-v2 cannot see image attachments shared via email (#14449)
## Description

Inbound email attachments are stored with `file_type: 'file'` regardless
of their actual MIME type. As a result, image screenshots shared by
customers via email are not exposed to Captain V2's multimodal pipeline
— `Captain::OpenAiMessageBuilderService#attachment_parts` selects images
via `attachments.where(file_type: :image)` and emits a placeholder
`"User has shared an attachment"` text part instead of an `image_url`
part. The model never gets the image, so Captain keeps asking the
customer to retype information that is already visible in the
screenshot.

This PR makes the email mailbox derive `file_type` from the blob's
`content_type` using the existing shared `FileTypeHelper`, matching how
every other inbound channel (`twilio`, `sms`, `telegram`, `line`,
`tiktok`, `twitter`, `messenger`) and `MessageBuilder` already classify
attachments.

Fixes #14448

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Reproduced and verified on a self-hosted production instance:

1. Real customer reply via email with a PNG screenshot of an in-app
error.

   Before:
   ```ruby
   a = Message.find(<id>).attachments.first
   a.file_type                # => "file"
   a.file.blob.content_type   # => "image/png"
Captain::OpenAiMessageBuilderService.new(message:
a.message).generate_content
   # => [{type: 'text', text: '...'},
# {type: 'text', text: 'User has shared an attachment'}] ❌ no image_url
   ```
Captain reply: "Please copy and paste the full error text…" (model never
saw the image).

2. After the patch + force-recreate, same conversation:
   ```ruby
   a.file_type                # => "image"
Captain::OpenAiMessageBuilderService.new(message:
a.message).generate_content
   # => [{type: 'text',      text: '...'},
# {type: 'image_url', image_url: {url: 'https://.../<blob>.png'}}] ✅
   ```
Captain reply now correctly references the on-screen error text from the
screenshot via the multimodal vision path — no more deflection.

3. Regression sanity-check on non-image attachments (PDF / Office docs):
`file_type` falls through to `:file`, behavior unchanged.

## Notes for self-hosted operators

Existing email image attachments in the DB will still have `file_type:
'file'`. A one-shot backfill is straightforward and safe (no data loss,
only metadata):

```ruby
Attachment.joins(message: :conversation)
          .where(messages: { content_type: 'incoming_email' })
          .where(file_type: 'file')
          .find_each do |a|
  next unless a.file.attached?
  ct = a.file.blob.content_type.to_s
  next unless ct.start_with?('image/', 'audio/', 'video/')
  new_type = ct.start_with?('image/') ? :image : (ct.start_with?('video/') ? :video : :audio)
  a.update_columns(file_type: Attachment.file_types[new_type])
end
```

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective — happy to add a
`mailbox_helper_spec` example for `process_regular_attachments` if
maintainers prefer; existing specs in that file focus on inline-image
handling.

---------

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-05-19 10:11:32 +05:30
64585faff0 feat: Add a documentation layout design for public help center portal (#14403)
https://github.com/user-attachments/assets/fc4d15f9-2b54-4627-940f-94772ec739b1

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-05-18 12:30:08 -07:00
Sony Mathew e05008e0a4 Merge branch 'release/4.14.0' into develop 2026-05-18 21:55:01 +05:30
188 changed files with 7287 additions and 522 deletions
+3
View File
@@ -2,6 +2,9 @@
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor.
# Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable.
- GHSA-jp94-3292-c3xv
# Rails 7.1 has no patched release for the Active Storage proxy range
# advisories. Chatwoot limits proxy range requests locally.
- CVE-2026-33658
+4 -2
View File
@@ -133,9 +133,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.3.1'
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
gem 'sidekiq-cron', '>= 2.4.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
@@ -209,6 +209,8 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ###
##############################################################
+12 -5
View File
@@ -196,6 +196,9 @@ GEM
bigdecimal
rexml
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -336,6 +339,7 @@ GEM
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
firecrawl-sdk (1.4.1)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.11.1)
@@ -902,10 +906,11 @@ GEM
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
sidekiq-cron (2.4.0)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1)
sidekiq (>= 6)
sidekiq (>= 6.5.0)
sidekiq_alive (2.5.0)
gserver (~> 0.0.1)
sidekiq (>= 5, < 9)
@@ -979,6 +984,7 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode (0.4.4.5)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
@@ -1074,6 +1080,7 @@ DEPENDENCIES
faker
faraday_middleware-aws-sigv4
fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu
foreman
gemoji
@@ -1155,8 +1162,8 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq (~> 7.3, >= 7.3.1)
sidekiq-cron (>= 2.4.0)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
@@ -0,0 +1,16 @@
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
end
end
@@ -162,6 +162,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
end
def should_update_last_seen?
@@ -78,7 +78,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
)
end
@@ -1,9 +1,11 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show]
before_action :set_article, only: [:show, :show_markdown]
layout 'portal'
def index
@@ -21,6 +23,13 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
@parsed_content = render_article_content(@article.content.to_s)
end
def show_markdown
return head :not_found unless @article&.published?
render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8'
end
def tracking_pixel
@@ -62,7 +71,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@parsed_content = render_article_content(@article.content.to_s)
end
def set_category
@@ -7,6 +7,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
around_action :set_locale
after_action :allow_iframe_requests
PORTAL_LAYOUTS = %w[classic documentation].freeze
private
def show_plain_layout
@@ -17,6 +19,14 @@ class Public::Api::V1::Portals::BaseController < PublicController
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
end
def set_portal_layout
@portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic'
end
def set_view_variant
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
end
@@ -42,7 +52,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
article_locale = if article.category.present?
article.category.locale
else
article.portal.default_locale
article.locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
@@ -1,12 +1,18 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show]
before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal'
def index
@categories = @portal.categories.order(position: :asc)
respond_to do |format|
format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently }
format.json { @categories = @portal.categories.order(position: :asc) }
end
end
def show
@@ -21,4 +27,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
render_404 && return if @category.blank?
end
def load_category_articles
@articles = @category.articles.published.order(:position).includes(:author)
@category_authors = @articles.filter_map(&:author).uniq
end
end
@@ -2,7 +2,10 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal'
def show
@@ -28,4 +31,28 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -31,7 +31,11 @@ class Twilio::CallbackController < ApplicationController
:Latitude,
:Longitude,
:MessageType,
:ProfileName
:ProfileName,
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
)
end
end
+1
View File
@@ -17,6 +17,7 @@ class AsyncDispatcher < BaseDispatcher
InstallationWebhookListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
Conversations::UnreadCounts::Listener.instance,
ReportingEventListener.instance,
WebhookListener.instance
]
+1
View File
@@ -17,6 +17,7 @@ module FileTypeHelper
def image_file?(content_type)
[
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/bmp',
+11
View File
@@ -96,4 +96,15 @@ module PortalHelper
colors[username.length % colors.size]
end
def format_authors_label(authors)
return if authors.blank?
names = authors.map(&:available_name)
return names.to_sentence if names.size <= 3
I18n.t('public_portal.sidebar.authors_others',
names: names.first(2).join(', '),
count: authors.size - 2)
end
end
@@ -13,6 +13,10 @@ class ConversationApi extends ApiClient {
updateLabels(conversationID, labels) {
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
}
getUnreadCounts() {
return axios.get(`${this.url}/unread_counts`);
}
}
export default new ConversationApi();
@@ -11,6 +11,7 @@ describe('#ConversationApi', () => {
expect(conversationsAPI).toHaveProperty('delete');
expect(conversationsAPI).toHaveProperty('getLabels');
expect(conversationsAPI).toHaveProperty('updateLabels');
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
});
describe('API calls', () => {
@@ -47,5 +48,12 @@ describe('#ConversationApi', () => {
}
);
});
it('#getUnreadCounts', () => {
conversationsAPI.getUnreadCounts();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/unread_counts'
);
});
});
});
@@ -0,0 +1,290 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import classicLayoutPreview from './classic-layout-preview.svg?raw';
import documentationLayoutPreview from './documentation-layout-preview.svg?raw';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
const props = defineProps({
activePortal: { type: Object, required: true },
isFetching: { type: Boolean, default: false },
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
const PORTAL_LAYOUTS = {
CLASSIC: 'classic',
DOCUMENTATION: 'documentation',
};
// `prefix` is the link the help center auto-fills; the DB only stores the handle.
const SOCIAL_PLATFORMS = [
{
key: 'facebook',
label: 'Facebook',
icon: 'i-ri-facebook-circle-fill',
prefix: 'facebook.com/',
},
{ key: 'x', label: 'X', icon: 'i-ri-twitter-x-fill', prefix: 'x.com/' },
{
key: 'instagram',
label: 'Instagram',
icon: 'i-ri-instagram-fill',
prefix: 'instagram.com/',
},
{
key: 'linkedin',
label: 'LinkedIn',
icon: 'i-ri-linkedin-box-fill',
prefix: 'linkedin.com/',
},
{
key: 'youtube',
label: 'YouTube',
icon: 'i-ri-youtube-fill',
prefix: 'youtube.com/',
},
{
key: 'tiktok',
label: 'TikTok',
icon: 'i-ri-tiktok-fill',
prefix: 'tiktok.com/',
},
{
key: 'github',
label: 'GitHub',
icon: 'i-ri-github-fill',
prefix: 'github.com/',
},
{
key: 'whatsapp',
label: 'WhatsApp',
icon: 'i-ri-whatsapp-fill',
prefix: 'wa.me/',
},
];
const portalConfig = computed(() => props.activePortal?.config || {});
const state = reactive({
layout: PORTAL_LAYOUTS.CLASSIC,
socialProfiles: {},
});
const visiblePlatforms = ref([]);
const showAddMenu = ref(false);
let originalSnapshot = '';
const platformByKey = key => SOCIAL_PLATFORMS.find(p => p.key === key);
const trimmedHandle = key => (state.socialProfiles[key] || '').trim();
const buildSocialProfiles = () =>
visiblePlatforms.value.reduce((acc, key) => {
const handle = trimmedHandle(key);
if (handle) acc[key] = handle;
return acc;
}, {});
const snapshot = () =>
JSON.stringify({ layout: state.layout, social: buildSocialProfiles() });
const resetFromPortal = () => {
const savedProfiles = portalConfig.value.social_profiles || {};
state.layout = portalConfig.value.layout || PORTAL_LAYOUTS.CLASSIC;
state.socialProfiles = SOCIAL_PLATFORMS.reduce((acc, { key }) => {
acc[key] = savedProfiles[key] || '';
return acc;
}, {});
visiblePlatforms.value = SOCIAL_PLATFORMS.map(p => p.key).filter(key =>
(savedProfiles[key] || '').trim()
);
originalSnapshot = snapshot();
};
watch(() => props.activePortal, resetFromPortal, {
immediate: true,
deep: true,
});
const hasChanges = computed(() => snapshot() !== originalSnapshot);
const visiblePlatformDetails = computed(() =>
visiblePlatforms.value.map(platformByKey)
);
const addablePlatforms = computed(() =>
SOCIAL_PLATFORMS.filter(p => !visiblePlatforms.value.includes(p.key)).map(
p => ({ label: p.label, value: p.key, action: p.key, icon: p.icon })
)
);
const addPlatform = ({ value }) => {
if (!visiblePlatforms.value.includes(value)) {
visiblePlatforms.value.push(value);
}
showAddMenu.value = false;
};
const removePlatform = key => {
visiblePlatforms.value = visiblePlatforms.value.filter(k => k !== key);
state.socialProfiles[key] = '';
};
const handleSave = () => {
emit('updatePortalConfiguration', {
id: props.activePortal.id,
slug: props.activePortal.slug,
config: {
layout: state.layout,
social_profiles: buildSocialProfiles(),
},
});
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.HEADER') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.DESCRIPTION') }}
</span>
</div>
<section class="flex flex-col gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-n-slate-11">
<RadioCard
:id="PORTAL_LAYOUTS.CLASSIC"
:is-active="state.layout === PORTAL_LAYOUTS.CLASSIC"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="classicLayoutPreview" />
</div>
</RadioCard>
<RadioCard
:id="PORTAL_LAYOUTS.DOCUMENTATION"
beta
:is-active="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="documentationLayoutPreview" />
</div>
</RadioCard>
</div>
</section>
<section
v-if="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1">
<h6 class="text-sm font-medium text-n-slate-12">
{{
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.HEADER')
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.DESCRIPTION'
)
}}
</span>
</div>
<div
v-for="platform in visiblePlatformDetails"
:key="platform.key"
class="flex items-center h-10 gap-1.5 px-3 rounded-lg outline outline-1 outline-n-weak focus-within:outline-n-brand"
>
<Icon :icon="platform.icon" class="size-4 shrink-0 text-n-slate-11" />
<span class="text-sm shrink-0 text-n-slate-10">{{
platform.prefix
}}</span>
<input
v-model="state.socialProfiles[platform.key]"
type="text"
class="flex-1 min-w-0 text-sm bg-transparent outline-none reset-base text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.PLACEHOLDER'
)
"
/>
<Button
icon="i-lucide-x"
color="slate"
variant="ghost"
size="xs"
:aria-label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.REMOVE')
"
@click="removePlatform(platform.key)"
/>
</div>
<div
v-if="addablePlatforms.length"
v-on-clickaway="() => (showAddMenu = false)"
class="relative"
>
<Button
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.ADD')
"
icon="i-lucide-plus"
color="slate"
variant="faded"
size="sm"
@click="showAddMenu = !showAddMenu"
/>
<DropdownMenu
v-if="showAddMenu"
:menu-items="addablePlatforms"
class="mt-1 w-52 top-full ltr:left-0 rtl:right-0"
@action="addPlatform"
/>
</div>
</section>
<div class="flex justify-end">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SAVE')"
:disabled="!hasChanges || isFetching"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -7,6 +7,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -102,6 +103,12 @@ const handleDeletePortal = () => {
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-n-weak" />
<PortalLayoutContentSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="14" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="14" y="22" width="64" height="5" rx="1" fill="currentColor" opacity="0.32"/>
<rect x="14" y="32" width="128" height="9" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="14" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="14" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="6" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="0" y="14" width="50" height="106" fill="currentColor" opacity="0.07"/>
<rect x="6" y="22" width="38" height="4" rx="1" fill="currentColor" opacity="0.25"/>
<rect x="6" y="32" width="30" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="40" width="35" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="48" width="28" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="56" width="32" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="60" y="25" width="80" height="6" rx="1" fill="currentColor" opacity="0.35"/>
<rect x="60" y="38" width="120" height="8" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="60" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="60" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import FormattedContent from './Text/FormattedContent.vue';
import { useI18n } from 'vue-i18n';
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import { useMessageContext } from '../provider.js';
@@ -41,7 +42,8 @@ const starRatingValue = computed(() => {
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<FormattedContent v-if="content" :content="content" />
<h4 v-else>{{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<dl v-if="isRatingSubmitted" class="mt-4">
<dt class="text-n-slate-11 italic">
{{ t('CONVERSATION.RATING_TITLE') }}
@@ -1,4 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
@@ -30,10 +31,16 @@ const props = defineProps({
type: String,
default: '',
},
beta: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => {
if (!props.isActive && !props.disabled) {
emit('select', props.id);
@@ -42,14 +49,14 @@ const handleChange = () => {
</script>
<template>
<div
class="cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
<label
:for="id"
class="rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6 focus-within:has-[:focus-visible]:ring-2 focus-within:has-[:focus-visible]:ring-n-strong"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]"
@click="handleChange"
>
<div class="flex flex-col gap-2 items-start">
<div class="flex items-center justify-between w-full gap-3">
@@ -58,6 +65,7 @@ const handleChange = () => {
{{ label }}
</h3>
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
<Label v-if="beta" :label="t('GENERAL.BETA')" color="blue" compact />
</div>
<input
:id="`${id}`"
@@ -66,7 +74,7 @@ const handleChange = () => {
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
class="shadow cursor-pointer grid place-items-center border-2 border-n-strong appearance-none rounded-full w-5 h-5 checked:bg-n-brand before:content-[''] before:bg-n-brand before:border-4 before:rounded-full before:border-n-strong checked:before:w-[18px] checked:before:h-[18px] checked:border checked:border-n-brand"
@change="handleChange"
/>
</div>
@@ -75,5 +83,5 @@ const handleChange = () => {
</p>
<slot />
</div>
</div>
</label>
</template>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: {
@@ -17,6 +18,10 @@ const props = defineProps({
type: Object,
required: true,
},
badgeCount: {
type: [Number, String],
default: 0,
},
});
const reauthorizationRequired = computed(() => {
@@ -29,6 +34,7 @@ const reauthorizationRequired = computed(() => {
<ChannelIcon :inbox="inbox" class="size-4" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
<div
v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
@@ -1,5 +1,5 @@
<script setup>
import { h, ref, computed, onMounted } from 'vue';
import { h, ref, computed, onMounted, watch } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -61,6 +61,24 @@ const hasAdvancedAssignment = computed(() => {
);
});
const hasConversationUnreadCounts = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
if (!isEnabled) {
store.dispatch('conversationUnreadCounts/clear');
return;
}
store.dispatch('conversationUnreadCounts/get');
};
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -157,6 +175,15 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const getInboxUnreadCount = useMapGetter(
'conversationUnreadCounts/getInboxUnreadCount'
);
const getLabelUnreadCount = useMapGetter(
'conversationUnreadCounts/getLabelUnreadCount'
);
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -173,6 +200,10 @@ onMounted(() => {
store.dispatch('customViews/get', 'contact');
});
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
immediate: true,
});
const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
@@ -270,6 +301,7 @@ const menuItems = computed(() => {
children: teams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
badgeCount: getTeamUnreadCount.value(team.id),
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
@@ -281,6 +313,7 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
badgeCount: getInboxUnreadCount.value(inbox.id),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
@@ -288,6 +321,7 @@ const menuItems = computed(() => {
label: leafProps.label,
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
}),
})),
},
@@ -299,6 +333,7 @@ const menuItems = computed(() => {
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
badgeCount: getLabelUnreadCount.value(label.id),
icon: h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
@@ -5,6 +5,7 @@ import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -166,6 +167,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
<SidebarUnreadBadge :count="subChild.badgeCount" />
</button>
</li>
</ul>
@@ -188,6 +190,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
<SidebarUnreadBadge :count="child.badgeCount" />
</button>
</li>
</template>
@@ -3,6 +3,7 @@ import { isVNode, computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import Policy from 'dashboard/components/policy.vue';
import { useSidebarContext } from './provider';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -10,6 +11,7 @@ const props = defineProps({
icon: { type: [String, Object], default: null },
active: { type: Boolean, default: false },
component: { type: Function, default: null },
badgeCount: { type: [Number, String], default: 0 },
});
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
@@ -39,15 +41,14 @@ const shouldRenderComponent = computed(() => {
<component
:is="component"
v-if="shouldRenderComponent"
:label
:icon
:active
v-bind="{ label, icon, active, badgeCount }"
/>
<template v-else>
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
<Icon :icon="icon" class="size-4 inline-block" />
</span>
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
</template>
</component>
</Policy>
@@ -0,0 +1,27 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
count: { type: [Number, String], default: 0 },
});
const normalizedCount = computed(() => {
const count = Number(props.count);
return Number.isFinite(count) && count > 0 ? count : 0;
});
const displayCount = computed(() =>
normalizedCount.value > 99 ? '99+' : String(normalizedCount.value)
);
</script>
<template>
<span
v-if="normalizedCount > 0"
data-test-id="sidebar-unread-badge"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-brand px-1 text-xxs font-medium leading-3 text-white flex-shrink-0"
>
{{ displayCount }}
</span>
<span v-else class="hidden" />
</template>
@@ -0,0 +1,38 @@
import { mount } from '@vue/test-utils';
import ChannelLeaf from '../ChannelLeaf.vue';
const mountChannelLeaf = props =>
mount(ChannelLeaf, {
props: {
label: 'Website',
inbox: { reauthorization_required: false },
...props,
},
global: {
mocks: {
$t: key => key,
},
stubs: {
ChannelIcon: true,
Icon: true,
},
},
});
describe('ChannelLeaf', () => {
it('renders unread badge when count is present', () => {
const wrapper = mountChannelLeaf({ badgeCount: 3 });
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe('3');
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountChannelLeaf({ badgeCount: 0 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
false
);
});
});
@@ -0,0 +1,76 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
vi.mock('../provider', () => ({
useSidebarContext: () => ({
resolvePermissions: () => [],
resolveFeatureFlag: () => '',
}),
}));
const PolicyStub = {
props: ['as', 'permissions', 'featureFlag'],
template: '<li><slot /></li>',
};
const RouterLinkStub = {
props: ['to'],
template: '<a><slot /></a>',
};
const mountLeaf = props =>
mount(SidebarGroupLeaf, {
props: {
label: 'Support',
to: '/support',
...props,
},
global: {
stubs: {
Icon: true,
Policy: PolicyStub,
RouterLink: RouterLinkStub,
},
},
});
describe('SidebarGroupLeaf', () => {
it('renders unread badge when count is present', () => {
const wrapper = mountLeaf({ badgeCount: 7 });
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe('7');
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountLeaf({ badgeCount: 0 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
false
);
});
it('caps large unread counts', () => {
const wrapper = mountLeaf({ badgeCount: 120 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').text()).toBe(
'99+'
);
});
it('passes unread count to custom leaf components', () => {
const wrapper = mountLeaf({
badgeCount: 4,
component: leafProps =>
h(
'span',
{ 'data-test-id': 'custom-leaf-count' },
leafProps.badgeCount
),
});
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
});
});
@@ -7,6 +7,7 @@ import {
ArticleMarkdownTransformer,
EditorState,
Selection,
imageResizeView,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -235,6 +236,7 @@ export default {
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
editorView.dispatch(tr.scrollIntoView());
},
imageUpload: () => this.openFileBrowser(),
};
const command = commandMap[actionKey];
@@ -332,6 +334,9 @@ export default {
createEditorView() {
editorView = new EditorView(this.$refs.editor, {
state: state,
nodeViews: {
image: imageResizeView,
},
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
@@ -60,6 +60,12 @@ const EDITOR_ACTIONS = [
icon: 'i-lucide-table',
menuKey: 'insertTable',
},
{
value: 'imageUpload',
labelKey: 'SLASH_COMMANDS.IMAGE',
icon: 'i-lucide-image',
menuKey: 'imageUpload',
},
{
value: 'strike',
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
+1
View File
@@ -46,6 +46,7 @@ export const FEATURE_FLAGS = {
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
};
export const PREMIUM_FEATURES = [
@@ -4,14 +4,18 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
const { websocketURL = '' } = window.chatwootConfig || {};
super(app, pubsubToken, websocketURL);
this.CancelTyping = [];
this.lastUnreadCountsFetchAt = null;
this.unreadCountsFetchTimer = null;
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
@@ -32,6 +36,8 @@ class ActionCableConnector extends BaseActionCableConnector {
'notification.updated': this.onNotificationUpdated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'conversation.unread_count_changed':
this.onConversationUnreadCountChanged,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
@@ -120,6 +126,56 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
onConversationUnreadCountChanged = () => {
this.throttledFetchConversationUnreadCounts();
};
throttledFetchConversationUnreadCounts = () => {
const now = Date.now();
const elapsedTime = now - this.lastUnreadCountsFetchAt;
if (
this.lastUnreadCountsFetchAt === null ||
elapsedTime >= UNREAD_COUNTS_REFETCH_THROTTLE_MS
) {
this.clearUnreadCountsFetchTimer();
this.fetchConversationUnreadCounts();
return;
}
if (this.unreadCountsFetchTimer) return;
this.unreadCountsFetchTimer = setTimeout(() => {
this.unreadCountsFetchTimer = null;
this.fetchConversationUnreadCounts();
}, UNREAD_COUNTS_REFETCH_THROTTLE_MS - elapsedTime);
};
clearUnreadCountsFetchTimer = () => {
if (!this.unreadCountsFetchTimer) return;
clearTimeout(this.unreadCountsFetchTimer);
this.unreadCountsFetchTimer = null;
};
fetchConversationUnreadCounts = () => {
if (!this.isConversationUnreadCountsEnabled()) return;
this.lastUnreadCountsFetchAt = Date.now();
this.app.$store.dispatch('conversationUnreadCounts/get');
};
isConversationUnreadCountsEnabled = () => {
const accountId = this.app.$store.getters.getCurrentAccountId;
const isFeatureEnabled =
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
return isFeatureEnabled?.(
accountId,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
@@ -1,4 +1,4 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('shared/helpers/mitt', () => ({
@@ -30,12 +30,17 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
},
},
};
actionCable = ActionCableConnector.init(store.$store, 'test-token');
});
afterEach(() => {
vi.useRealTimers();
});
describe('copilot event handlers', () => {
it('should register the copilot.message.created event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
@@ -64,4 +69,95 @@ describe('ActionCableConnector - Copilot Tests', () => {
);
});
});
describe('conversation unread count event handlers', () => {
it('should register the conversation.unread_count_changed event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
'conversation.unread_count_changed'
);
expect(actionCable.events['conversation.unread_count_changed']).toBe(
actionCable.onConversationUnreadCountChanged
);
});
it('should refetch unread counts when unread count changes', () => {
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
});
it('does not refetch unread counts when unread count feature is disabled', () => {
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockReturnValue(false);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('should throttle unread count refetches for repeated events', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(4999);
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenLastCalledWith(
'conversationUnreadCounts/get'
);
});
it('clears pending unread count refetch before immediate refetch', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
vi.advanceTimersByTime(1000);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
vi.setSystemTime(new Date('2026-01-01T00:00:06Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(4000);
expect(mockDispatch).toHaveBeenCalledTimes(2);
});
});
});
@@ -63,6 +63,7 @@
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
"TABLE": "Table",
"IMAGE": "Image"
}
}
@@ -866,6 +866,28 @@
},
"EDIT_CONFIGURATION": "Edit configuration"
},
"LAYOUT_CONTENT": {
"HEADER": "Appearance",
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
"LAYOUT": {
"CLASSIC": {
"TITLE": "Classic",
"DESCRIPTION": "A welcoming home page with search and featured topics."
},
"SIDEBAR": {
"TITLE": "Documentation",
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
}
},
"SOCIAL_LINKS": {
"HEADER": "Social links",
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
"PLACEHOLDER": "handle",
"ADD": "Add social link",
"REMOVE": "Remove"
},
"SAVE": "Save changes"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal created successfully",
@@ -13,7 +13,7 @@ defineEmits(['update']);
<template>
<div
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
class="flex flex-col sm:flex-row flex-col items-start gap-4 mt-3 min-w-0"
>
<RadioCard
id="disabled"
@@ -1,63 +0,0 @@
<script setup>
defineProps({
active: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
lightImage: {
type: String,
default: '',
},
darkImage: {
type: String,
default: '',
},
});
</script>
<template>
<button
class="flex flex-col gap-4 w-full h-fit p-4 rounded-md border border-n-weak dark:border-n-weak"
:class="{
'border-n-brand ': active,
}"
>
<div class="flex flex-col gap-2 items-center w-full rounded-t-[5px]">
<div class="grid grid-cols-[1fr_auto] items-center w-full gap-1">
<div class="overflow-hidden text-heading-2 text-n-slate-12 text-start">
<span class="block truncate">{{ title }}</span>
</div>
<input
:checked="active"
type="radio"
:name="`hotkey-${title}`"
class="shadow cursor-pointer grid place-items-center border-2 border-n-strong appearance-none rounded-full w-5 h-5 checked:bg-n-brand before:content-[''] before:bg-n-brand before:border-4 before:rounded-full before:border-n-strong checked:before:w-[18px] checked:before:h-[18px] checked:border checked:border-n-brand"
/>
</div>
<span class="text-n-slate-11 line-clamp-2 text-body-para text-start">
{{ description }}
</span>
</div>
<div>
<img
:src="lightImage"
:alt="`Light themed image for ${title}`"
class="block object-cover w-full dark:hidden"
/>
<img
:src="darkImage"
:alt="`Dark themed image for ${title}`"
class="hidden object-cover w-full dark:block"
/>
</div>
</button>
</template>
@@ -13,7 +13,6 @@ import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import FontSize from './FontSize.vue';
import UserLanguageSelect from './UserLanguageSelect.vue';
import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
@@ -22,6 +21,7 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import Policy from 'dashboard/components/policy.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import {
ROLES,
CONVERSATION_PERMISSIONS,
@@ -36,7 +36,7 @@ export default {
UserProfilePicture,
Policy,
UserBasicDetails,
HotKeyCard,
RadioCard,
ChangePassword,
NotificationPreferences,
AudioNotifications,
@@ -268,26 +268,27 @@ export default {
<div
class="flex flex-col justify-between w-full gap-5 sm:gap-4 sm:flex-row"
>
<button
<RadioCard
v-for="hotKey in hotKeys"
:id="hotKey.key"
:key="hotKey.key"
class="px-0 reset-base w-full sm:flex-1 rounded-xl outline-1 outline"
:class="
isEditorHotKeyEnabled(hotKey.key)
? 'outline-n-brand/30'
: 'outline-n-weak'
"
:label="hotKey.title"
:description="hotKey.description"
:is-active="isEditorHotKeyEnabled(hotKey.key)"
class="sm:flex-1"
@select="toggleHotKey"
>
<HotKeyCard
:key="hotKey.title"
:title="hotKey.title"
:description="hotKey.description"
:light-image="hotKey.lightImage"
:dark-image="hotKey.darkImage"
:active="isEditorHotKeyEnabled(hotKey.key)"
@click="toggleHotKey(hotKey.key)"
<img
:src="hotKey.lightImage"
:alt="`Light themed image for ${hotKey.title}`"
class="block object-cover w-full dark:hidden"
/>
</button>
<img
:src="hotKey.darkImage"
:alt="`Dark themed image for ${hotKey.title}`"
class="hidden object-cover w-full dark:block"
/>
</RadioCard>
</div>
</SectionLayout>
<SectionLayout
+2
View File
@@ -25,6 +25,7 @@ import conversations from './modules/conversations';
import conversationSearch from './modules/conversationSearch';
import conversationStats from './modules/conversationStats';
import conversationTypingStatus from './modules/conversationTypingStatus';
import conversationUnreadCounts from './modules/conversationUnreadCounts';
import conversationWatchers from './modules/conversationWatchers';
import csat from './modules/csat';
import customRole from './modules/customRole';
@@ -88,6 +89,7 @@ export default createStore({
conversationSearch,
conversationStats,
conversationTypingStatus,
conversationUnreadCounts,
conversationWatchers,
csat,
customRole,
@@ -0,0 +1,70 @@
import ConversationAPI from '../../api/conversations';
import types from '../mutation-types';
export const state = {
inboxes: {},
labels: {},
teams: {},
};
const normalizeCounts = counts => {
return Object.entries(counts || {}).reduce((result, [id, count]) => {
const parsedCount = Number(count);
if (Number.isFinite(parsedCount) && parsedCount > 0) {
result[String(id)] = parsedCount;
}
return result;
}, {});
};
export const getters = {
getInboxUnreadCount: $state => inboxId => {
return $state.inboxes[String(inboxId)] || 0;
},
getLabelUnreadCount: $state => labelId => {
return $state.labels[String(labelId)] || 0;
},
getTeamUnreadCount: $state => teamId => {
return $state.teams[String(teamId)] || 0;
},
getInboxUnreadCounts($state) {
return $state.inboxes;
},
getLabelUnreadCounts($state) {
return $state.labels;
},
getTeamUnreadCounts($state) {
return $state.teams;
},
};
export const actions = {
get: async function getUnreadCounts({ commit }) {
try {
const response = await ConversationAPI.getUnreadCounts();
commit(types.SET_CONVERSATION_UNREAD_COUNTS, response.data.payload);
} catch (error) {
// Ignore errors so the sidebar can continue rendering without badges.
}
},
clear({ commit }) {
commit(types.SET_CONVERSATION_UNREAD_COUNTS, {});
},
};
export const mutations = {
[types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) {
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
@@ -0,0 +1,53 @@
import axios from 'axios';
import { actions } from '../../conversationUnreadCounts';
import types from '../../../mutation-types';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
beforeEach(() => {
commit.mockClear();
axios.get.mockReset();
});
describe('#get', () => {
it('commits unread counts when API is successful', async () => {
const payload = {
inboxes: { 1: '2' },
labels: { 3: 4 },
teams: { 5: 6 },
};
axios.get.mockResolvedValue({ data: { payload } });
await actions.get({ commit });
expect(axios.get).toHaveBeenCalledWith(
'/api/v1/conversations/unread_counts'
);
expect(commit.mock.calls).toEqual([
[types.SET_CONVERSATION_UNREAD_COUNTS, payload],
]);
});
it('does not commit when API fails', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.get({ commit });
expect(commit).not.toHaveBeenCalled();
});
});
describe('#clear', () => {
it('clears unread counts', () => {
actions.clear({ commit });
expect(commit).toHaveBeenCalledWith(
types.SET_CONVERSATION_UNREAD_COUNTS,
{}
);
});
});
});
@@ -0,0 +1,51 @@
import { getters } from '../../conversationUnreadCounts';
describe('#getters', () => {
it('returns inbox unread count by id', () => {
const state = {
inboxes: { 1: 2 },
labels: {},
teams: {},
};
expect(getters.getInboxUnreadCount(state)(1)).toBe(2);
expect(getters.getInboxUnreadCount(state)('1')).toBe(2);
expect(getters.getInboxUnreadCount(state)(2)).toBe(0);
});
it('returns label unread count by id', () => {
const state = {
inboxes: {},
labels: { 3: 4 },
teams: {},
};
expect(getters.getLabelUnreadCount(state)(3)).toBe(4);
expect(getters.getLabelUnreadCount(state)('3')).toBe(4);
expect(getters.getLabelUnreadCount(state)(4)).toBe(0);
});
it('returns team unread count by id', () => {
const state = {
inboxes: {},
labels: {},
teams: { 5: 6 },
};
expect(getters.getTeamUnreadCount(state)(5)).toBe(6);
expect(getters.getTeamUnreadCount(state)('5')).toBe(6);
expect(getters.getTeamUnreadCount(state)(6)).toBe(0);
});
it('returns unread count maps', () => {
const state = {
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
};
expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
});
});
@@ -0,0 +1,48 @@
import types from '../../../mutation-types';
import { mutations } from '../../conversationUnreadCounts';
describe('#mutations', () => {
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
it('normalizes unread count payload', () => {
const state = { inboxes: {}, labels: {}, teams: {} };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
inboxes: {
1: '2',
2: 0,
3: 'invalid',
},
labels: {
4: 5,
5: -1,
},
teams: {
6: '7',
7: 0,
},
});
expect(state).toEqual({
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
});
});
it('clears counts when payload is empty', () => {
const state = {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
expect(state).toEqual({
inboxes: {},
labels: {},
teams: {},
});
});
});
});
@@ -187,6 +187,9 @@ export default {
CLEAR_SELECTED_CONVERSATION_IDS: 'CLEAR_SELECTED_CONVERSATION_IDS',
REMOVE_SELECTED_CONVERSATION_IDS: 'REMOVE_SELECTED_CONVERSATION_IDS',
// Conversation Unread Counts
SET_CONVERSATION_UNREAD_COUNTS: 'SET_CONVERSATION_UNREAD_COUNTS',
// Reports
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
+3
View File
@@ -1,8 +1,10 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import store from '../survey/store';
import i18nMessages from '../survey/i18n';
import App from '../survey/App.vue';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
const app = createApp(App);
const i18n = createI18n({
@@ -12,6 +14,7 @@ const i18n = createI18n({
app.use(i18n);
app.use(store);
app.use(VueDOMPurifyHTML, domPurifyConfig);
window.onload = () => {
window.WOOT_SURVEY = app.mount('#app');
+13 -4
View File
@@ -5,9 +5,18 @@
@import 'widget/assets/scss/reset';
@import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'shared/assets/fonts/inter';
@import 'dashboard/assets/scss/next-colors';
html,
body {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
letter-spacing: 0.2px;
}
.font-default {
font-family:
'InterDisplay',
-apple-system,
@@ -23,10 +32,6 @@ body {
'Apple Color Emoji',
'Segoe UI Emoji',
'Noto Color Emoji';
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
letter-spacing: 0.2px;
}
// Taking these utils from tailwind 3.x.x, need to remove once we upgrade
@@ -49,3 +54,7 @@ body {
}
}
}
.turbolinks-progress-bar {
background-color: var(--dynamic-portal-color);
}
@@ -9,6 +9,17 @@ export default {
PublicSearchInput,
SearchSuggestions,
},
props: {
size: {
type: String,
default: 'default',
validator: value => ['small', 'default'].includes(value),
},
showKbd: {
type: Boolean,
default: false,
},
},
emits: ['input', 'blur'],
data() {
return {
@@ -36,6 +47,13 @@ export default {
const { searchTranslations = {} } = window.portalConfig;
return searchTranslations;
},
kbdLabel() {
if (!this.showKbd) return '';
const isMac = /Mac|iPhone|iPad|iPod/i.test(
navigator.platform || navigator.userAgent
);
return isMac ? '⌘ K' : 'Ctrl K';
},
},
watch: {
@@ -44,7 +62,12 @@ export default {
},
},
mounted() {
if (this.showKbd) document.addEventListener('keydown', this.onKeydown);
},
unmounted() {
if (this.showKbd) document.removeEventListener('keydown', this.onKeydown);
clearTimeout(this.typingTimer);
},
@@ -83,6 +106,16 @@ export default {
clearSearchTerm() {
this.searchTerm = '';
},
onKeydown(e) {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
if (this.$refs.searchInput) this.$refs.searchInput.focusInput();
}
if (e.key === 'Escape') {
this.closeSearch();
if (this.$refs.searchInput) this.$refs.searchInput.blurInput();
}
},
async fetchArticlesByQuery() {
const query = this.normalizedSearchTerm;
if (!query) {
@@ -112,8 +145,11 @@ export default {
<template>
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
<PublicSearchInput
ref="searchInput"
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
:size="size"
:kbd="kbdLabel"
@update:search-term="onUpdateSearchTerm"
@focus="openSearch"
/>
@@ -1,8 +1,8 @@
<script setup>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { ref } from 'vue';
import { ref, computed } from 'vue';
defineProps({
const props = defineProps({
searchTerm: {
type: [String, Number],
default: '',
@@ -11,10 +11,33 @@ defineProps({
type: String,
default: '',
},
size: {
type: String,
default: 'default', // 'small' | 'default'
validator: value => ['small', 'default'].includes(value),
},
kbd: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:searchTerm', 'focus', 'blur']);
const isFocused = ref(false);
const inputRef = ref(null);
const sizeClasses = computed(() => {
if (props.size === 'small') {
return {
wrapper: 'h-9 px-3 py-1',
input: 'text-sm py-0',
};
}
return {
wrapper: 'h-12 px-5 py-2',
input: 'text-base py-2',
};
});
const onChange = e => {
emit('update:searchTerm', e.target.value);
@@ -29,26 +52,46 @@ const onBlur = e => {
isFocused.value = false;
emit('blur', e.target.value);
};
const focusInput = () => {
if (inputRef.value) inputRef.value.focus();
};
const blurInput = () => {
if (inputRef.value) inputRef.value.blur();
};
defineExpose({ focusInput, blurInput });
</script>
<template>
<div
class="w-full flex items-center rounded-lg border-solid border h-12 bg-white dark:bg-slate-900 px-5 py-2 text-slate-600 dark:text-slate-200"
:class="{
'shadow border-woot-100 dark:border-woot-700': isFocused,
'border-slate-50 dark:border-slate-800 shadow-sm': !isFocused,
}"
class="w-full flex items-center gap-2 rounded-lg border-solid border bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200 transition-all duration-500 ease-out"
:class="[
sizeClasses.wrapper,
isFocused
? 'shadow border-n-portal ring-4 ring-n-portal-soft'
: 'border-slate-50 dark:border-slate-800 shadow-sm ring-0 ring-transparent',
]"
>
<FluentIcon icon="search" />
<input
ref="inputRef"
:value="searchTerm"
type="text"
class="w-full focus:outline-none text-base h-full bg-white dark:bg-slate-900 px-2 py-2 text-slate-700 dark:text-slate-100 placeholder-slate-500"
class="flex-1 min-w-0 focus:outline-none h-full bg-transparent px-2 text-slate-700 dark:text-slate-100 placeholder-slate-500"
:class="sizeClasses.input"
:placeholder="searchPlaceholder"
role="search"
@input="onChange"
@focus="onFocus"
@blur="onBlur"
/>
<kbd
v-if="kbd"
class="shrink-0 inline-flex items-center text-xs font-medium text-n-slate-11 bg-n-alpha-2 border border-solid border-n-weak rounded px-1.5 py-0.5 font-inter"
>
{{ kbd }}
</kbd>
</div>
</template>
@@ -29,7 +29,7 @@ export default {
setup(props) {
const selectedIndex = ref(-1);
const portalSearchSuggestionsRef = ref(null);
const { highlightContent } = useMessageFormatter();
const { highlightContent, getPlainText } = useMessageFormatter();
const adjustScroll = () => {
nextTick(() => {
portalSearchSuggestionsRef.value.scrollTop = 102 * selectedIndex.value;
@@ -37,9 +37,7 @@ export default {
};
const isSearchItemActive = index => {
return index === selectedIndex.value
? 'bg-slate-25 dark:bg-slate-800'
: 'bg-white dark:bg-slate-900';
return index === selectedIndex.value ? 'bg-n-portal-soft' : '';
};
useKeyboardNavigableList({
@@ -53,6 +51,7 @@ export default {
portalSearchSuggestionsRef,
isSearchItemActive,
highlightContent,
getPlainText,
};
},
@@ -70,7 +69,7 @@ export default {
return this.highlightContent(
content,
this.searchTerm,
'bg-slate-100 dark:bg-slate-700 font-semibold text-slate-600 dark:text-slate-200'
'bg-n-portal-soft text-n-portal font-semibold rounded-sm px-1'
);
},
},
@@ -80,46 +79,46 @@ export default {
<template>
<div
ref="portalSearchSuggestionsRef"
class="p-5 mt-2 overflow-y-auto text-sm bg-white border border-solid rounded-lg shadow-xl hover:shadow-lg dark:bg-slate-900 max-h-96 scroll-py-2 text-slate-700 dark:text-slate-100 border-slate-50 dark:border-slate-800"
class="mt-2 overflow-y-auto bg-white dark:bg-n-slate-2 border border-solid border-n-weak rounded-xl shadow-2xl max-h-96 p-2"
>
<div
v-if="isLoading"
class="text-sm font-medium text-slate-400 dark:text-slate-700"
>
<div v-if="isLoading" class="px-3 py-6 text-sm text-n-slate-11 text-center">
{{ loadingPlaceholder }}
</div>
<ul
v-if="shouldShowResults"
class="flex flex-col gap-4 text-sm bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100"
role="listbox"
>
<ul v-if="shouldShowResults" class="flex flex-col gap-0.5" role="listbox">
<li
v-for="(article, index) in items"
:id="article.id"
:key="article.id"
class="flex items-center p-4 border border-solid rounded-lg cursor-pointer select-none group hover:bg-slate-25 dark:hover:bg-slate-800 border-slate-100 dark:border-slate-800"
class="rounded-md cursor-pointer select-none group transition-colors hover:bg-n-alpha-2"
:class="isSearchItemActive(index)"
role="option"
tabindex="-1"
@mouse-enter="onHover(index)"
@mouse-leave="onHover(-1)"
>
<a class="flex flex-col gap-1 overflow-y-hidden" :href="article.link">
<a
class="flex items-start gap-3 px-3 py-2.5 overflow-hidden"
:href="article.link"
>
<span
v-dompurify-html="prepareContent(article.title)"
class="flex-auto w-full overflow-hidden text-base font-semibold leading-6 truncate text-ellipsis whitespace-nowrap"
/>
<div
v-dompurify-html="prepareContent(article.content)"
class="overflow-hidden text-sm line-clamp-2 text-ellipsis whitespace-nowrap text-slate-600 dark:text-slate-300"
class="i-lucide-file-text size-4 mt-0.5 flex-shrink-0 text-n-slate-10 group-hover:text-n-slate-11"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 flex flex-col gap-1">
<span
v-dompurify-html="prepareContent(getPlainText(article.title))"
class="block text-base font-520 text-n-slate-12 truncate"
/>
<span
v-dompurify-html="prepareContent(article.content)"
class="block text-sm text-n-slate-11 line-clamp-1"
/>
</span>
</a>
</li>
</ul>
<div
v-if="showEmptyResults"
class="text-sm font-medium text-slate-400 dark:text-slate-700"
class="px-3 py-6 text-sm text-n-slate-11 text-center"
>
{{ emptyPlaceholder }}
</div>
@@ -0,0 +1,136 @@
<script>
import { directive as onClickaway } from 'vue3-click-away';
const OPTION_DEFS = [
{ value: 'system', icon: 'i-lucide-monitor', fallback: 'System' },
{ value: 'light', icon: 'i-lucide-sun', fallback: 'Light' },
{ value: 'dark', icon: 'i-lucide-moon', fallback: 'Dark' },
];
const readLabels = () => {
const el = document.querySelector('#sidebar-theme-toggle');
const data = (el && el.dataset) || {};
return {
trigger: data.labelTrigger || 'Appearance',
options: OPTION_DEFS.map(opt => ({
...opt,
label:
data[
`label${opt.value.charAt(0).toUpperCase()}${opt.value.slice(1)}`
] || opt.fallback,
})),
};
};
export default {
directives: { onClickaway },
data() {
const labels = readLabels();
return {
mode: 'system',
systemPrefersDark: false,
open: false,
options: labels.options,
triggerLabel: labels.trigger,
};
},
computed: {
triggerIcon() {
const opt = this.options.find(o => o.value === this.mode);
return opt ? opt.icon : 'i-lucide-monitor';
},
isDarkActive() {
if (this.mode === 'dark') return true;
if (this.mode === 'light') return false;
return this.systemPrefersDark;
},
},
mounted() {
try {
const stored = localStorage.theme;
this.mode = stored === 'dark' || stored === 'light' ? stored : 'system';
} catch (e) {
this.mode = 'system';
}
if (window.matchMedia) {
this.media = window.matchMedia('(prefers-color-scheme: dark)');
this.systemPrefersDark = this.media.matches;
this.media.addEventListener('change', this.onSystemChange);
}
this.applyTheme();
},
unmounted() {
if (this.media)
this.media.removeEventListener('change', this.onSystemChange);
},
methods: {
onSystemChange(e) {
this.systemPrefersDark = e.matches;
if (this.mode === 'system') this.applyTheme();
},
applyTheme() {
const dark = this.isDarkActive;
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(dark ? 'dark' : 'light');
},
select(value) {
this.mode = value;
try {
if (value === 'system') localStorage.removeItem('theme');
else localStorage.theme = value;
} catch (e) {
/* no-op */
}
this.applyTheme();
this.open = false;
},
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
},
},
};
</script>
<template>
<div v-on-clickaway="close" class="relative">
<button
type="button"
class="flex items-center justify-center w-9 h-9 text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 rounded-lg transition"
:aria-label="triggerLabel"
:aria-expanded="open"
@click="toggle"
>
<span class="size-4" :class="[triggerIcon]" aria-hidden="true" />
</button>
<div
v-if="open"
class="absolute end-0 top-full mt-2 bg-n-slate-1 border border-solid border-n-weak rounded-lg shadow-lg p-1 min-w-36 z-50 flex flex-col gap-0.5"
>
<button
v-for="opt in options"
:key="opt.value"
type="button"
class="w-full flex items-center justify-between gap-2.5 px-2.5 py-2 text-sm rounded-md transition"
:class="[
mode === opt.value
? 'bg-n-portal-soft text-n-portal'
: 'text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12',
]"
@click="select(opt.value)"
>
<span class="flex items-center gap-2">
<span class="size-4" :class="[opt.icon]" aria-hidden="true" />
{{ opt.label }}
</span>
<span
v-if="mode === opt.value"
class="i-lucide-check size-3.5"
aria-hidden="true"
/>
</button>
</div>
</div>
</template>
@@ -79,13 +79,13 @@ export default {
},
elementBorderStyles(el) {
if (this.isElementActive(el)) {
return 'border-slate-400 dark:border-slate-50 transition-colors duration-200';
return 'border-n-portal transition-colors duration-200';
}
return 'border-slate-100 dark:border-slate-800';
},
elementTextStyles(el) {
if (this.isElementActive(el)) {
return 'text-slate-900 dark:text-slate-25 transition-colors duration-200';
return 'text-n-portal transition-colors duration-200';
}
return 'text-slate-700 dark:text-slate-100';
},
@@ -113,7 +113,7 @@ export default {
<a
:href="`#${element.slug}`"
data-turbolinks="false"
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
class="font-medium text-sm cursor-pointer"
:class="elementTextStyles(element)"
>
{{ element.title }}
+39 -6
View File
@@ -7,6 +7,7 @@ import { isSameHost } from '@chatwoot/utils';
import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue';
import SidebarThemeToggle from './components/SidebarThemeToggle.vue';
import { initializeTheme } from './portalThemeHelper.js';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
@@ -78,18 +79,23 @@ export const InitializationHelpers = {
},
initializeSearch: () => {
const isSearchContainerAvailable = document.querySelector('#search-wrap');
if (isSearchContainerAvailable) {
['#search-wrap', '#search-wrap-hero'].forEach(selector => {
const mountPoint = document.querySelector(selector);
if (!mountPoint) return;
const size = mountPoint.dataset.size || 'default';
const showKbd = !!mountPoint.dataset.kbd;
// eslint-disable-next-line vue/one-component-per-file
const app = createApp({
components: { PublicArticleSearch },
template: '<PublicArticleSearch />',
data() {
return { size, showKbd };
},
template: '<PublicArticleSearch :size="size" :show-kbd="showKbd" />',
});
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.directive('on-clickaway', onClickaway);
app.mount('#search-wrap');
}
app.mount(selector);
});
},
initializeTableOfContents: () => {
@@ -109,6 +115,31 @@ export const InitializationHelpers = {
}
},
initializeSidebarThemeToggle: () => {
const mountPoint = document.querySelector('#sidebar-theme-toggle');
if (mountPoint) {
// eslint-disable-next-line vue/one-component-per-file
const app = createApp({
components: { SidebarThemeToggle },
template: '<sidebar-theme-toggle />',
});
app.directive('on-clickaway', onClickaway);
app.mount('#sidebar-theme-toggle');
}
},
initializeDetailsClickAway: () => {
document.addEventListener('click', event => {
document
.querySelectorAll('details[data-close-on-clickaway][open]')
.forEach(details => {
if (!details.contains(event.target)) {
details.removeAttribute('open');
}
});
});
},
appendPlainParamToURLs: () => {
[...document.getElementsByTagName('a')].forEach(aTagElement => {
if (aTagElement.href && aTagElement.href.includes('/hc/')) {
@@ -143,6 +174,8 @@ export const InitializationHelpers = {
InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initializeSearch();
InitializationHelpers.initializeTableOfContents();
InitializationHelpers.initializeSidebarThemeToggle();
InitializationHelpers.initializeDetailsClickAway();
}
},
@@ -9,6 +9,7 @@ vi.mock('dashboard/composables/useKeyboardNavigableList', () => ({
vi.mock('shared/composables/useMessageFormatter', () => ({
useMessageFormatter: () => ({
highlightContent: content => content,
getPlainText: content => content,
}),
}));
@@ -4,6 +4,7 @@ import Spinner from 'shared/components/Spinner.vue';
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import StarRating from 'shared/components/StarRating.vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
@@ -30,6 +31,10 @@ export default {
default: '',
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return { formatMessage };
},
data() {
return {
email: '',
@@ -61,6 +66,9 @@ export default {
? this.$t('CSAT.SUBMITTED_TITLE')
: this.message || this.$t('CSAT.TITLE');
},
formattedTitle() {
return this.formatMessage(this.title, false);
},
isEmojiType() {
return this.displayType === CSAT_DISPLAY_TYPES.EMOJI;
},
@@ -128,9 +136,10 @@ export default {
class="customer-satisfaction w-full bg-n-background dark:bg-n-solid-3 shadow-[0_0.25rem_6px_rgba(50,50,93,0.08),0_1px_3px_rgba(0,0,0,0.05)] ltr:rounded-bl-[0.25rem] rtl:rounded-br-[0.25rem] rounded-lg inline-block leading-[1.5] mt-1 border-t-2 border-t-n-brand border-solid"
:style="{ borderColor: widgetColor }"
>
<h6 class="text-n-slate-12 text-sm font-medium pt-5 px-2.5 text-center">
{{ title }}
</h6>
<h6
v-dompurify-html="formattedTitle"
class="text-n-slate-12 text-sm font-medium pt-5 px-2.5 text-center prose prose-bubble"
/>
<div v-if="isEmojiType" class="ratings flex justify-around py-5 px-4">
<button
v-for="rating in ratings"
+12 -5
View File
@@ -6,6 +6,7 @@ import Rating from 'survey/components/Rating.vue';
import Feedback from 'survey/components/Feedback.vue';
import Banner from 'survey/components/Banner.vue';
import StarRating from 'shared/components/StarRating.vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { getSurveyDetails, updateSurvey } from 'survey/api/survey';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
@@ -20,6 +21,10 @@ export default {
Feedback,
StarRating,
},
setup() {
const { formatMessage } = useMessageFormatter();
return { formatMessage };
},
data() {
return {
surveyDetails: null,
@@ -77,6 +82,9 @@ export default {
}
return this.$t('SURVEY.RATING.SUCCESS_MESSAGE');
},
formattedMessageContent() {
return this.formatMessage(this.messageContent, false);
},
},
async mounted() {
this.getSurveyDetails();
@@ -167,12 +175,11 @@ export default {
>
<div class="w-full px-12 pt-12 pb-6 m-auto my-0">
<img v-if="logo" :src="logo" alt="Chatwoot logo" class="mb-6 logo" />
<p
<div
v-if="!isRatingSubmitted"
class="mb-8 text-lg leading-relaxed text-n-slate-12"
>
{{ messageContent }}
</p>
v-dompurify-html="formattedMessageContent"
class="mb-8 text-lg leading-relaxed text-n-slate-12 prose prose-bubble"
/>
<Banner
v-if="shouldShowBanner"
:show-success="shouldShowSuccessMessage"
+34 -2
View File
@@ -1,21 +1,53 @@
class AutoAssignment::AssignmentJob < ApplicationJob
queue_as :default
def perform(inbox_id:)
IN_FLIGHT_TTL = 5.minutes
# Coalesce per inbox: at most one AssignmentJob per inbox is in-flight
# (queued or running) at any time. The marker carries a token so a job only
# releases its own claim (a newer job may have taken it after a TTL lapse).
def self.enqueue_for_inbox(inbox_id)
key = format(::Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox_id)
token = SecureRandom.uuid
return false unless ::Redis::Alfred.set(key, token, nx: true, ex: IN_FLIGHT_TTL)
return true if perform_later(inbox_id: inbox_id, token: token)
# Enqueue was halted; release our own claim so the inbox isn't gated until the TTL.
::Redis::Alfred.delete_if_equals(key, token)
false
rescue StandardError
# Enqueue raised after we claimed the gate; release our own claim, then re-raise.
::Redis::Alfred.delete_if_equals(key, token)
raise
end
def perform(inbox_id:, token: nil)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
service = AutoAssignment::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment(limit: bulk_assignment_limit)
Rails.logger.info "Assigned #{assigned_count} conversations for inbox #{inbox.id}"
rescue StandardError => e
Rails.logger.error "Bulk assignment failed for inbox #{inbox_id}: #{e.message}"
raise e if Rails.env.test?
ensure
release_in_flight(inbox_id, token)
end
private
# Release the in-flight marker only if we still own it. The atomic
# compare-and-delete ensures a job whose TTL lapsed can't delete a newer
# job's claim. Tokenless (pre-deploy) jobs never claimed a key, so skip.
def release_in_flight(inbox_id, token)
return if token.nil?
key = format(::Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox_id)
::Redis::Alfred.delete_if_equals(key, token)
end
def bulk_assignment_limit
ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i
end
@@ -10,7 +10,7 @@ class AutoAssignment::PeriodicAssignmentJob < ApplicationJob
inboxes.each do |inbox|
next unless inbox.auto_assignment_v2_enabled?
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
end
end
end
+29 -2
View File
@@ -91,10 +91,37 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
# Returns nil for status-only webhooks so they bypass the lock.
def contact_sender_id(params)
value = params.dig(:entry, 0, :changes, 0, :value) || params
message = (value[:messages] || value[:message_echoes])&.first
return contact_sender_id_from_message_echoes(value[:message_echoes]) if value[:message_echoes].present?
contact_sender_id_from_messages(value[:messages], value[:contacts])
end
# Echo payloads are outbound messages from the WhatsApp Business app, so `to`
# points to the contact. Prefer parent BSUID when present so payloads that have
# both regular+parent BSUIDs serialize with parent-BSUID-only payloads.
def contact_sender_id_from_message_echoes(message_echoes)
message = message_echoes&.first
return if message.blank?
message[:to] || message[:from]
[message[:to_parent_user_id], message[:to_user_id], message[:to]].compact_blank.first
end
# Regular inbound payloads are sent by the contact, so `from` points to the
# contact. Prefer parent BSUID when present so payloads that have both
# regular+parent BSUIDs serialize with parent-BSUID-only payloads.
def contact_sender_id_from_messages(messages, contacts)
message = messages&.first
return if message.blank?
contact = contacts&.first || {}
[
message[:from_parent_user_id],
contact[:parent_user_id],
message[:from_user_id],
contact[:user_id],
message[:from]
].compact_blank.first
end
def channel_is_inactive?(channel)
+9
View File
@@ -90,6 +90,15 @@ class ActionCableListener < BaseListener
broadcast(account, tokens, CONVERSATION_UPDATED, conversation.push_event_data)
end
def conversation_unread_count_changed(event)
account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
tokens = user_tokens(account, inbox_members)
broadcast(account, tokens, CONVERSATION_UNREAD_COUNT_CHANGED, {})
end
def conversation_typing_on(event)
conversation = event.data[:conversation]
account = conversation.account
+4 -2
View File
@@ -1,5 +1,6 @@
module MailboxHelper
include MailboxInlineAttachmentHelper
include ::FileTypeHelper
private
@@ -53,11 +54,12 @@ module MailboxHelper
def process_regular_attachments(attachments)
Rails.logger.info "[MailboxHelper] Processing regular attachments for message with ID: #{processed_mail.message_id}"
attachments.each do |mail_attachment|
blob = mail_attachment[:blob]
attachment = @message.attachments.new(
account_id: @conversation.account_id,
file_type: 'file'
file_type: file_type(blob.content_type)
)
attachment.file.attach(mail_attachment[:blob])
attachment.file.attach(blob)
end
end
+10
View File
@@ -109,6 +109,7 @@ class Account < ApplicationRecord
before_validation :validate_limit_keys
after_create_commit :notify_creation
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
after_destroy :remove_account_sequences
def agents
@@ -168,12 +169,21 @@ class Account < ApplicationRecord
Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step
end
def reset_cache_keys
super
clear_unread_conversation_counts_cache
end
private
def notify_creation
Rails.configuration.dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self)
end
def clear_unread_conversation_counts_cache
::Conversations::UnreadCounts::Store.clear_account!(id)
end
trigger.after(:insert).for_each(:row) do
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
end
@@ -15,8 +15,10 @@ module AutoAssignmentHandler
return unless should_run_auto_assignment?
if inbox.auto_assignment_v2_enabled?
# Use new assignment system
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
# Coalesces bursts of triggers per inbox. Fine if the job runs even when the
# surrounding save rolls back: it only scans the inbox's current unassigned
# conversations, so running it for an uncommitted change is harmless.
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
else
# Use legacy assignment system
# If conversation has a team, only consider team members for assignment
+1
View File
@@ -30,6 +30,7 @@ module CacheKeys
update_cache_key_for_account(id, model.name.underscore)
end
::Conversations::UnreadCounts::Store.clear_account!(id)
dispatch_cache_update_event
end
+19
View File
@@ -121,6 +121,8 @@ class Conversation < ApplicationRecord
after_update_commit :execute_after_update_commit_callbacks
after_create_commit :notify_conversation_creation
after_create_commit :load_attributes_created_by_db_triggers
before_destroy :set_unread_count_deletion_data
after_destroy_commit :notify_conversation_deletion
delegate :auto_resolve_after, to: :account
@@ -270,6 +272,12 @@ class Conversation < ApplicationRecord
dispatcher_dispatch(CONVERSATION_CREATED)
end
def notify_conversation_deletion
return if @unread_count_deletion_data.blank?
Rails.configuration.dispatcher.dispatch(CONVERSATION_DELETED, Time.zone.now, conversation_data: @unread_count_deletion_data)
end
def notify_conversation_updation
return unless previous_changes.keys.present? && allowed_keys?
@@ -315,6 +323,17 @@ class Conversation < ApplicationRecord
performed_by: Current.executed_by)
end
def set_unread_count_deletion_data
@unread_count_deletion_data = {
id: id,
account_id: account_id,
inbox_id: inbox_id,
assignee_id: assignee_id,
team_id: team_id,
cached_label_list: cached_label_list
}
end
def conversation_status_changed_to_open?
return false unless open?
# saved_change_to_status? method only works in case of update
+11 -3
View File
@@ -46,7 +46,7 @@ class Portal < ApplicationRecord
scope :active, -> { where(archived: false) }
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token].freeze
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout].freeze
def file_base_data
{
@@ -94,15 +94,23 @@ class Portal < ApplicationRecord
page_title.presence || name
end
def layout
config_value('layout').presence || 'classic'
end
def social_profiles
config_value('social_profiles') || {}
end
private
def config_json_format
self.config = (config || {}).deep_stringify_keys
self.config = persisted_config.merge((config || {}).deep_stringify_keys)
config['allowed_locales'] = allowed_locale_codes
config['default_locale'] = default_locale
config['draft_locales'] = draft_locale_codes
denied_keys = config.keys - CONFIG_JSON_KEYS
errors.add(:cofig, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
errors.add(:config, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
end
@@ -72,15 +72,32 @@ class AutoAssignment::AssignmentService
end
def assign_conversation(conversation, agent)
Current.executed_by = inbox.assignment_policy || inbox
conversation.update!(assignee: agent)
Current.executed_by = nil
return false unless claim_and_assign(conversation, agent)
conversation.reload
rate_limiter = build_rate_limiter(agent)
rate_limiter.track_assignment(conversation)
dispatch_assignment_event(conversation, agent)
true
end
# Atomically claim the row so two bulk runs that overlap (the in-flight gate
# is best-effort and can lapse on TTL) can't both assign the same conversation.
def claim_and_assign(conversation, agent)
Current.executed_by = inbox.assignment_policy || inbox
Conversation.transaction do
locked = inbox.conversations
.where(id: conversation.id, assignee_id: nil)
.lock('FOR UPDATE SKIP LOCKED')
.first
next false unless locked
locked.update!(assignee: agent)
true
end
ensure
Current.executed_by = nil
end
@@ -0,0 +1,30 @@
class ContactInboxSourceIdResolver
pattr_initialize [:inbox!, :source_ids!, :contact_attributes!]
def perform
existing_contact_inbox || create_contact_inbox
end
private
def existing_contact_inbox
normalized_source_ids.each do |source_id|
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
return contact_inbox if contact_inbox
end
nil
end
def create_contact_inbox
::ContactInboxWithContactBuilder.new(
source_id: normalized_source_ids.first,
inbox: inbox,
contact_attributes: contact_attributes
).perform
end
def normalized_source_ids
@normalized_source_ids ||= source_ids.compact_blank.uniq
end
end
@@ -0,0 +1,4 @@
module Conversations::UnreadCounts
READY_TTL = 24.hours.to_i
SET_TTL = 25.hours.to_i
end
@@ -0,0 +1,36 @@
class Conversations::UnreadCounts::BroadcastScope
attr_reader :event
def initialize(event)
@event = event
end
def perform
return [conversation.account, conversation.inbox.members] if conversation.present?
deleted_conversation_scope
end
private
def conversation
event.data[:conversation]
end
def deleted_conversation_scope
conversation_data = event.data[:conversation_data]&.with_indifferent_access
return if conversation_data.blank?
account = Account.find_by(id: conversation_data[:account_id])
return if account.blank?
[account, inbox_members_for(account, conversation_data[:inbox_id])]
end
def inbox_members_for(account, inbox_id)
inbox = account.inboxes.find_by(id: inbox_id)
return User.none if inbox.blank?
inbox.members
end
end
@@ -0,0 +1,75 @@
class Conversations::UnreadCounts::Builder
BATCH_SIZE = 1000
attr_reader :account
def initialize(account)
@account = account
end
def build_base!
store.clear_account!(account.id)
write_memberships(assignment: false)
store.mark_base_ready!(account.id)
end
def build_assignment!
store.clear_assignment!(account.id)
write_memberships(assignment: true)
store.mark_assignment_ready!(account.id)
end
def build_all!
build_base!
build_assignment!
end
private
def write_memberships(assignment:)
unread_conversations.in_batches(of: BATCH_SIZE) do |relation|
columns = %i[id inbox_id assignee_id cached_label_list team_id]
memberships = relation.pluck(*columns).map do |id, inbox_id, assignee_id, cached_label_list, team_id|
{
conversation_id: id,
inbox_id: inbox_id,
assignee_id: assignee_id,
team_id: team_id,
label_ids: label_ids_for(cached_label_list)
}
end
store.add_memberships(account_id: account.id, memberships: memberships, assignment: assignment)
end
end
def unread_conversations
account.conversations
.open
.joins(:messages)
.merge(Message.incoming.reorder(nil))
.where(messages: { account_id: account.id })
.where(unread_since_last_seen_condition)
.distinct
end
def unread_since_last_seen_condition
conversations = Conversation.arel_table
messages = Message.arel_table
conversations[:agent_last_seen_at].eq(nil).or(messages[:created_at].gt(conversations[:agent_last_seen_at]))
end
def label_ids_for(cached_label_list)
label_titles = cached_label_list.to_s.split(',').map(&:strip).compact_blank
labels_by_title.values_at(*label_titles).compact
end
def labels_by_title
@labels_by_title ||= account.labels.pluck(:title, :id).to_h
end
def store
::Conversations::UnreadCounts::Store
end
end
@@ -0,0 +1,200 @@
class Conversations::UnreadCounts::Counter
MANAGE_ALL_PERMISSION = 'conversation_manage'.freeze
UNASSIGNED_PERMISSION = 'conversation_unassigned_manage'.freeze
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
BUILD_LOCK_TTL = 15.minutes.to_i
BUILD_WAIT_TIMEOUT = 30.seconds.to_i
BUILD_WAIT_INTERVAL = 0.1.seconds
attr_reader :account, :user
def initialize(account:, user:)
@account = account
@user = user
end
def perform
return empty_counts if permission_mode == :none
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
{
inboxes: unread_inbox_counts,
labels: unread_label_counts,
teams: unread_team_counts
}
end
private
def ensure_base_cache!
ensure_cache_ready!(
ready: -> { store.base_ready?(account.id) },
lock_key: base_build_lock_key
) { ::Conversations::UnreadCounts::Builder.new(account).build_base! }
end
def ensure_assignment_cache!
ensure_cache_ready!(
ready: -> { store.assignment_ready?(account.id) },
lock_key: assignment_build_lock_key
) { ::Conversations::UnreadCounts::Builder.new(account).build_assignment! }
end
def ensure_cache_ready!(ready:, lock_key:)
lock_manager = Redis::LockManager.new
loop do
return if ready.call
return if lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
wait_for_cache_ready(ready)
end
end
def wait_for_cache_ready(ready)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BUILD_WAIT_TIMEOUT
sleep BUILD_WAIT_INTERVAL until ready.call || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
end
def base_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_BUILD_LOCK, account_id: account.id)
end
def assignment_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK, account_id: account.id)
end
def unread_inbox_counts
counts_for_grouped_keys(visible_inbox_ids.index_with { |inbox_id| inbox_keys_for_mode(inbox_id) })
end
def unread_label_counts
keys_by_id = Hash.new { |hash, key| hash[key] = [] }
sidebar_label_ids.each do |label_id|
visible_inbox_ids.each do |inbox_id|
keys_by_id[label_id].concat(label_inbox_keys_for_mode(label_id, inbox_id))
end
end
counts_for_grouped_keys(keys_by_id)
end
def unread_team_counts
keys_by_id = Hash.new { |hash, key| hash[key] = [] }
visible_team_ids.each do |team_id|
visible_inbox_ids.each do |inbox_id|
keys_by_id[team_id].concat(team_inbox_keys_for_mode(team_id, inbox_id))
end
end
counts_for_grouped_keys(keys_by_id)
end
def inbox_keys_for_mode(inbox_id)
case permission_mode
when :base
[store.inbox_key(account.id, inbox_id)]
when :unassigned_and_mine
[store.inbox_unassigned_key(account.id, inbox_id), store.inbox_assignee_key(account.id, inbox_id, user.id)]
when :mine
[store.inbox_assignee_key(account.id, inbox_id, user.id)]
end
end
def label_inbox_keys_for_mode(label_id, inbox_id)
case permission_mode
when :base
[store.label_inbox_key(account.id, label_id, inbox_id)]
when :unassigned_and_mine
[
store.label_inbox_unassigned_key(account.id, label_id, inbox_id),
store.label_inbox_assignee_key(account.id, label_id, inbox_id, user.id)
]
when :mine
[store.label_inbox_assignee_key(account.id, label_id, inbox_id, user.id)]
end
end
def team_inbox_keys_for_mode(team_id, inbox_id)
case permission_mode
when :base
[store.team_inbox_key(account.id, team_id, inbox_id)]
when :unassigned_and_mine
[
store.team_inbox_unassigned_key(account.id, team_id, inbox_id),
store.team_inbox_assignee_key(account.id, team_id, inbox_id, user.id)
]
when :mine
[store.team_inbox_assignee_key(account.id, team_id, inbox_id, user.id)]
end
end
def counts_for_grouped_keys(keys_by_id)
counts_by_key = store.counts_for_keys(keys_by_id.values.flatten)
keys_by_id.each_with_object({}) do |(id, keys), result|
count = keys.sum { |key| counts_by_key[key].to_i }
result[id.to_s] = count if count.positive?
end
end
def assignment_mode?
%i[unassigned_and_mine mine].include?(permission_mode)
end
def permission_mode
@permission_mode ||=
if !custom_role_agent? || permissions.include?(MANAGE_ALL_PERMISSION)
:base
elsif permissions.include?(UNASSIGNED_PERMISSION)
:unassigned_and_mine
elsif permissions.include?(PARTICIPATING_PERMISSION)
:mine
else
:none
end
end
def custom_role_agent?
account_user&.agent? && account_user.custom_role_id.present?
end
def permissions
account_user&.permissions || []
end
def account_user
@account_user ||= account.account_users.find_by(user_id: user.id)
end
def visible_inbox_ids
@visible_inbox_ids ||= if account_user&.administrator?
account.inboxes.pluck(:id)
else
user.inboxes.where(account_id: account.id).pluck(:id)
end
end
def sidebar_label_ids
@sidebar_label_ids ||= account.labels.where(show_on_sidebar: true).pluck(:id)
end
def visible_team_ids
@visible_team_ids ||= if account_user&.administrator?
account.teams.pluck(:id)
else
user.teams.where(account_id: account.id).pluck(:id)
end
end
def empty_counts
{ inboxes: {}, labels: {}, teams: {} }
end
def store
::Conversations::UnreadCounts::Store
end
end
@@ -0,0 +1,96 @@
class Conversations::UnreadCounts::Listener < BaseListener
include Events::Types
def message_created(event)
message, = extract_message_and_account(event)
return unless message.incoming?
return unless message.account.feature_enabled?('conversation_unread_counts')
refresh(message.conversation)
end
def conversation_status_changed(event)
conversation, = extract_conversation_and_account(event)
refresh(conversation, event.data[:changed_attributes])
end
def conversation_updated(event)
return unless label_changed?(event.data[:changed_attributes])
conversation, = extract_conversation_and_account(event)
refresh(conversation, event.data[:changed_attributes])
end
def assignee_changed(event)
conversation, = extract_conversation_and_account(event)
refresh(conversation, event.data[:changed_attributes])
end
def team_changed(event)
conversation, = extract_conversation_and_account(event)
refresh(conversation, event.data[:changed_attributes])
end
def conversation_deleted(event)
conversation_data = event.data[:conversation_data]&.with_indifferent_access
return if conversation_data.blank?
account = Account.find_by(id: conversation_data[:account_id])
return unless account&.feature_enabled?('conversation_unread_counts')
return unless remove_deleted_conversation(account, conversation_data)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
end
private
def refresh(conversation, changed_attributes = nil)
::Conversations::UnreadCounts::Notifier.new(conversation, changed_attributes: changed_attributes).perform
end
def remove_deleted_conversation(account, conversation_data)
return false unless store.base_ready?(account.id) || store.assignment_ready?(account.id)
removed = false
removed = remove_deleted_base_membership(account, conversation_data) || removed if store.base_ready?(account.id)
removed = remove_deleted_assignment_membership(account, conversation_data) || removed if store.assignment_ready?(account.id)
removed
end
def remove_deleted_base_membership(account, conversation_data)
store.remove_base_membership(
account_id: account.id,
inbox_ids: [conversation_data[:inbox_id]],
label_ids: label_ids_for(account, conversation_data[:cached_label_list]),
team_ids: [conversation_data[:team_id]],
conversation_id: conversation_data[:id]
)
end
def remove_deleted_assignment_membership(account, conversation_data)
store.remove_assignment_membership(
account_id: account.id,
inbox_ids: [conversation_data[:inbox_id]],
label_ids: label_ids_for(account, conversation_data[:cached_label_list]),
assignee_ids: [conversation_data[:assignee_id]],
team_ids: [conversation_data[:team_id]],
conversation_id: conversation_data[:id]
)
end
def label_ids_for(account, label_list)
label_titles = label_list.to_s.split(',').map(&:strip).compact_blank
account.labels.pluck(:title, :id).to_h.values_at(*label_titles).compact
end
def label_changed?(changed_attributes)
return false if changed_attributes.blank?
changed_attributes.key?('label_list') || changed_attributes.key?(:label_list) ||
changed_attributes.key?('cached_label_list') || changed_attributes.key?(:cached_label_list)
end
def store
::Conversations::UnreadCounts::Store
end
end
@@ -0,0 +1,19 @@
class Conversations::UnreadCounts::Notifier
include Events::Types
attr_reader :conversation, :changed_attributes
def initialize(conversation, changed_attributes: nil)
@conversation = conversation
@changed_attributes = changed_attributes
end
def perform
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
true
end
end
@@ -0,0 +1,178 @@
class Conversations::UnreadCounts::Refresher
attr_reader :conversation, :changed_attributes
def initialize(conversation, changed_attributes: nil)
@conversation = conversation
@changed_attributes = changed_attributes.is_a?(Hash) ? changed_attributes : {}
end
def perform
return false unless base_ready? || assignment_ready?
before_memberships = store.memberships_for_keys(affected_cache_keys, conversation.id)
refresh_base_membership if base_ready?
refresh_assignment_membership if assignment_ready?
after_memberships = store.memberships_for_keys(affected_cache_keys, conversation.id)
before_memberships != after_memberships
end
private
def affected_cache_keys
keys = []
keys.concat(affected_base_keys) if base_ready?
keys.concat(affected_assignment_keys) if assignment_ready?
keys.uniq
end
def affected_base_keys
affected_inbox_ids.flat_map do |inbox_id|
[store.inbox_key(account.id, inbox_id)] +
affected_label_ids.map { |label_id| store.label_inbox_key(account.id, label_id, inbox_id) } +
affected_team_ids.map { |team_id| store.team_inbox_key(account.id, team_id, inbox_id) }
end
end
def affected_assignment_keys
affected_inbox_ids.flat_map do |inbox_id|
affected_assignee_ids.flat_map do |assignee_id|
assignment_keys_for(inbox_id, assignee_id)
end
end
end
def assignment_keys_for(inbox_id, assignee_id)
keys = assignee_id.present? ? assignee_keys_for(inbox_id, assignee_id) : unassigned_keys_for(inbox_id)
keys + affected_team_ids.map { |team_id| team_assignment_key_for(team_id, inbox_id, assignee_id) }
end
def assignee_keys_for(inbox_id, assignee_id)
[store.inbox_assignee_key(account.id, inbox_id, assignee_id)] +
affected_label_ids.map { |label_id| store.label_inbox_assignee_key(account.id, label_id, inbox_id, assignee_id) }
end
def unassigned_keys_for(inbox_id)
[store.inbox_unassigned_key(account.id, inbox_id)] +
affected_label_ids.map { |label_id| store.label_inbox_unassigned_key(account.id, label_id, inbox_id) }
end
def refresh_base_membership
store.remove_base_membership(
account_id: account.id,
inbox_ids: affected_inbox_ids,
label_ids: affected_label_ids,
team_ids: affected_team_ids,
conversation_id: conversation.id
)
return unless unread?
store.add_base_membership(
account_id: account.id,
inbox_id: conversation.inbox_id,
label_ids: current_label_ids,
team_id: conversation.team_id,
conversation_id: conversation.id
)
end
def refresh_assignment_membership
store.remove_assignment_membership(
account_id: account.id,
inbox_ids: affected_inbox_ids,
label_ids: affected_label_ids,
assignee_ids: affected_assignee_ids,
team_ids: affected_team_ids,
conversation_id: conversation.id
)
return unless unread?
store.add_assignment_membership(
account_id: account.id,
inbox_id: conversation.inbox_id,
label_ids: current_label_ids,
assignee_id: conversation.assignee_id,
team_id: conversation.team_id,
conversation_id: conversation.id
)
end
def unread?
# Sidebar unread counts intentionally track only open conversations.
return false unless conversation.open?
incoming_messages = conversation.messages.incoming.where(account_id: account.id)
if conversation.agent_last_seen_at
incoming_messages = incoming_messages.where(Message.arel_table[:created_at].gt(conversation.agent_last_seen_at))
end
incoming_messages.exists?
end
def affected_inbox_ids
[previous_value_for(:inbox_id), conversation.inbox_id].compact.uniq
end
def affected_assignee_ids
return [conversation.assignee_id] unless changed_attribute?(:assignee_id)
[previous_value_for(:assignee_id), conversation.assignee_id].uniq
end
def affected_label_ids
(previous_label_ids + current_label_ids).uniq
end
def affected_team_ids
[previous_value_for(:team_id), conversation.team_id].compact.uniq
end
def previous_label_ids
label_ids_for(previous_value_for(:label_list) || previous_value_for(:cached_label_list) || conversation.cached_label_list)
end
def current_label_ids
@current_label_ids ||= label_ids_for(conversation.cached_label_list)
end
def label_ids_for(label_list)
labels = label_list.is_a?(Array) ? label_list : label_list.to_s.split(',')
label_titles = labels.map(&:to_s).map(&:strip).compact_blank
labels_by_title.values_at(*label_titles).compact
end
def previous_value_for(attribute)
change = changed_attributes[attribute.to_s] || changed_attributes[attribute.to_sym]
change&.first
end
def changed_attribute?(attribute)
changed_attributes.key?(attribute.to_s) || changed_attributes.key?(attribute.to_sym)
end
def team_assignment_key_for(team_id, inbox_id, assignee_id)
return store.team_inbox_assignee_key(account.id, team_id, inbox_id, assignee_id) if assignee_id.present?
store.team_inbox_unassigned_key(account.id, team_id, inbox_id)
end
def labels_by_title
@labels_by_title ||= account.labels.pluck(:title, :id).to_h
end
def base_ready?
@base_ready ||= store.base_ready?(account.id)
end
def assignment_ready?
@assignment_ready ||= store.assignment_ready?(account.id)
end
def account
conversation.account
end
def store
::Conversations::UnreadCounts::Store
end
end
@@ -0,0 +1,199 @@
class Conversations::UnreadCounts::Store
extend ::Conversations::UnreadCounts::StoreKeys
class << self
def base_ready?(account_id)
Redis::Alfred.exists?(base_ready_key(account_id))
end
def assignment_ready?(account_id)
Redis::Alfred.exists?(assignment_ready_key(account_id))
end
def mark_base_ready!(account_id)
Redis::Alfred.set(base_ready_key(account_id), Time.current.to_i, ex: Conversations::UnreadCounts::READY_TTL)
end
def mark_assignment_ready!(account_id)
Redis::Alfred.set(assignment_ready_key(account_id), Time.current.to_i, ex: Conversations::UnreadCounts::READY_TTL)
end
def clear_account!(account_id)
delete_matching("#{account_prefix(account_id)}::*")
end
def clear_assignment!(account_id)
assignment_key_patterns(account_id).each { |pattern| delete_matching(pattern) }
end
def add_base_membership(account_id:, inbox_id:, label_ids:, conversation_id:, team_id: nil)
add_to_sets(base_keys(account_id, inbox_id, label_ids, team_id), conversation_id)
end
def remove_base_membership(account_id:, inbox_ids:, label_ids:, conversation_id:, team_ids: [])
keys = Array(inbox_ids).flat_map { |inbox_id| removable_base_keys(account_id, inbox_id, label_ids, team_ids) }
remove_from_sets(keys, conversation_id)
end
def add_assignment_membership(account_id:, conversation_id:, **membership)
add_to_sets(
assignment_keys(account_id, membership[:inbox_id], membership[:label_ids], membership[:assignee_id], membership[:team_id]),
conversation_id
)
end
def remove_assignment_membership(account_id:, conversation_id:, **membership)
keys = Array(membership[:inbox_ids]).flat_map do |inbox_id|
Array(membership[:assignee_ids]).flat_map do |assignee_id|
removable_assignment_keys(account_id, inbox_id, membership[:label_ids], assignee_id, membership[:team_ids])
end
end
remove_from_sets(keys, conversation_id)
end
def add_memberships(account_id:, memberships:, assignment: false)
return if memberships.blank?
Redis::Alfred.pipelined do |pipeline|
memberships.each do |membership|
keys = if assignment
assignment_keys(account_id, membership[:inbox_id], membership[:label_ids], membership[:assignee_id], membership[:team_id])
else
base_keys(account_id, membership[:inbox_id], membership[:label_ids], membership[:team_id])
end
keys.each do |key|
pipeline.sadd(key, membership[:conversation_id])
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
end
end
end
end
def counts_for_keys(keys)
keys = keys.compact_blank
return {} if keys.blank?
counts = Redis::Alfred.pipelined do |pipeline|
keys.each { |key| pipeline.scard(key) }
end
keys.zip(counts).to_h
end
def memberships_for_keys(keys, conversation_id)
keys = keys.compact_blank
return {} if keys.blank?
memberships = Redis::Alfred.pipelined do |pipeline|
keys.each { |key| pipeline.sismember(key, conversation_id) }
end
keys.zip(memberships.map { |membership| membership == true || membership == 1 }).to_h
end
private
def base_ready_key(account_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_READY, account_id: account_id)
end
def assignment_ready_key(account_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_READY, account_id: account_id)
end
def account_prefix(account_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_ACCOUNT_PREFIX, account_id: account_id)
end
def base_keys(account_id, inbox_id, label_ids, team_id = nil)
keys = [inbox_key(account_id, inbox_id)] + Array(label_ids).map { |label_id| label_inbox_key(account_id, label_id, inbox_id) }
keys << team_inbox_key(account_id, team_id, inbox_id) if team_id.present?
keys
end
def removable_base_keys(account_id, inbox_id, label_ids, team_ids)
keys = base_keys(account_id, inbox_id, label_ids)
keys.concat(Array(team_ids).compact_blank.map { |team_id| team_inbox_key(account_id, team_id, inbox_id) })
end
def assignment_keys(account_id, inbox_id, label_ids, assignee_id, team_id = nil)
keys = assignment_keys_without_team(account_id, inbox_id, label_ids, assignee_id)
keys << team_assignment_key(account_id, team_id, inbox_id, assignee_id) if team_id.present?
keys
end
def removable_assignment_keys(account_id, inbox_id, label_ids, assignee_id, team_ids)
keys = assignment_keys_without_team(account_id, inbox_id, label_ids, assignee_id)
keys.concat(Array(team_ids).compact_blank.map { |team_id| team_assignment_key(account_id, team_id, inbox_id, assignee_id) })
end
def assignment_keys_without_team(account_id, inbox_id, label_ids, assignee_id)
return assignee_keys(account_id, inbox_id, label_ids, assignee_id) if assignee_id.present?
unassigned_keys(account_id, inbox_id, label_ids)
end
def assignee_keys(account_id, inbox_id, label_ids, assignee_id)
[inbox_assignee_key(account_id, inbox_id, assignee_id)] +
Array(label_ids).map { |label_id| label_inbox_assignee_key(account_id, label_id, inbox_id, assignee_id) }
end
def unassigned_keys(account_id, inbox_id, label_ids)
[inbox_unassigned_key(account_id, inbox_id)] +
Array(label_ids).map { |label_id| label_inbox_unassigned_key(account_id, label_id, inbox_id) }
end
def team_assignment_key(account_id, team_id, inbox_id, assignee_id)
return team_inbox_assignee_key(account_id, team_id, inbox_id, assignee_id) if assignee_id.present?
team_inbox_unassigned_key(account_id, team_id, inbox_id)
end
def add_to_sets(keys, conversation_id)
write_to_sets(keys) { |pipeline, key| pipeline.sadd(key, conversation_id) }
end
def remove_from_sets(keys, conversation_id)
keys = keys.compact_blank
return false if keys.blank?
results = Redis::Alfred.pipelined do |pipeline|
keys.each do |key|
pipeline.srem(key, conversation_id)
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
end
end
results.each_slice(2).any? { |removed, _| removed == true || (removed.respond_to?(:to_i) && removed.to_i.positive?) }
end
def write_to_sets(keys)
keys = keys.compact_blank
return if keys.blank?
Redis::Alfred.pipelined do |pipeline|
keys.each do |key|
yield(pipeline, key)
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
end
end
end
def delete_matching(pattern)
Redis::Alfred.scan_each(match: pattern, count: 1000) do |key|
Redis::Alfred.delete(key)
end
end
def assignment_key_patterns(account_id)
prefix = account_prefix(account_id)
[
assignment_ready_key(account_id),
"#{prefix}::INBOX::*::UNASSIGNED",
"#{prefix}::INBOX::*::ASSIGNEE::*",
"#{prefix}::LABEL::*::INBOX::*::UNASSIGNED",
"#{prefix}::LABEL::*::INBOX::*::ASSIGNEE::*",
"#{prefix}::TEAM::*::INBOX::*::UNASSIGNED",
"#{prefix}::TEAM::*::INBOX::*::ASSIGNEE::*"
]
end
end
end
@@ -0,0 +1,43 @@
module Conversations::UnreadCounts::StoreKeys
def inbox_key(account_id, inbox_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_INBOX, account_id: account_id, inbox_id: inbox_id)
end
def label_inbox_key(account_id, label_id, inbox_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_LABEL_INBOX, account_id: account_id, label_id: label_id, inbox_id: inbox_id)
end
def team_inbox_key(account_id, team_id, inbox_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX, account_id: account_id, team_id: team_id, inbox_id: inbox_id)
end
def inbox_unassigned_key(account_id, inbox_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_INBOX_UNASSIGNED, account_id: account_id, inbox_id: inbox_id)
end
def inbox_assignee_key(account_id, inbox_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_INBOX_ASSIGNEE, account_id: account_id, inbox_id: inbox_id, user_id: user_id)
end
def label_inbox_unassigned_key(account_id, label_id, inbox_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_LABEL_INBOX_UNASSIGNED, account_id: account_id, label_id: label_id, inbox_id: inbox_id)
end
def label_inbox_assignee_key(account_id, label_id, inbox_id, user_id)
format(
Redis::Alfred::UNREAD_CONVERSATIONS_LABEL_INBOX_ASSIGNEE,
account_id: account_id,
label_id: label_id,
inbox_id: inbox_id,
user_id: user_id
)
end
def team_inbox_unassigned_key(account_id, team_id, inbox_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX_UNASSIGNED, account_id: account_id, team_id: team_id, inbox_id: inbox_id)
end
def team_inbox_assignee_key(account_id, team_id, inbox_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE, account_id: account_id, team_id: team_id, inbox_id: inbox_id, user_id: user_id)
end
end
+20 -13
View File
@@ -1,5 +1,6 @@
class Twilio::IncomingMessageService
include ::FileTypeHelper
include ::Twilio::WhatsappIdentifierHelper
pattr_initialize [:params!]
@@ -51,17 +52,27 @@ class Twilio::IncomingMessageService
@account ||= inbox.account
end
# Twilio WhatsApp phone payloads arrive as `whatsapp:+E164`. BSUID-only
# payloads use `whatsapp:<BSUID>` in `From`, so this intentionally returns
# nil when `From` is not phone-shaped.
def phone_number
twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '')
return params[:From] if twilio_channel.sms?
return unless twilio_whatsapp_phone_source?
params[:From].gsub('whatsapp:', '')
end
# Keep Twilio WhatsApp source ids in Twilio's native shape. Phone messages use
# `whatsapp:+E164`; BSUID-only messages fall back to `whatsapp:<BSUID>`.
def normalized_phone_number
return phone_number unless twilio_channel.whatsapp?
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
twilio_whatsapp_primary_source_id
end
def formatted_phone_number
return if phone_number.blank?
TelephoneNumber.parse(phone_number).international_number
end
@@ -71,15 +82,9 @@ class Twilio::IncomingMessageService
def set_contact
source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From]
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: source_id,
inbox: inbox,
contact_attributes: contact_attributes
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
@contact_inbox = twilio_contact_inbox(source_id)
@contact = @contact_inbox.contact
update_twilio_whatsapp_identifiers
# Update existing contact name if ProfileName is available and current name is just phone number
update_contact_name_if_needed
@@ -111,13 +116,13 @@ class Twilio::IncomingMessageService
def contact_attributes
{
name: contact_name,
phone_number: phone_number,
phone_number: phone_number.presence,
additional_attributes: additional_attributes
}
end
def contact_name
params[:ProfileName].presence || formatted_phone_number
params[:ProfileName].presence || formatted_phone_number || twilio_whatsapp_display_identifier || params[:From]
end
def additional_attributes
@@ -207,6 +212,8 @@ class Twilio::IncomingMessageService
end
def contact_name_matches_phone_number?
return false if phone_number.blank?
@contact.name == phone_number || @contact.name == formatted_phone_number
end
end
@@ -0,0 +1,69 @@
module Twilio::WhatsappIdentifierHelper
TWILIO_WHATSAPP_BSUID_SOURCE_ID_REGEX = Regexp.new("\\Awhatsapp:#{RegexHelper::WHATSAPP_BSUID_PATTERN}\\z")
def update_twilio_whatsapp_identifiers
return unless twilio_channel.whatsapp?
Whatsapp::IdentifierSyncService.new(contact_inbox: @contact_inbox, contact: @contact).perform(
source_ids: twilio_whatsapp_source_ids,
username: params[:ProfileUsername].presence || params[:Username],
phone_number: phone_number.presence
)
end
def twilio_whatsapp_phone_source?
params[:From].to_s.match?(/\Awhatsapp:\+\d{1,15}\z/)
end
def twilio_whatsapp_bsuid
params[:ExternalUserId].presence || twilio_whatsapp_bsuid_source_id
end
def twilio_whatsapp_display_identifier
twilio_whatsapp_bsuid.to_s.delete_prefix('whatsapp:').presence
end
def twilio_whatsapp_source_ids
[
twilio_whatsapp_phone_source_id,
twilio_whatsapp_source_id(params[:ExternalUserId].presence) || twilio_whatsapp_bsuid_source_id,
twilio_whatsapp_source_id(params[:ParentExternalUserId].presence)
].compact_blank.uniq
end
def twilio_whatsapp_primary_source_id
twilio_whatsapp_source_ids.first
end
def twilio_whatsapp_phone_source_id
return if phone_number.blank?
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
end
def twilio_whatsapp_source_id(identifier)
identifier = identifier.to_s
return if identifier.blank?
"whatsapp:#{identifier.delete_prefix('whatsapp:')}"
end
def twilio_whatsapp_bsuid_source_id
from = params[:From].to_s
return from if from.match?(TWILIO_WHATSAPP_BSUID_SOURCE_ID_REGEX)
end
def twilio_contact_inbox(source_id)
ContactInboxSourceIdResolver.new(
inbox: inbox,
source_ids: twilio_contact_inbox_source_ids(source_id),
contact_attributes: contact_attributes
).perform
end
def twilio_contact_inbox_source_ids(source_id)
return [source_id] unless twilio_channel.whatsapp?
twilio_whatsapp_source_ids.presence || [source_id]
end
end
@@ -0,0 +1,68 @@
class Whatsapp::IdentifierSyncService
pattr_initialize [:contact_inbox!, :contact]
def perform(source_ids: [], username: nil, phone_number: nil)
create_contact_inboxes(source_ids)
update_contact(username, phone_number)
end
private
def create_contact_inboxes(source_ids)
source_ids.compact_blank.uniq.each do |source_id|
next if inbox.contact_inboxes.exists?(source_id: source_id)
inbox.contact_inboxes.create!(contact: synced_contact, source_id: source_id)
rescue ActiveRecord::RecordNotUnique
# A concurrent webhook (e.g. a status update bypassing the per-contact
# mutex) just inserted the same (inbox_id, source_id). Treat it as a
# no-op instead of falling through to ContactInboxBuilder's retry path,
# which would scramble the freshly-written row.
end
end
def update_contact(username, phone_number)
return if synced_contact.blank?
update_contact_phone_number(phone_number)
update_contact_username(username)
end
def update_contact_phone_number(phone_number)
phone_number = phone_number.presence
return if phone_number.blank? || synced_contact.phone_number.present?
return if synced_contact.account.contacts.where(phone_number: phone_number).where.not(id: synced_contact.id).exists?
synced_contact.update!(phone_number: phone_number)
end
def update_contact_username(username)
username = normalize_username(username)
return if username.blank?
synced_contact.update!(additional_attributes: additional_attributes_with_username(username))
end
def synced_contact
@synced_contact ||= contact || contact_inbox.contact
end
def inbox
@inbox ||= contact_inbox.inbox
end
def normalize_username(value)
value.to_s.sub(/\A@+/, '').presence
end
def additional_attributes_with_username(username)
attributes = synced_contact.additional_attributes.deep_dup
social_profiles = attributes['social_profiles'] || {}
social_profiles['whatsapp'] = username
attributes.merge(
'social_profiles' => social_profiles,
'social_whatsapp_user_name' => username
)
end
end
@@ -3,6 +3,7 @@
# https://developers.facebook.com/docs/whatsapp/api/media/
class Whatsapp::IncomingMessageBaseService
include ::Whatsapp::IncomingMessageServiceHelpers
include ::Whatsapp::IncomingMessageIdentifierHelper
pattr_initialize [:inbox!, :params!, :outgoing_echo]
@@ -46,9 +47,11 @@ class Whatsapp::IncomingMessageBaseService
end
def process_statuses
return unless find_message_by_source_id(@processed_params[:statuses].first[:id])
status = @processed_params[:statuses].first
return unless find_message_by_source_id(status[:id])
update_message_with_status(@message, @processed_params[:statuses].first)
update_whatsapp_identifiers_from_status(status)
update_message_with_status(@message, status)
rescue ArgumentError => e
Rails.logger.error "Error while processing whatsapp status update #{e.message}"
end
@@ -95,40 +98,6 @@ class Whatsapp::IncomingMessageBaseService
end
end
def set_contact_from_echo
# For echo messages, contact phone is in the 'to' field
phone_number = messages_data.first[:to]
waid = processed_waid(phone_number)
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: waid,
inbox: inbox,
contact_attributes: { name: "+#{phone_number}", phone_number: "+#{phone_number}" }
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
end
def set_contact_from_message
contact_params = @processed_params[:contacts]&.first
return if contact_params.blank?
waid = processed_waid(contact_params[:wa_id])
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: waid,
inbox: inbox,
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{messages_data.first[:from]}" }
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
# Update existing contact name if ProfileName is available and current name is just phone number
update_contact_with_profile_name(contact_params)
end
def set_conversation
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
@conversation = if @inbox.lock_to_single_conversation
@@ -224,7 +193,10 @@ class Whatsapp::IncomingMessageBaseService
end
def contact_name_matches_phone_number?
phone_number = "+#{messages_data.first[:from]}"
message_phone_number = whatsapp_phone_number(messages_data.first[:from])
return false if message_phone_number.blank?
phone_number = "+#{message_phone_number}"
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
@contact.name == phone_number || @contact.name == formatted_phone_number
end
@@ -0,0 +1,108 @@
module Whatsapp::IncomingMessageIdentifierHelper
def set_contact_from_echo
message = messages_data.first
source_ids = outgoing_message_source_ids(message)
return if source_ids.blank?
contact_attributes = contact_attributes_for_identifier(source_ids.first, message[:to])
@contact_inbox = find_or_create_contact_inbox(
source_ids: source_ids,
contact_attributes: contact_attributes
)
@contact = @contact_inbox.contact
update_whatsapp_identifiers(source_ids: source_ids, phone_number: contact_attributes[:phone_number])
end
def set_contact_from_message
contact_params = @processed_params[:contacts]&.first
return if contact_params.blank?
source_ids = incoming_message_source_ids(contact_params)
return if source_ids.blank?
attrs = contact_attributes_from_contact_params(contact_params, source_ids.first)
@contact_inbox = find_or_create_contact_inbox(
source_ids: source_ids,
contact_attributes: attrs
)
@contact = @contact_inbox.contact
update_whatsapp_identifiers(source_ids: source_ids, username: contact_params.dig(:profile, :username), phone_number: attrs[:phone_number])
update_contact_with_profile_name(contact_params)
end
def find_or_create_contact_inbox(source_ids:, contact_attributes:)
ContactInboxSourceIdResolver.new(
inbox: inbox,
source_ids: source_ids,
contact_attributes: contact_attributes
).perform
end
def incoming_message_source_ids(contact_params)
[
whatsapp_phone_source_id(contact_params[:wa_id].presence || messages_data.first[:from].presence),
whatsapp_source_id(contact_params[:user_id].presence || messages_data.first[:from_user_id].presence),
whatsapp_source_id(contact_params[:parent_user_id].presence || messages_data.first[:from_parent_user_id].presence)
].compact_blank.uniq
end
def outgoing_message_source_ids(message)
[
whatsapp_phone_source_id(message[:to].presence),
whatsapp_source_id(message[:to_user_id].presence),
whatsapp_source_id(message[:to_parent_user_id].presence)
].compact_blank.uniq
end
def whatsapp_phone_source_id(identifier)
phone_number = whatsapp_phone_number(identifier)
return if phone_number.blank?
processed_waid(phone_number)
end
def whatsapp_source_id(identifier)
identifier.to_s.presence
end
def contact_attributes_from_contact_params(contact_params, source_identifier)
contact_attributes_for_identifier(
contact_params.dig(:profile, :name).presence || source_identifier,
contact_params[:wa_id].presence || messages_data.first[:from].presence
)
end
def contact_attributes_for_identifier(name, phone_identifier)
phone_number = whatsapp_phone_number(phone_identifier)
return { name: name } if phone_number.blank?
formatted_phone_number = "+#{phone_number}"
display_name = name == phone_identifier ? formatted_phone_number : name
{ name: display_name, phone_number: formatted_phone_number }
end
def update_whatsapp_identifiers(source_ids: [], username: nil, phone_number: nil)
Whatsapp::IdentifierSyncService.new(contact_inbox: @contact_inbox, contact: @contact).perform(source_ids: source_ids, username: username,
phone_number: phone_number)
end
def update_whatsapp_identifiers_from_status(status)
contact_inbox = @message&.conversation&.contact_inbox
return if contact_inbox.blank?
Whatsapp::IdentifierSyncService.new(contact_inbox: contact_inbox, contact: contact_inbox.contact).perform(
source_ids: status_source_ids(status)
)
end
def status_source_ids(status)
contact_params = @processed_params[:contacts]&.first || {}
[
whatsapp_source_id(status[:recipient_user_id]),
whatsapp_source_id(status[:recipient_parent_user_id]),
whatsapp_source_id(contact_params[:user_id]),
whatsapp_source_id(contact_params[:parent_user_id])
].compact_blank.uniq
end
end
@@ -51,6 +51,14 @@ module Whatsapp::IncomingMessageServiceHelpers
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud)
end
def whatsapp_phone_number(identifier)
identifier = identifier.to_s
return if identifier.blank?
return unless identifier.match?(/\A\d{1,15}\z/)
identifier
end
def error_webhook_event?(message)
message.key?('errors')
end
@@ -15,6 +15,9 @@ json.config do
json.partial! 'api/v1/models/portal_config', formats: [:json], locale: locale, portal: portal
end
end
json.default_locale portal.default_locale
json.layout portal.layout
json.social_profiles portal.social_profiles
end
if portal.channel_web_widget
+41
View File
@@ -0,0 +1,41 @@
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1">
<meta name="turbolinks-cache-control" content="no-cache">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%= vite_client_tag %>
<%= vite_javascript_tag 'portal' %>
<style>
.appearance-menu[data-current-theme="system"] .check-mark-icon:is(.light-theme, .dark-theme),
.appearance-menu[data-current-theme="dark"] .check-mark-icon:is(.light-theme, .system-theme),
.appearance-menu[data-current-theme="light"] .check-mark-icon:is(.dark-theme, .system-theme) {
display: none;
}
</style>
<%= csrf_meta_tags %>
<% if content_for?(:head) %>
<%= yield(:head) %>
<% else %>
<title><%= @portal.display_title %></title>
<% end %>
<% if @portal.logo.present? %>
<link rel="icon" href="<%= url_for(@portal.logo) %>">
<% end %>
<% unless @theme_from_params.blank? %>
<%# this adds the theme from params, ensuring that there a localstorage value set %>
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>
<script>localStorage.theme = '<%= @theme_from_params %>';</script>
<% end %>
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
// we can use document.body here but that would mean pushing this script inside the body
// since the body is not created yet. This is done to avoid FOUC, at a tiny cost of Time to Interactive
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
document.documentElement.classList.add('light')
}
</script>
@@ -0,0 +1,92 @@
<% if @article.present? %>
<script>
(function() {
let viewTracked = false;
const trackView = function() {
if (!viewTracked) {
viewTracked = true;
const img = new Image();
img.src = '<%= request.base_url %>/hc/<%= @portal.slug %>/articles/<%= @article.slug %>.png';
}
};
const addTrackingListeners = function() {
const events = ['mouseenter', 'touchstart', 'focus'];
events.forEach(event => {
document.body.addEventListener(event, function() {
setTimeout(trackView, 5000);
}, { once: true });
});
};
addTrackingListeners();
})();
</script>
<% end %>
<style>
html.dark {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'dark') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('dark') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'dark') %>;
--dynamic-portal-color: <%= @portal.color %>;
--dynamic-portal-color-soft: color-mix(in srgb, <%= @portal.color %> 18%, transparent);
--dynamic-portal-color-faint: color-mix(in srgb, <%= @portal.color %> 6%, transparent);
}
html.light {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'light') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('light') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'light') %>;
--dynamic-portal-color: <%= @portal.color %>;
--dynamic-portal-color-soft: color-mix(in srgb, <%= @portal.color %> 12%, transparent);
--dynamic-portal-color-faint: color-mix(in srgb, <%= @portal.color %> 5%, transparent);
}
/* Portal background */
#portal-bg {
background: var(--dynamic-portal-bg);
}
/* Portal background gradient */
#portal-bg-gradient {
background-image: var(--dynamic-portal-bg-gradient);
}
/* Category block item hover color */
#category-item:hover {
background-color: var(--dynamic-hover-bg-color);
}
/* Header section */
#header-action-button:hover,
#toggle-appearance:hover,
#toggle-theme-button:hover {
color: var(--dynamic-hover-color);
stroke: var(--dynamic-hover-color);
}
#category-block:hover {
border-color: var(--dynamic-hover-color);
}
#category-block:hover #category-name {
color: var(--dynamic-hover-color);
}
</style>
<script>
window.portalConfig = {
portalSlug: '<%= @portal.slug %>',
portalColor: '<%= @portal.color %>',
theme: '<%= @theme_from_params %>',
customDomain: '<%= @portal.custom_domain %>',
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
localeCode: '<%= @locale %>',
searchTranslations: {
searchPlaceholder: '<%= I18n.t('public_portal.search.search_placeholder') %>',
emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>',
loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>',
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>',
},
isPlainLayoutEnabled: '<%= @is_plain_layout_enabled %>',
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
};
</script>
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
<%= @portal.channel_web_widget.web_widget_script.html_safe %>
<% end %>
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
<head>
<%= render 'layouts/portal_head' %>
</head>
<body class="font-inter">
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
<%= render 'public/api/v1/portals/documentation_layout/topbar',
portal: @portal, locale: @locale, article: @article, category: @category %>
<div class="bg-white dark:bg-n-slate-2 flex-1 flex font-inter tracking-normal [font-optical-sizing:auto]">
<div class="max-w-screen-2xl w-full mx-auto flex flex-1">
<%= render 'public/api/v1/portals/documentation_layout/sidebar',
portal: @portal, locale: @locale, article: @article, category: @category %>
<main class="flex-1 min-w-0 flex flex-col">
<%= yield %>
<%= render 'public/api/v1/portals/documentation_layout/footer',
portal: @portal, global_config: @global_config %>
</main>
</div>
</div>
</main>
</div>
<%= render 'layouts/portal_scripts' %>
</body>
</html>
+5 -149
View File
@@ -1,160 +1,16 @@
<%#
# Application Layout
This view template is used as the layout
for every page that Administrate generates.
By default, it renders:
- Navigation
- Content for a search bar
(if provided by a `content_for` block in a nested page)
- Flashes
- Links to stylesheets and JavaScripts
- The appearance dropdown styles are added to the top to prevent FOUC
%>
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1">
<meta name= "turbolinks-cache-control" content= "no-cache">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<%= vite_client_tag %>
<%= vite_javascript_tag 'portal' %>
<style>
.appearance-menu[data-current-theme="system"] .check-mark-icon:is(.light-theme, .dark-theme),
.appearance-menu[data-current-theme="dark"] .check-mark-icon:is(.light-theme, .system-theme),
.appearance-menu[data-current-theme="light"] .check-mark-icon:is(.dark-theme, .system-theme) {
display: none;
}
</style>
<%= csrf_meta_tags %>
<% if content_for?(:head) %>
<%= yield(:head) %>
<% else %>
<title><%= @portal.display_title %></title>
<% end %>
<% if @portal.logo.present? %>
<link rel="icon" href="<%= url_for(@portal.logo) %>">
<% end %>
<% unless @theme_from_params.blank? %>
<%# this adds the theme from params, ensuring that there a localstorage value set %>
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>
<script>localStorage.theme = '<%= @theme_from_params %>';</script>
<% end %>
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
// we can use document.body here but that would mean pushing this script inside the body
// since the body is not created yet. This is done to avoid FOUC, at a tiny cost of Time to Interactive
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
document.documentElement.classList.add('light')
}
</script>
<%= render 'layouts/portal_head' %>
</head>
<body>
<body class="font-default">
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
<% if !@is_plain_layout_enabled %>
<%= render "public/api/v1/portals/header", portal: @portal %>
<% end %>
<%= render 'public/api/v1/portals/header', portal: @portal unless @is_plain_layout_enabled %>
<%= yield %>
<% if !(@is_plain_layout_enabled || @portal.account.feature_enabled?('disable_branding')) %>
<%= render "public/api/v1/portals/footer" %>
<% end %>
<%= render 'public/api/v1/portals/footer' unless @is_plain_layout_enabled || @portal.account.feature_enabled?('disable_branding') %>
</main>
</div>
<% if @article.present? %>
<script>
(function() {
let viewTracked = false;
const trackView = function() {
if (!viewTracked) {
viewTracked = true;
const img = new Image();
img.src = '<%= request.base_url %>/hc/<%= @portal.slug %>/articles/<%= @article.slug %>.png';
}
};
const addTrackingListeners = function() {
const events = ['mouseenter', 'touchstart', 'focus'];
events.forEach(event => {
document.body.addEventListener(event, function() {
setTimeout(trackView, 5000);
}, { once: true });
});
};
addTrackingListeners();
})();
</script>
<% end %>
<%= render 'layouts/portal_scripts' %>
</body>
<style>
html.dark {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'dark') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('dark') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'dark') %>;
}
html.light {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'light') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('light') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'light') %>;
}
/* Portal background */
#portal-bg {
background: var(--dynamic-portal-bg);
}
/* Portal background gradient */
#portal-bg-gradient {
background-image: var(--dynamic-portal-bg-gradient);
}
/* Category block item hover color */
#category-item:hover {
background-color: var(--dynamic-hover-bg-color);
}
/* Header section */
#header-action-button:hover,
#toggle-appearance:hover,
#toggle-theme-button:hover {
color: var(--dynamic-hover-color);
stroke: var(--dynamic-hover-color);
}
#category-block:hover {
border-color: var(--dynamic-hover-color);
}
#category-block:hover #category-name {
color: var(--dynamic-hover-color);
}
</style>
<script>
window.portalConfig = {
portalSlug: '<%= @portal.slug %>',
portalColor: '<%= @portal.color %>',
theme: '<%= @theme_from_params %>',
customDomain: '<%= @portal.custom_domain %>',
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
localeCode: '<%= @locale %>',
searchTranslations: {
searchPlaceholder: '<%= I18n.t('public_portal.search.search_placeholder') %>',
emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>',
loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>',
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>',
},
isPlainLayoutEnabled: '<%= @is_plain_layout_enabled %>',
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
};
</script>
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
<%= @portal.channel_web_widget.web_widget_script.html_safe %>
<% end %>
</html>
@@ -1,4 +1,4 @@
content = article.content.to_s.squish
content = ChatwootMarkdownRenderer.new(article.content.to_s).render_markdown_to_plain_text.squish
snippet = excerpt(content, search_query, radius: 110, omission: '...') ||
truncate(content, length: 220, separator: ' ')
@@ -2,8 +2,11 @@
<% if author_count > 0 %>
<div class="flex flex-row items-center gap-1">
<div class="flex items-center ltr:flex-row rtl:flex-row-reverse -space-x-2">
<% category.articles.published.order(position: :asc).map(&:author).uniq.take(3).each do |author| %>
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
<% z_classes = %w[z-30 z-20 z-10] %>
<% category.articles.published.order(position: :asc).map(&:author).uniq.take(3).each_with_index do |author, idx| %>
<span class="relative <%= z_classes[idx] %>">
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
</span>
<% end %>
</div>
@@ -18,7 +18,7 @@
<%= portal.header_text %>
</h1>
<p class="text-slate-600 dark:text-slate-200 text-start text-lg leading-normal pt-2 pb-4"><%= I18n.t('public_portal.hero.sub_title') %></p>
<div id="search-wrap" class="w-full"></div>
<div id="search-wrap" class="w-full relative z-30"></div>
</div>
</div>
</section>
@@ -6,7 +6,7 @@
</h3>
</div>
<div class="-mt-4">
<% portal.articles.published.where(category_id: nil).order(position: :asc).take(5).each do |article| %>
<% portal.articles.published.where(category_id: nil, locale: @locale).order(position: :asc).take(5).each do |article| %>
<a
class="leading-7 text-slate-700 dark:text-slate-100"
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
@@ -21,7 +21,7 @@
<% end %>
</div>
<div class="flex justify-between flex-row items-center <%= !@is_plain_layout_enabled && 'px-2' %>">
<span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: portal.articles.published.where(category_id: nil).size %></span>
<span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: portal.articles.published.where(category_id: nil, locale: @locale).size %></span>
</div>
</div>
</section>
@@ -0,0 +1,44 @@
<% content_for :head do %>
<%= render 'public/api/v1/portals/documentation_layout/articles/meta_head',
article: @article, portal: @portal, og_image_url: @og_image_url %>
<% end %>
<div class="px-6 md:px-10 py-8 md:py-10">
<div class="flex gap-10">
<article class="flex-1 min-w-0">
<%= render 'public/api/v1/portals/documentation_layout/breadcrumb',
portal: @portal, locale: @locale, category: @article.category, article: @article %>
<%= render 'public/api/v1/portals/documentation_layout/articles/header',
portal: @portal, article: @article %>
<div id="cw-article-content"
class="prose dark:prose-invert max-w-none break-words
prose-headings:font-620
prose-p:font-420 prose-li:font-420 prose-blockquote:font-420 prose-td:font-420
prose-a:text-n-portal prose-a:no-underline hover:prose-a:underline
[&_li>p]:m-0
[&_.tableWrapper]:overflow-x-auto [&_.tableWrapper]:mb-4 [&_.tableWrapper]:rounded-lg [&_.tableWrapper]:border [&_.tableWrapper]:border-solid [&_.tableWrapper]:border-n-weak
[&_table]:!my-0 [&_table]:!min-w-full [&_table]:!border-separate [&_table]:!border-spacing-0
[&_th]:!bg-n-slate-2 [&_th]:!text-n-slate-12 [&_th]:!font-semibold [&_th]:!text-start [&_th]:!px-3 [&_th]:!py-2 [&_th]:!border-0 [&_th]:!border-b [&_th]:!border-solid [&_th]:!border-n-weak
[&_td]:!text-n-slate-11 [&_td]:!align-top [&_td]:!text-start [&_td]:!px-3 [&_td]:!py-2 [&_td]:!border-0 [&_td]:!border-b [&_td]:!border-solid [&_td]:!border-n-weak
[&_th:not(:last-child)]:!border-e [&_td:not(:last-child)]:!border-e
[&_tr:last-child_td]:!border-b-0">
<%= @parsed_content %>
</div>
<% if @article.category %>
<footer class="mt-20 pt-8 border-t border-n-weak flex items-center justify-between gap-4 flex-wrap">
<a href="<%= public_portal_category_path(@portal.slug, @article.category.locale, @article.category.slug) %>"
class="group inline-flex items-center gap-2 px-3 py-2 -ml-3 text-sm font-medium text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 rounded-md transition">
<span class="i-lucide-arrow-left size-3.5 transition-transform group-hover:-translate-x-0.5" aria-hidden="true"></span>
<%= I18n.t('public_portal.sidebar.back_to_category', name: @article.category.name) %>
</a>
</footer>
<% end %>
</article>
<aside class="hidden lg:block w-56 shrink-0">
<div class="sticky top-20" id="cw-hc-toc"></div>
</aside>
</div>
</div>
@@ -0,0 +1,25 @@
<% content_for :head do %>
<%= render 'public/api/v1/portals/documentation_layout/categories/meta_head',
category: @category, portal: @portal, og_image_url: @og_image_url %>
<% end %>
<div class="px-6 md:px-10 py-8 md:py-10">
<%= render 'public/api/v1/portals/documentation_layout/breadcrumb',
portal: @portal, locale: @locale, category: @category %>
<%= render 'public/api/v1/portals/documentation_layout/categories/header',
category: @category, articles: @articles, category_authors: @category_authors %>
<% if @articles.empty? %>
<%= render 'public/api/v1/portals/documentation_layout/empty_state' %>
<% else %>
<div class="grid grid-cols-1 gap-4">
<% @articles.each do |article| %>
<%= render 'public/api/v1/portals/documentation_layout/article_card',
portal: @portal,
article: article,
show_category: false %>
<% end %>
</div>
<% end %>
</div>
@@ -0,0 +1,12 @@
<%# locals: (portal:, article:, show_category: true) %>
<a href="<%= public_portal_article_path(portal.slug, article.slug) %>"
class="group flex flex-col p-5 rounded-xl border border-solid border-n-weak hover:border-n-portal hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
<% if show_category && article.category %>
<span class="inline-flex items-center gap-1 self-start text-xs font-medium text-n-slate-11 mb-3">
<% if article.category.icon.present? %><span><%= article.category.icon %></span><% end %>
<%= article.category.name %>
</span>
<% end %>
<h3 class="text-base font-620 text-n-slate-12 group-hover:text-n-portal transition"><%= article.title %></h3>
<p class="mt-2 text-sm leading-relaxed text-n-slate-11 line-clamp-2"><%= render_category_content(article.content) %></p>
</a>
@@ -0,0 +1,10 @@
<%# locals: (users:, size: :sm, overlap: '-ml-1.5') %>
<% z_classes = %w[z-30 z-20 z-10] %>
<span class="inline-flex items-center">
<% users.first(3).each_with_index do |user, idx| %>
<%= render 'public/api/v1/portals/documentation_layout/user_avatar',
user: user,
size: size,
extra_class: "relative #{z_classes[idx]} #{idx.positive? ? overlap : ''}" %>
<% end %>
</span>
@@ -0,0 +1,31 @@
<%# locals: (portal:, locale:, category: nil, article: nil) %>
<nav class="flex items-center gap-2 text-sm text-n-slate-11 mb-8 flex-wrap">
<a
href="<%= public_portal_locale_path(portal.slug, locale) %>"
class="inline-flex items-center gap-1.5 hover:text-n-slate-12 transition"
>
<span class="i-lucide-house size-3.5" aria-hidden="true"></span>
<%= I18n.t('public_portal.common.home') %>
</a>
<% if category %>
<span class="i-lucide-chevron-right size-3 text-n-slate-9" aria-hidden="true"></span>
<% if article %>
<a
href="<%= public_portal_category_path(portal.slug, category.locale, category.slug) %>"
class="inline-flex items-center gap-1.5 hover:text-n-slate-12 transition"
>
<% if category.icon.present? %><span><%= category.icon %></span><% end %>
<%= category.name %>
</a>
<% else %>
<span class="inline-flex items-center gap-1.5 text-n-slate-12 font-medium truncate">
<% if category.icon.present? %><span><%= category.icon %></span><% end %>
<%= category.name %>
</span>
<% end %>
<% end %>
<% if article %>
<span class="i-lucide-chevron-right size-3 text-n-slate-9" aria-hidden="true"></span>
<span class="text-n-slate-12 font-medium truncate"><%= article.title %></span>
<% end %>
</nav>
@@ -0,0 +1,24 @@
<%# locals: (portal:, category:, contributors: []) %>
<% contributors = Array(contributors) %>
<a href="<%= public_portal_category_path(portal.slug, category.locale, category.slug) %>"
class="group relative flex flex-col p-5 rounded-xl border border-solid border-n-weak hover:border-n-portal hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
<span class="flex-shrink-0 w-11 h-11 flex items-center justify-center text-2xl rounded-xl bg-n-slate-2 border border-solid border-n-weak">
<%= category.icon.presence || '📁' %>
</span>
<h3 class="mt-4 text-lg font-620 tracking-tight text-n-slate-12 group-hover:text-n-portal transition"><%= category.name %></h3>
<% if category.description.present? %>
<p class="mt-1.5 text-sm leading-relaxed text-n-slate-11"><%= category.description %></p>
<% end %>
<div class="mt-auto pt-5 flex items-center justify-between">
<% if contributors.any? %>
<%= render 'public/api/v1/portals/documentation_layout/avatar_group',
users: contributors, size: :sm, overlap: '-ml-1.5' %>
<% else %>
<span></span>
<% end %>
<span class="inline-flex items-center gap-1 text-sm font-medium text-n-slate-11 group-hover:text-n-portal transition">
<%= I18n.t('public_portal.sidebar.browse') %>
<span class="i-lucide-arrow-right size-3.5 group-hover:translate-x-0.5 transition-transform" aria-hidden="true"></span>
</span>
</div>
</a>
@@ -0,0 +1,3 @@
<div class="rounded-xl border border-dashed border-n-weak p-12">
<p class="text-base text-n-slate-11"><%= local_assigns[:message] || I18n.t('public_portal.common.no_articles') %></p>
</div>
@@ -0,0 +1,53 @@
<%# locals: (portal:, global_config:) %>
<%
socials = portal.social_profiles
social_definitions = [
{ key: 'facebook', icon: 'i-ri-facebook-circle-fill', label: 'Facebook', base_url: 'https://facebook.com/' },
{ key: 'x', icon: 'i-ri-twitter-x-fill', label: 'X', base_url: 'https://x.com/' },
{ key: 'instagram', icon: 'i-ri-instagram-fill', label: 'Instagram', base_url: 'https://instagram.com/' },
{ key: 'linkedin', icon: 'i-ri-linkedin-box-fill', label: 'LinkedIn', base_url: 'https://linkedin.com/' },
{ key: 'youtube', icon: 'i-ri-youtube-fill', label: 'YouTube', base_url: 'https://youtube.com/' },
{ key: 'tiktok', icon: 'i-ri-tiktok-fill', label: 'TikTok', base_url: 'https://tiktok.com/' },
{ key: 'github', icon: 'i-ri-github-fill', label: 'GitHub', base_url: 'https://github.com/' },
{ key: 'whatsapp', icon: 'i-ri-whatsapp-fill', label: 'WhatsApp', base_url: 'https://wa.me/' }
]
active_socials = social_definitions.filter_map do |social|
handle = socials[social[:key]].to_s.strip
social.merge(url: "#{social[:base_url]}#{handle}") if handle.present?
end
show_branding = !portal.account.feature_enabled?('disable_branding')
%>
<% if show_branding || active_socials.any? %>
<footer class="mt-auto px-6 md:px-10 py-6 border-t border-solid border-n-weak flex items-center gap-4 flex-wrap <%= show_branding ? 'justify-between' : 'justify-end' %>">
<% if show_branding %>
<a href="<%= generate_portal_brand_url(global_config['BRAND_URL'], request.referer) %>"
target="_blank"
rel="noopener noreferrer nofollow"
class="inline-flex items-center gap-2 text-xs text-n-slate-10 hover:text-n-slate-12 transition">
<%= I18n.t('public_portal.footer.made_with') %>
<span class="inline-flex items-center gap-1.5 font-medium text-n-slate-11">
<% if global_config['LOGO_THUMBNAIL'].present? %>
<img class="w-3.5 h-3.5 rounded-sm" alt="<%= global_config['BRAND_NAME'] %>" src="<%= global_config['LOGO_THUMBNAIL'] %>">
<% end %>
<%= global_config['INSTALLATION_NAME'] %>
</span>
</a>
<% end %>
<% if active_socials.any? %>
<div class="flex items-center gap-0.5">
<% active_socials.each do |social| %>
<a href="<%= social[:url] %>"
target="_blank"
rel="noopener noreferrer nofollow"
class="p-2 rounded-md text-n-slate-10 hover:text-n-slate-12 hover:bg-n-alpha-2 transition"
aria-label="<%= social[:label] %>"
title="<%= social[:label] %>">
<span class="<%= social[:icon] %> size-4 block" aria-hidden="true"></span>
</a>
<% end %>
</div>
<% end %>
</footer>
<% end %>

Some files were not shown because too many files have changed in this diff Show More