Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41670bfae9 | ||
|
|
1984bddb11 | ||
|
|
bba230a1ac | ||
|
|
10f9d61d02 | ||
|
|
b1db6c3e9b | ||
|
|
3d20a7b049 | ||
|
|
3cd8cf43ce | ||
|
|
f33e469e9a | ||
|
|
27f2c2b392 | ||
|
|
40deaef458 | ||
|
|
3fae800936 | ||
|
|
1913ccadfa | ||
|
|
bca95efb82 | ||
|
|
6560dbb68d | ||
|
|
497d34c097 | ||
|
|
4371793741 | ||
|
|
64585faff0 | ||
|
|
e05008e0a4 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ class AsyncDispatcher < BaseDispatcher
|
||||
InstallationWebhookListener.instance,
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
Conversations::UnreadCounts::Listener.instance,
|
||||
ReportingEventListener.instance,
|
||||
WebhookListener.instance
|
||||
]
|
||||
|
||||
@@ -17,6 +17,7 @@ module FileTypeHelper
|
||||
def image_file?(content_type)
|
||||
[
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+290
@@ -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
@@ -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">
|
||||
|
||||
+10
@@ -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 |
+18
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
+48
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user