Compare commits

..
1164 changed files with 14154 additions and 18330 deletions
+1 -11
View File
@@ -4,15 +4,5 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
# Do the set up required for chatwoot app # Do the set up required for chatwoot app
WORKDIR /workspace WORKDIR /workspace
# Copy dependency files first for better caching
COPY package.json pnpm-lock.yaml ./
COPY Gemfile Gemfile.lock ./
# Install dependencies (will be cached if files don't change)
RUN pnpm install --frozen-lockfile && \
gem install bundler && \
bundle install --jobs=$(nproc)
# Copy source code after dependencies are installed
COPY . /workspace COPY . /workspace
RUN yarn && gem install bundler && bundle install
+42 -65
View File
@@ -1,16 +1,12 @@
ARG VARIANT="ubuntu-22.04"
ARG VARIANT
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
ENV DEBIAN_FRONTEND=noninteractive
ARG NODE_VERSION ARG NODE_VERSION
ARG RUBY_VERSION ARG RUBY_VERSION
ARG USER_UID ARG USER_UID
ARG USER_GID ARG USER_GID
ARG PNPM_VERSION="10.2.0"
ENV PNPM_VERSION ${PNPM_VERSION}
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. # Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
@@ -19,80 +15,61 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
&& chmod -R $USER_UID:$USER_GID /home/vscode; \ && chmod -R $USER_UID:$USER_GID /home/vscode; \
fi fi
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ && apt-get -y install --no-install-recommends \
&& apt-get update \ build-essential \
&& apt-get -y install --no-install-recommends \ libssl-dev \
build-essential \ zlib1g-dev \
libssl-dev \ gnupg2 \
zlib1g-dev \ tar \
gnupg \ tzdata \
tar \ postgresql-client \
tzdata \ libpq-dev \
postgresql-client \ yarn \
libpq-dev \ git \
git \ imagemagick \
imagemagick \ tmux \
libyaml-dev \ zsh \
curl \ git-flow \
ca-certificates \ npm \
tmux \ libyaml-dev
nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install rbenv and ruby for root user first # Install rbenv and ruby
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \ RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \ && echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc && echo 'eval "$(rbenv init -)"' >> ~/.bashrc
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH" ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \ RUN git clone https://github.com/rbenv/ruby-build.git && \
PREFIX=/usr/local ./ruby-build/install.sh PREFIX=/usr/local ./ruby-build/install.sh
RUN rbenv install $RUBY_VERSION && \ RUN rbenv install $RUBY_VERSION && \
rbenv global $RUBY_VERSION && \ rbenv global $RUBY_VERSION && \
rbenv versions rbenv versions
# Set up rbenv for vscode user # Install overmind
RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \
&& su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \
&& su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION"
# Install overmind and gh in single layer
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
&& gunzip overmind.gz \ && gunzip overmind.gz \
&& mv overmind /usr/local/bin \ && sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind \ && chmod +x /usr/local/bin/overmind
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \ # Install gh
&& apt-get install -y --no-install-recommends gh \ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& apt-get clean \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && sudo apt update \
&& sudo apt install gh
# Do the set up required for chatwoot app # Do the set up required for chatwoot app
WORKDIR /workspace WORKDIR /workspace
RUN chown vscode:vscode /workspace COPY . /workspace
# set up node js, pnpm and claude code in single layer # set up ruby
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \ COPY Gemfile Gemfile.lock ./
&& npm cache clean --force RUN gem install bundler && bundle install
# Switch to vscode user # set up node js
USER vscode RUN npm install n -g && \
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH" n $NODE_VERSION
RUN npm install --global yarn
# Copy dependency files first for better caching RUN yarn
COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./
# Install dependencies as vscode user
RUN eval "$(rbenv init -)" \
&& gem install bundler -N \
&& bundle install --jobs=$(nproc) \
&& pnpm install --frozen-lockfile
# Copy source code after dependencies are installed
COPY --chown=vscode:vscode . /workspace
+8 -17
View File
@@ -4,26 +4,17 @@
"dockerComposeFile": "docker-compose.yml", "dockerComposeFile": "docker-compose.yml",
"settings": { "settings": {
"terminal.integrated.shell.linux": "/bin/zsh", "terminal.integrated.shell.linux": "/bin/zsh"
"extensions.showRecommendationsOnlyOnDemand": true,
"editor.formatOnSave": true,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"search.exclude": {
"**/node_modules": true,
"**/tmp": true,
"**/log": true,
"**/coverage": true,
"**/public/packs": true
}
}, },
// Add the IDs of extensions you want installed when the container is created. // Add the IDs of extensions you want installed when the container is created.
"extensions": [ "extensions": [
"Shopify.ruby-lsp", "rebornix.Ruby",
"misogi.ruby-rubocop", "misogi.ruby-rubocop",
"wingrunr21.vscode-ruby",
"davidpallinder.rails-test-runner", "davidpallinder.rails-test-runner",
"eamodio.gitlens",
"github.copilot", "github.copilot",
"mrmlnc.vscode-duplicate" "mrmlnc.vscode-duplicate"
], ],
@@ -32,15 +23,15 @@
// 5432 postgres // 5432 postgres
// 6379 redis // 6379 redis
// 1025,8025 mailhog // 1025,8025 mailhog
"forwardPorts": [8025, 3000, 3036], "forwardPorts": [8025, 3000, 3035],
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install", "postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn",
"portsAttributes": { "portsAttributes": {
"3000": { "3000": {
"label": "Rails Server" "label": "Rails Server"
}, },
"3036": { "3035": {
"label": "Vite Dev Server" "label": "Webpack Dev Server"
}, },
"8025": { "8025": {
"label": "Mailhog UI" "label": "Mailhog UI"
-18
View File
@@ -1,18 +0,0 @@
# Docker Compose file for building the base image in GitHub Actions
# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base
version: '3'
services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: ghcr.io/chatwoot/chatwoot_codespace:latest
+13
View File
@@ -5,6 +5,19 @@
version: '3' version: '3'
services: services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: base:latest
app: app:
build: build:
context: .. context: ..
+7 -10
View File
@@ -2,15 +2,12 @@ cp .env.example .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# Setup Claude Code API key if available # uncomment the webpacker env variable
if [ -n "$CLAUDE_CODE_API_KEY" ]; then sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
mkdir -p ~/.claude # fix the error with webpacker
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
chmod +x ~/.claude/anthropic_key.sh
fi
# codespaces make the ports public # codespaces make the ports public
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME
@@ -19,5 +19,6 @@ jobs:
- name: Build the Codespace Base Image - name: Build the Codespace Base Image
run: | run: |
docker compose -f .devcontainer/docker-compose.base.yml build base docker-compose -f .devcontainer/docker-compose.yml build base
docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest
docker push ghcr.io/chatwoot/chatwoot_codespace:latest docker push ghcr.io/chatwoot/chatwoot_codespace:latest
+1 -8
View File
@@ -41,15 +41,8 @@ run:
force_run: force_run:
rm -f ./.overmind.sock rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.tunnel
debug: debug:
overmind connect backend overmind connect backend
@@ -59,4 +52,4 @@ debug_worker:
docker: docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile . docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker .PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
-4
View File
@@ -1,4 +0,0 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch
+1 -12
View File
@@ -10,8 +10,7 @@ function toggleSecretField(e) {
if (!textElement) return; if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') { if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10; textElement.textContent = '•'.repeat(10);
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true'; textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show'); toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
@@ -33,13 +32,3 @@ function copySecretField(e) {
navigator.clipboard.writeText(secretField.dataset.secretText); navigator.clipboard.writeText(secretField.dataset.secretText);
} }
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});
@@ -46,25 +46,17 @@
.cell-data__secret-field { .cell-data__secret-field {
align-items: center; align-items: center;
color: $hint-grey;
display: flex; display: flex;
span { span {
flex: 0 0 auto; flex: 1;
} }
[data-secret-toggler], button {
[data-secret-copier] { margin-left: 5px;
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
svg { svg {
fill: currentColor; fill: currentColor;
height: 1.25rem;
width: 1.25rem;
} }
} }
} }
@@ -1,103 +0,0 @@
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :account, :params
# rubocop:disable Lint/MissingSuper
# the parent class has no initialize
def initialize(account:, params:)
@account = account
@params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
end
# rubocop:enable Lint/MissingSuper
def build
labels = account.labels.to_a
return [] if labels.empty?
report_data = collect_report_data
labels.map { |label| build_label_report(label, report_data) }
end
private
def collect_report_data
conversation_filter = build_conversation_filter
use_business_hours = use_business_hours?
{
conversation_counts: fetch_conversation_counts(conversation_filter),
resolved_counts: fetch_resolved_counts(conversation_filter),
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
}
end
def build_label_report(label, report_data)
{
id: label.id,
name: label.title,
conversations_count: report_data[:conversation_counts][label.title] || 0,
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
}
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
def build_conversation_filter
conversation_filter = { account_id: account.id }
conversation_filter[:created_at] = range if range.present?
conversation_filter
end
def fetch_conversation_counts(conversation_filter)
fetch_counts(conversation_filter)
end
def fetch_resolved_counts(conversation_filter)
# since the base query is ActsAsTaggableOn,
# the status :resolved won't automatically be converted to integer status
fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved]))
end
def fetch_counts(conversation_filter)
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
taggable_type: 'Conversation',
context: 'labels',
conversations: conversation_filter
)
.select('tags.name, COUNT(taggings.*) AS count')
.group('tags.name')
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
end
def fetch_metrics(conversation_filter, event_name, use_business_hours)
ReportingEvent
.joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
.joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
conversations: conversation_filter,
name: event_name,
taggings: { taggable_type: 'Conversation', context: 'labels' }
)
.group('tags.name')
.order('tags.name')
.select(
'tags.name',
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
)
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
end
end
@@ -29,11 +29,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def reset_access_token
@agent_bot.access_token.regenerate_token
@agent_bot.reload
end
private private
def agent_bot def agent_bot
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id)) .get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count @contacts_count = contacts.count
@contacts = fetch_contacts(contacts) @contacts = contacts.page(@current_page)
end end
def show; end def show; end
@@ -124,12 +124,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.save! @conversation.save!
end end
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
private private
def permitted_update_params def permitted_update_params
@@ -1,23 +1,32 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern include GoogleConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url( redirect_url = google_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/google/callback", redirect_uri: "#{base_url}/google/callback",
scope: scope, scope: 'email profile https://mail.google.com/',
response_type: 'code', response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online' access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
} }
) )
if redirect_url if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern include InstagramConcern
include Instagram::IntegrationHelper include Instagram::IntegrationHelper
before_action :check_authorization
def create def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -20,4 +21,10 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,5 +1,5 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy] before_action :fetch_hook, only: [:destroy]
def destroy def destroy
@@ -31,12 +31,6 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_created,
issue_data: { id: issue[:data][:identifier] },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
@@ -48,30 +42,17 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_linked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
def unlink_issue def unlink_issue
link_id = permitted_params[:link_id] link_id = permitted_params[:link_id]
issue_id = permitted_params[:issue_id]
issue = linear_processor_service.unlink_issue(link_id) issue = linear_processor_service.unlink_issue(link_id)
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_unlinked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
@@ -1,14 +0,0 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
private
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
end
end
@@ -1,19 +1,28 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
include MicrosoftConcern include MicrosoftConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url( redirect_url = microsoft_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: scope, scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
state: state,
prompt: 'consent' prompt: 'consent'
} }
) )
if redirect_url if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,21 +0,0 @@
class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include NotionConcern
def create
redirect_url = notion_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/notion/callback",
response_type: 'code',
owner: 'user',
state: state,
client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil)
}
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
end
@@ -1,23 +0,0 @@
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
before_action :check_authorization
protected
def scope
''
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def settings_params def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label) params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting)
end end
def check_signup_enabled def check_signup_enabled
@@ -38,11 +38,6 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok head :ok
end end
def reset_access_token
@user.access_token.regenerate_token
@user.reload
end
private private
def set_user def set_user
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label] before_action :prepare_builder_params, only: [:agent, :team, :inbox]
def agent def agent
render_report_with(V2::Reports::AgentSummaryBuilder) render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -14,10 +14,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::InboxSummaryBuilder) render_report_with(V2::Reports::InboxSummaryBuilder)
end end
def label
render_report_with(V2::Reports::LabelSummaryBuilder)
end
private private
def check_authorization def check_authorization
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private private
def scope def base_url
'email profile https://mail.google.com/' ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
end end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private private
def scope def base_url
'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email' ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
end end
@@ -1,21 +0,0 @@
module NotionConcern
extend ActiveSupport::Concern
def notion_client
app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret, {
site: 'https://api.notion.com',
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
token_url: 'https://api.notion.com/v1/oauth/token',
auth_scheme: :basic_auth
})
end
private
def scope
'read'
end
end
+1 -1
View File
@@ -15,7 +15,7 @@ class DashboardController < ActionController::Base
private private
def ensure_html_format def ensure_html_format
render json: { error: 'Please use API routes instead of dashboard routes for JSON requests' }, status: :not_acceptable if request.format.json? head :not_acceptable unless request.format.html?
end end
def set_global_config def set_global_config
@@ -1,36 +0,0 @@
class Notion::CallbacksController < OauthCallbackController
include NotionConcern
private
def provider_name
'notion'
end
def oauth_client
notion_client
end
def handle_response
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'notion',
settings: {
token_type: parsed_body['token_type'],
workspace_name: parsed_body['workspace_name'],
workspace_id: parsed_body['workspace_id'],
workspace_icon: parsed_body['workspace_icon'],
bot_id: parsed_body['bot_id'],
owner: parsed_body['owner']
}
)
hook.save!
redirect_to notion_redirect_uri
end
def notion_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
end
end
+8 -9
View File
@@ -6,6 +6,7 @@ class OauthCallbackController < ApplicationController
) )
handle_response handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/' redirect_to '/'
@@ -63,10 +64,13 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError raise NotImplementedError
end end
def cache_key
"#{provider_name}::#{users_data['email'].downcase}"
end
def create_channel_with_inbox def create_channel_with_inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account) channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!( account.inboxes.create!(
account: account, account: account,
channel: channel_email, channel: channel_email,
@@ -81,17 +85,12 @@ class OauthCallbackController < ApplicationController
decoded_token[0] decoded_token[0]
end end
def account_from_signed_id def account_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? ::Redis::Alfred.get(cache_key)
account = GlobalID::Locator.locate_signed(params[:state])
raise 'Invalid or expired state' if account.nil?
account
end end
def account def account
@account ||= account_from_signed_id @account ||= Account.find(account_id)
end end
# Fallback name, for when name field is missing from users_data # Fallback name, for when name field is missing from users_data
@@ -1,7 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController 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]
before_action :portal before_action :portal
before_action :set_category, except: [:index, :show, :tracking_pixel] before_action :set_category, except: [:index, :show]
before_action :set_article, only: [:show] before_action :set_article, only: [:show]
layout 'portal' layout 'portal'
@@ -15,21 +15,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show; end def show; end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
return head :not_found unless @article
@article.increment_view_count if @article.published?
# Serve the 1x1 tracking pixel with 24-hour private cache
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
expires_in 24.hours, public: false
response.headers['Content-Type'] = 'image/png'
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
send_file pixel_path, type: 'image/png', disposition: 'inline'
end
private private
def limit_results def limit_results
@@ -54,6 +39,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content) @parsed_content = render_article_content(@article.content)
end end
@@ -2,13 +2,6 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior # Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated. # For example, you may want to send an email after a foo is updated.
# #
# Since account/user page - account user role attribute links to the show page
# Handle with a redirect to the user show page
def show
redirect_to super_admin_user_path(requested_resource.user)
end
def create def create
resource = resource_class.new(resource_params) resource = resource_class.new(resource_params)
authorize_resource(resource) authorize_resource(resource)
@@ -39,7 +39,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
} }
@@ -36,13 +36,9 @@ module Api::V2::Accounts::ReportsHelper
end end
def generate_labels_report def generate_labels_report
reports = V2::Reports::LabelSummaryBuilder.new( Current.account.labels.map do |label|
account: Current.account, label_report = report_builder({ type: :label, id: label.id }).short_summary
params: build_params({}) [label.title] + generate_readable_report_metrics(label_report)
).build
reports.map do |report|
[report[:name]] + generate_readable_report_metrics(report)
end end
end end
@@ -21,10 +21,6 @@ class AgentBotsAPI extends ApiClient {
deleteAgentBotAvatar(botId) { deleteAgentBotAvatar(botId) {
return axios.delete(`${this.url}/${botId}/avatar`); return axios.delete(`${this.url}/${botId}/avatar`);
} }
resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`);
}
} }
export default new AgentBotsAPI(); export default new AgentBotsAPI();
+11 -15
View File
@@ -38,7 +38,13 @@ export default {
} }
return false; return false;
}, },
profileUpdate({ displayName, avatar, ...profileAttributes }) { profileUpdate({
password,
password_confirmation,
displayName,
avatar,
...profileAttributes
}) {
const formData = new FormData(); const formData = new FormData();
Object.keys(profileAttributes).forEach(key => { Object.keys(profileAttributes).forEach(key => {
const hasValue = profileAttributes[key] === undefined; const hasValue = profileAttributes[key] === undefined;
@@ -47,22 +53,16 @@ export default {
} }
}); });
formData.append('profile[display_name]', displayName || ''); formData.append('profile[display_name]', displayName || '');
if (password && password_confirmation) {
formData.append('profile[password]', password);
formData.append('profile[password_confirmation]', password_confirmation);
}
if (avatar) { if (avatar) {
formData.append('profile[avatar]', avatar); formData.append('profile[avatar]', avatar);
} }
return axios.put(endPoints('profileUpdate').url, formData); return axios.put(endPoints('profileUpdate').url, formData);
}, },
profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) {
return axios.put(endPoints('profileUpdate').url, {
profile: {
current_password: currentPassword,
password,
password_confirmation: passwordConfirmation,
},
});
},
updateUISettings({ uiSettings }) { updateUISettings({ uiSettings }) {
return axios.put(endPoints('profileUpdate').url, { return axios.put(endPoints('profileUpdate').url, {
profile: { ui_settings: uiSettings }, profile: { ui_settings: uiSettings },
@@ -102,8 +102,4 @@ export default {
const urlData = endPoints('resendConfirmation'); const urlData = endPoints('resendConfirmation');
return axios.post(urlData.url); return axios.post(urlData.url);
}, },
resetAccessToken() {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
}; };
-5
View File
@@ -61,11 +61,6 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL); return axios.get(requestURL);
} }
active(page = 1, sortAttr = 'name') {
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
return axios.get(requestURL);
}
// eslint-disable-next-line default-param-last // eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) { filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`; let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
@@ -51,10 +51,6 @@ const endPoints = {
resendConfirmation: { resendConfirmation: {
url: '/api/v1/profile/resend_confirmation', url: '/api/v1/profile/resend_confirmation',
}, },
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
}; };
export default page => { export default page => {
@@ -134,12 +134,12 @@ class ConversationApi extends ApiClient {
return axios.get(`${this.url}/${conversationId}/attachments`); return axios.get(`${this.url}/${conversationId}/attachments`);
} }
getInboxAssistant(conversationId) { requestCopilot(conversationId, body) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`); return axios.post(`${this.url}/${conversationId}/copilot`, body);
} }
delete(conversationId) { getInboxAssistant(conversationId) {
return axios.delete(`${this.url}/${conversationId}`); return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
} }
} }
@@ -33,11 +33,9 @@ class LinearAPI extends ApiClient {
); );
} }
unlinkIssue(linkId, issueIdentifier, conversationId) { unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, { return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId, link_id: linkId,
issue_id: issueIdentifier,
conversation_id: conversationId,
}); });
} }
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class NotionOAuthClient extends ApiClient {
constructor() {
super('notion', { accountScoped: true });
}
generateAuthorization() {
return axios.post(`${this.url}/authorization`);
}
}
export default new NotionOAuthClient();
@@ -9,6 +9,5 @@ describe('#AgentBotsAPI', () => {
expect(AgentBotsAPI).toHaveProperty('create'); expect(AgentBotsAPI).toHaveProperty('create');
expect(AgentBotsAPI).toHaveProperty('update'); expect(AgentBotsAPI).toHaveProperty('update');
expect(AgentBotsAPI).toHaveProperty('delete'); expect(AgentBotsAPI).toHaveProperty('delete');
expect(AgentBotsAPI).toHaveProperty('resetAccessToken');
}); });
}); });
@@ -91,19 +91,6 @@ describe('#linearAPI', () => {
issueData issueData
); );
}); });
it('creates a valid request with conversation_id', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
conversation_id: 123,
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
}); });
describe('link_issue', () => { describe('link_issue', () => {
@@ -133,18 +120,6 @@ describe('#linearAPI', () => {
} }
); );
}); });
it('creates a valid request with title', () => {
LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 'ENG-123',
conversation_id: 1,
title: 'Sample Issue',
}
);
});
}); });
describe('getLinkedIssue', () => { describe('getLinkedIssue', () => {
@@ -189,26 +164,12 @@ describe('#linearAPI', () => {
window.axios = originalAxios; window.axios = originalAxios;
}); });
it('creates a valid request with link_id only', () => { it('creates a valid request', () => {
LinearAPIClient.unlinkIssue('link123'); LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue', '/api/v1/integrations/linear/unlink_issue',
{ {
link_id: 'link123', link_id: 1,
issue_id: undefined,
conversation_id: undefined,
}
);
});
it('creates a valid request with all parameters', () => {
LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: 'ENG-456',
conversation_id: 789,
} }
); );
}); });
@@ -35,16 +35,6 @@ class SummaryReportsAPI extends ApiClient {
}, },
}); });
} }
getLabelReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/label`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
} }
export default new SummaryReportsAPI(); export default new SummaryReportsAPI();
@@ -47,7 +47,7 @@
@apply max-w-full; @apply max-w-full;
.multiselect__option { .multiselect__option {
@apply text-sm font-normal flex justify-between items-center; @apply text-sm font-normal;
span { span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit; @apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
@@ -58,7 +58,7 @@
} }
&::after { &::after {
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight; @apply bottom-0 flex items-center justify-center text-center;
} }
&.multiselect__option--highlight { &.multiselect__option--highlight {
@@ -74,7 +74,7 @@
} }
&.multiselect__option--highlight::after { &.multiselect__option--highlight::after {
@apply bg-transparent text-n-slate-12; @apply bg-transparent;
} }
&.multiselect__option--selected { &.multiselect__option--selected {
@@ -101,7 +101,7 @@ select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>"); background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-size: 9px 6px; background-size: 9px 6px;
@apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3; @apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
&[disabled] { &[disabled] {
@apply field-disabled; @apply field-disabled;
@@ -3,7 +3,7 @@
} }
.tabs--container--with-border { .tabs--container--with-border {
@apply border-b border-b-n-weak; @apply border-b border-n-weak;
} }
.tabs--container--compact.tab--chat-type { .tabs--container--compact.tab--chat-type {
@@ -17,7 +17,6 @@ const props = defineProps({
additionalAttributes: { type: Object, default: () => ({}) }, additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' }, phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' }, thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false }, isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false },
}); });
@@ -93,13 +92,7 @@ const onClickViewDetails = () => emit('showContact', props.id);
<template> <template>
<CardLayout :key="id" layout="row"> <CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4"> <div class="flex items-center justify-start flex-1 gap-4">
<Avatar <Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1"> <div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1"> <div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12"> <span class="text-base font-medium truncate text-n-slate-12">
@@ -7,16 +7,42 @@ import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({ defineProps({
showSearch: { type: Boolean, default: true }, showSearch: {
searchValue: { type: String, default: '' }, type: Boolean,
headerTitle: { type: String, required: true }, default: true,
buttonLabel: { type: String, default: '' }, },
activeSort: { type: String, default: 'last_activity_at' }, searchValue: {
activeOrdering: { type: String, default: '' }, type: String,
isSegmentsView: { type: Boolean, default: false }, default: '',
hasActiveFilters: { type: Boolean, default: false }, },
isLabelView: { type: Boolean, default: false }, headerTitle: {
isActiveView: { type: Boolean, default: false }, type: String,
required: true,
},
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -59,7 +85,7 @@ const emit = defineEmits([
</Input> </Input>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative"> <div v-if="!isLabelView" class="relative">
<Button <Button
id="toggleContactsFilterButton" id="toggleContactsFilterButton"
:icon=" :icon="
@@ -79,12 +105,7 @@ const emit = defineEmits([
<slot name="filter" /> <slot name="filter" />
</div> </div>
<Button <Button
v-if=" v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save" icon="i-lucide-save"
color="slate" color="slate"
size="sm" size="sm"
@@ -92,7 +113,7 @@ const emit = defineEmits([
@click="emit('createSegment')" @click="emit('createSegment')"
/> />
<Button <Button
v-if="isSegmentsView && !isLabelView && !isActiveView" v-if="isSegmentsView && !isLabelView"
icon="i-lucide-trash" icon="i-lucide-trash"
color="slate" color="slate"
size="sm" size="sm"
@@ -36,7 +36,6 @@ const props = defineProps({
activeSegment: { type: Object, default: null }, activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false }, hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false }, isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -278,7 +277,6 @@ defineExpose({
:header-title="headerTitle" :header-title="headerTitle"
:is-segments-view="hasActiveSegments" :is-segments-view="hasActiveSegments"
:is-label-view="isLabelView" :is-label-view="isLabelView"
:is-active-view="isActiveView"
:has-active-filters="hasAppliedFilters" :has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')" :button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)" @search="emit('search', $event)"
@@ -6,7 +6,7 @@ import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/Contact
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue'; import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
const props = defineProps({ defineProps({
searchValue: { type: String, default: '' }, searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' }, headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true }, showPaginationFooter: { type: Boolean, default: true },
@@ -37,23 +37,10 @@ const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index'; return route.name !== 'contacts_dashboard_segments_index';
}); });
const isActiveView = computed(() => {
return route.name === 'contacts_dashboard_active';
});
const isLabelView = computed( const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index' () => route.name === 'contacts_dashboard_labels_index'
); );
const showActiveFiltersPreview = computed(() => {
return (
(props.hasAppliedFilters || !isNotSegmentView.value) &&
!props.isFetchingList &&
!isLabelView.value &&
!isActiveView.value
);
});
const updateCurrentPage = page => { const updateCurrentPage = page => {
emit('update:currentPage', page); emit('update:currentPage', page);
}; };
@@ -70,7 +57,7 @@ const openFilter = () => {
<div class="flex flex-col w-full h-full transition-all duration-300"> <div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper <ContactListHeaderWrapper
ref="contactListHeaderWrapper" ref="contactListHeaderWrapper"
:show-search="isNotSegmentView && !isActiveView" :show-search="isNotSegmentView"
:search-value="searchValue" :search-value="searchValue"
:active-sort="activeSort" :active-sort="activeSort"
:active-ordering="activeOrdering" :active-ordering="activeOrdering"
@@ -79,7 +66,6 @@ const openFilter = () => {
:segments-id="segmentsId" :segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters" :has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView" :is-label-view="isLabelView"
:is-active-view="isActiveView"
@update:sort="emit('update:sort', $event)" @update:sort="emit('update:sort', $event)"
@search="emit('search', $event)" @search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)" @apply-filter="emit('applyFilter', $event)"
@@ -88,7 +74,11 @@ const openFilter = () => {
<main class="flex-1 overflow-y-auto"> <main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]"> <div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview <ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview" v-if="
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
:active-segment="activeSegment" :active-segment="activeSegment"
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
@open-filter="openFilter" @open-filter="openFilter"
@@ -71,7 +71,6 @@ const toggleExpanded = id => {
:thumbnail="contact.thumbnail" :thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber" :phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes" :additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id" :is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating" :is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)" @toggle="toggleExpanded(contact.id)"
@@ -1,87 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMapGetter } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
const { updateUISettings } = useUISettings();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open
);
const toggleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false,
});
};
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: true,
is_copilot_panel_open: false,
});
};
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
};
const keyboardEvents = {
'Alt+KeyO': {
action: toggleConversationSidebarToggle,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
ghost
slate
sm
class="!rounded-full"
:class="{
'bg-n-alpha-2': isContactSidebarOpen,
}"
icon="i-ph-user-bold"
@click="handleConversationSidebarToggle"
/>
<Button
v-if="showCopilotTab"
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
ghost
slate
class="!rounded-full"
:class="{
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
}"
sm
icon="i-woot-captain"
@click="handleCopilotSidebarToggle"
/>
</div>
</template>
@@ -4,14 +4,38 @@ import { computed, ref, watch, useSlots } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: {
label: { type: String, default: '' }, type: String,
placeholder: { type: String, default: '' }, default: '',
focusOnMount: { type: Boolean, default: false }, },
maxLength: { type: Number, default: 200 }, label: {
showCharacterCount: { type: Boolean, default: true }, type: String,
disabled: { type: Boolean, default: false }, default: '',
message: { type: String, default: '' }, },
placeholder: {
type: String,
default: '',
},
focusOnMount: {
type: Boolean,
default: false,
},
maxLength: {
type: Number,
default: 200,
},
showCharacterCount: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
messageType: { messageType: {
type: String, type: String,
default: 'info', default: 'info',
@@ -19,7 +43,6 @@ const props = defineProps({
}, },
enableVariables: { type: Boolean, default: false }, enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true }, enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@@ -97,7 +120,6 @@ watch(
:disabled="disabled" :disabled="disabled"
:enable-variables="enableVariables" :enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses" :enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@@ -1,47 +0,0 @@
<script setup>
import Button from './button/Button.vue';
defineProps({
title: {
type: String,
required: true,
},
buttons: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['click', 'close']);
const handleButtonClick = button => {
emit('click', button.key);
};
</script>
<template>
<div
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">{{ title }}</span>
<div class="flex items-center">
<Button
v-for="button in buttons"
:key="button.key"
v-tooltip="button.tooltip"
:icon="button.icon"
ghost
sm
@click="handleButtonClick(button)"
/>
<Button
v-tooltip="$t('GENERAL.CLOSE')"
icon="i-lucide-x"
ghost
sm
@click="$emit('close')"
/>
</div>
</div>
</div>
</template>
@@ -1,41 +0,0 @@
<script setup>
import ConfirmButton from './ConfirmButton.vue';
import { ref } from 'vue';
const count = ref(0);
const incrementCount = () => {
count.value += 1;
};
</script>
<template>
<Story
title="Components/ConfirmButton"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Basic">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Delete"
confirm-label="Confirm?"
@click="incrementCount"
/>
</div>
</Variant>
<Variant title="Color Change">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Archive"
confirm-label="Confirm?"
color="slate"
confirm-color="amber"
@click="incrementCount"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,99 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import Button from './Button.vue';
const props = defineProps({
label: { type: [String, Number], default: '' },
confirmLabel: { type: [String, Number], default: '' },
color: { type: String, default: 'blue' },
confirmColor: { type: String, default: 'ruby' },
confirmHint: { type: String, default: '' },
variant: { type: String, default: null },
size: { type: String, default: null },
justify: { type: String, default: null },
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const isConfirmMode = ref(false);
const isClicked = ref(false);
const currentLabel = computed(() => {
return isConfirmMode.value ? props.confirmLabel : props.label;
});
const currentColor = computed(() => {
return isConfirmMode.value ? props.confirmColor : props.color;
});
const resetConfirmMode = () => {
isConfirmMode.value = false;
isClicked.value = false;
};
const handleClick = () => {
if (!isConfirmMode.value) {
isConfirmMode.value = true;
} else {
isClicked.value = true;
emit('click');
setTimeout(resetConfirmMode, 400);
}
};
</script>
<template>
<div
class="relative"
:class="{
'animate-bounce-complete': isClicked,
}"
>
<Button
type="button"
:label="currentLabel"
:color="currentColor"
:variant="variant"
:size="size"
:justify="justify"
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
@click="handleClick"
@blur="resetConfirmMode"
>
<template v-if="$slots.default" #default>
<slot />
</template>
<template v-if="$slots.icon" #icon>
<slot name="icon" />
</template>
</Button>
<div
v-if="isConfirmMode && confirmHint"
class="absolute mt-1 w-full text-[10px] text-center text-n-slate-10"
>
{{ confirmHint }}
</div>
</div>
</template>
<style scoped>
@keyframes bounce-complete {
0% {
transform: scale(0.95);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.animate-bounce-complete {
animation: bounce-complete 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
</style>
@@ -76,7 +76,7 @@ const handlePageChange = event => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6"> <header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full max-w-[60rem] mx-auto"> <div class="w-full max-w-[60rem] mx-auto">
<div <div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
@@ -116,7 +116,7 @@ const handlePageChange = event => {
</div> </div>
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[60rem] h-full mx-auto py-4"> <div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" /> <slot v-if="!showPaywall" name="controls" />
<div <div
@@ -1,24 +1,29 @@
<script setup> <script setup>
import { nextTick, ref, watch, computed } from 'vue'; import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useUISettings } from 'dashboard/composables/useUISettings';
import CopilotInput from './CopilotInput.vue'; import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue'; import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue'; import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue'; import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue'; import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import CopilotEmptyState from './CopilotEmptyState.vue'; import Icon from '../icon/Icon.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
supportAgent: {
type: Object,
default: () => ({}),
},
messages: { messages: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
isCaptainTyping: {
type: Boolean,
default: false,
},
conversationInboxType: { conversationInboxType: {
type: String, type: String,
required: true, required: true,
@@ -37,11 +42,22 @@ const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n(); const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => { const sendMessage = message => {
emit('sendMessage', message); emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE); useTrack(COPILOT_EVENTS.SEND_MESSAGE);
}; };
const useSuggestion = opt => {
emit('sendMessage', t(opt.prompt));
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const handleReset = () => {
emit('reset');
};
const chatContainer = ref(null); const chatContainer = ref(null);
const scrollToBottom = async () => { const scrollToBottom = async () => {
@@ -51,72 +67,23 @@ const scrollToBottom = async () => {
} }
}; };
const groupedMessages = computed(() => { const promptOptions = [
const result = []; {
let thinkingGroup = []; label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
props.messages.forEach(message => { prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
if (message.message_type === 'assistant_thinking') { },
thinkingGroup.push(message); {
} else { label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
if (thinkingGroup.length > 0) { prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
result.push({ },
id: thinkingGroup[0].id, {
message_type: 'thinking_group', label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
messages: thinkingGroup, prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
}); },
thinkingGroup = []; ];
}
result.push(message);
}
});
if (thinkingGroup.length > 0) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
}
return result;
});
const isLastMessageFromAssistant = computed(() => {
return (
groupedMessages.value[groupedMessages.value.length - 1].message_type ===
'assistant'
);
});
const { updateUISettings } = useUISettings();
const closeCopilotPanel = () => {
updateUISettings({
is_copilot_panel_open: false,
is_contact_sidebar_open: false,
});
};
const handleSidebarAction = action => {
if (action === 'reset') {
emit('reset');
}
};
const hasAssistants = computed(() => props.assistants.length > 0);
const hasMessages = computed(() => props.messages.length > 0);
const copilotButtons = computed(() => {
if (hasMessages.value) {
return [
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: t('CAPTAIN.COPILOT.RESET'),
},
];
}
return [];
});
watch( watch(
[() => props.messages], [() => props.messages, () => props.isCaptainTyping],
() => { () => {
scrollToBottom(); scrollToBottom();
}, },
@@ -126,59 +93,62 @@ watch(
<template> <template>
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full"> <div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<SidebarActionsHeader <div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
:title="$t('CAPTAIN.COPILOT.TITLE')" <template v-for="message in messages" :key="message.id">
:buttons="copilotButtons" <CopilotAgentMessage
@click="handleSidebarAction" v-if="message.role === 'user'"
@close="closeCopilotPanel" :support-agent="supportAgent"
/> :message="message"
<div />
ref="chatContainer" <CopilotAssistantMessage
class="flex-1 flex px-4 py-4 overflow-y-auto items-start" v-else-if="COPILOT_USER_ROLES.includes(message.role)"
> :message="message"
<div v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full"> :conversation-inbox-type="conversationInboxType"
<template v-for="(item, index) in groupedMessages" :key="item.id"> />
<CopilotAgentMessage </template>
v-if="item.message_type === 'user'"
:message="item.message"
/>
<CopilotAssistantMessage
v-else-if="item.message_type === 'assistant'"
:message="item.message"
:is-last-message="index === groupedMessages.length - 1"
:conversation-inbox-type="conversationInboxType"
/>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" /> <CopilotLoader v-if="isCaptainTyping" />
</div>
<div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div> </div>
<CopilotEmptyState
v-else
:has-assistants="hasAssistants"
@use-suggestion="sendMessage"
/>
</div> </div>
<div class="mx-3 mt-px mb-2"> <div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1"> <div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant <ToggleCopilotAssistant
v-if="assistants.length > 1" v-if="assistants.length"
:assistants="assistants" :assistants="assistants"
:active-assistant="activeAssistant" :active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)" @set-assistant="$event => emit('setAssistant', $event)"
/> />
<div v-else /> <div v-else />
<button
v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline"
@click="handleReset"
>
<i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button>
</div> </div>
<CopilotInput <CopilotInput class="mb-1 w-full" @send="sendMessage" />
v-if="hasAssistants"
class="mb-1 w-full"
@send="sendMessage"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -11,10 +11,6 @@ import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
isLastMessage: {
type: Boolean,
default: false,
},
message: { message: {
type: Object, type: Object,
required: true, required: true,
@@ -24,15 +20,6 @@ const props = defineProps({
required: true, required: true,
}, },
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const showUseButton = computed(() => {
return (
!hasEmptyMessageContent.value &&
props.message.reply_suggestion &&
props.isLastMessage
);
});
const messageContent = computed(() => { const messageContent = computed(() => {
const formatter = new MessageFormatter(props.message.content); const formatter = new MessageFormatter(props.message.content);
@@ -45,6 +32,8 @@ const insertIntoRichEditor = computed(() => {
); );
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => { const useCopilotResponse = () => {
if (insertIntoRichEditor.value) { if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content); emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -68,7 +57,7 @@ const useCopilotResponse = () => {
/> />
<div class="flex flex-row mt-1"> <div class="flex flex-row mt-1">
<Button <Button
v-if="showUseButton" v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')" :label="$t('CAPTAIN.COPILOT.USE')"
faded faded
sm sm
@@ -1,106 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Icon from '../icon/Icon.vue';
defineProps({
hasAssistants: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['useSuggestion']);
const { t } = useI18n();
const route = useRoute();
const routePromptMap = {
conversations: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
],
dashboard: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.CONTENT',
},
],
};
const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
return 'dashboard';
};
const promptOptions = computed(() => {
const currentRoute = getCurrentRoute();
return routePromptMap[currentRoute] || routePromptMap.conversations;
});
const handleSuggestion = opt => {
emit('useSuggestion', t(opt.prompt));
};
</script>
<template>
<div class="flex-1 flex flex-col gap-6 px-2">
<div class="flex flex-col space-y-4 py-4">
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
<div class="space-y-1">
<h3 class="text-base font-medium text-n-slate-12 leading-8">
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
</p>
</div>
</div>
<div v-if="!hasAssistants" class="w-full space-y-2">
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.ASSISTANTS.NO_ASSISTANTS_AVAILABLE') }}
</p>
<router-link
:to="{
name: 'captain_assistants_index',
params: { accountId: route.params.accountId },
}"
class="text-n-slate-11 underline hover:text-n-slate-12"
>
{{ $t('CAPTAIN.ASSISTANTS.ADD_NEW') }}
</router-link>
</div>
<div v-else class="w-full space-y-2">
<span class="text-xs text-n-slate-10 block">
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
</span>
<div class="space-y-1">
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
@click="handleSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
</div>
</div>
</template>
@@ -1,29 +1,21 @@
<script setup> <script setup>
import SidebarActionsHeader from './SidebarActionsHeader.vue'; import CopilotHeader from './CopilotHeader.vue';
</script> </script>
<template> <template>
<Story <Story
title="Components/SidebarActionsHeader" title="Captain/Copilot/CopilotHeader"
:layout="{ type: 'grid', width: '800px' }" :layout="{ type: 'grid', width: '800px' }"
> >
<!-- Default State --> <!-- Default State -->
<Variant title="Default State"> <Variant title="Default State">
<SidebarActionsHeader title="Default State" /> <CopilotHeader />
</Variant> </Variant>
<!-- With New Conversation Button --> <!-- With New Conversation Button -->
<Variant title="With New Conversation Button"> <Variant title="With New Conversation Button">
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand --> <!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
<SidebarActionsHeader <CopilotHeader :has-messages="true" />
title="With New Conversation Button"
:buttons="[
{
key: 'new_conversation',
icon: 'i-lucide-plus',
},
]"
/>
</Variant> </Variant>
</Story> </Story>
</template> </template>
@@ -0,0 +1,32 @@
<script setup>
import Button from '../button/Button.vue';
defineProps({
hasMessages: {
type: Boolean,
default: false,
},
});
defineEmits(['reset', 'close']);
</script>
<template>
<div
class="flex items-center justify-between px-5 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">
{{ $t('CAPTAIN.COPILOT.TITLE') }}
</span>
<div class="flex items-center">
<Button
v-if="hasMessages"
icon="i-lucide-plus"
ghost
sm
@click="$emit('reset')"
/>
<Button icon="i-lucide-x" ghost sm @click="$emit('close')" />
</div>
</div>
</div>
</template>
@@ -1,63 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
'inbox_view_conversation',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotLauncher = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
return (
isCaptainEnabled &&
!uiSettings.value.is_copilot_panel_open &&
!isConversationRoute.value
);
});
const toggleSidebar = () => {
updateUISettings({
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
is_contact_sidebar_open: false,
});
};
</script>
<template>
<div v-if="showCopilotLauncher" class="fixed bottom-4 right-4 z-50">
<div class="rounded-full bg-n-alpha-2 p-1">
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
<template v-else />
</template>
@@ -17,7 +17,7 @@ defineProps({
icon="i-lucide-sparkles" icon="i-lucide-sparkles"
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9" class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
/> />
<div class="text-sm text-n-slate-12"> <div class="text-sm text-n-slate-11">
{{ content }} {{ content }}
</div> </div>
</div> </div>
@@ -51,10 +51,10 @@ watch(
}" }"
> >
<CopilotThinkingBlock <CopilotThinkingBlock
v-for="copilotMessage in messages" v-for="message in messages"
:key="copilotMessage.id" :key="message.id"
:content="copilotMessage.message.content" :content="message.content"
:reasoning="copilotMessage.message.reasoning" :reasoning="message.reasoning"
/> />
</div> </div>
</div> </div>
@@ -74,7 +74,6 @@ const updateSelected = newValue => {
<slot name="trigger" :toggle="toggle"> <slot name="trigger" :toggle="toggle">
<Button <Button
ref="triggerRef" ref="triggerRef"
type="button"
sm sm
slate slate
:variant :variant
@@ -9,14 +9,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { const { options } = defineProps({
options,
disableSearch,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
} = defineProps({
options: { options: {
type: Array, type: Array,
required: true, required: true,
@@ -25,22 +18,6 @@ const {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
placeholderIcon: {
type: String,
default: 'i-lucide-plus',
},
placeholder: {
type: String,
default: '',
},
placeholderTrailingIcon: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -92,26 +69,15 @@ const toggleSelected = option => {
sm sm
slate slate
faded faded
type="button"
:icon="selectedItem.icon" :icon="selectedItem.icon"
:label="selectedItem.name" :label="selectedItem.name"
@click="toggle" @click="toggle"
/> />
<Button <Button v-else sm slate faded @click="toggle">
v-else
sm
slate
faded
type="button"
:trailing-icon="placeholderTrailingIcon"
@click="toggle"
>
<template #icon> <template #icon>
<Icon :icon="placeholderIcon" class="text-n-slate-11" /> <Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template> </template>
<span class="text-n-slate-11">{{ <span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
placeholder || t('COMBOBOX.PLACEHOLDER')
}}</span>
</Button> </Button>
</template> </template>
<DropdownBody class="top-0 min-w-56 z-50" strong> <DropdownBody class="top-0 min-w-56 z-50" strong>
@@ -121,7 +87,7 @@ const toggleSelected = option => {
v-model="searchTerm" v-model="searchTerm"
autofocus autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full" class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" :placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
/> />
</div> </div>
<DropdownSection class="max-h-80 overflow-scroll"> <DropdownSection class="max-h-80 overflow-scroll">
@@ -1,8 +1,7 @@
<script setup> <script setup>
import { computed, watch } from 'vue'; import { computed, ref } from 'vue';
import Input from './Input.vue'; import Input from './Input.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { DURATION_UNITS } from './constants';
const props = defineProps({ const props = defineProps({
min: { type: Number, default: 0 }, min: { type: Number, default: 0 },
@@ -12,50 +11,36 @@ const props = defineProps({
const { t } = useI18n(); const { t } = useI18n();
const duration = defineModel('modelValue', { type: Number, default: null }); const duration = defineModel('modelValue', { type: Number, default: null });
const unit = defineModel('unit', {
type: String,
default: DURATION_UNITS.MINUTES,
validate(value) {
return Object.values(DURATION_UNITS).includes(value);
},
});
const convertToMinutes = newValue => { const UNIT_TYPES = {
if (unit.value === DURATION_UNITS.MINUTES) { MINUTES: 'minutes',
return Math.floor(newValue); HOURS: 'hours',
} DAYS: 'days',
if (unit.value === DURATION_UNITS.HOURS) {
return Math.floor(newValue) * 60;
}
return Math.floor(newValue) * 24 * 60;
}; };
const unit = ref(UNIT_TYPES.MINUTES);
const transformedValue = computed({ const transformedValue = computed({
get() { get() {
if (unit.value === DURATION_UNITS.MINUTES) return duration.value; if (unit.value === UNIT_TYPES.MINUTES) return duration.value;
if (unit.value === DURATION_UNITS.HOURS) if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60);
return Math.floor(duration.value / 60); if (unit.value === UNIT_TYPES.DAYS)
if (unit.value === DURATION_UNITS.DAYS)
return Math.floor(duration.value / 24 / 60); return Math.floor(duration.value / 24 / 60);
return 0; return 0;
}, },
set(newValue) { set(newValue) {
let minuteValue = convertToMinutes(newValue); let minuteValue;
if (unit.value === UNIT_TYPES.MINUTES) {
minuteValue = Math.floor(newValue);
} else if (unit.value === UNIT_TYPES.HOURS) {
minuteValue = Math.floor(newValue * 60);
} else if (unit.value === UNIT_TYPES.DAYS) {
minuteValue = Math.floor(newValue * 24 * 60);
}
duration.value = Math.min(Math.max(minuteValue, props.min), props.max); duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
}, },
}); });
// when unit is changed set the nearest value to that unit
// so if the minute is set to 900, and the user changes the unit to "days"
// the transformed value will show 0, but the real value will still be 900
// this might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => {
let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
});
</script> </script>
<template> <template>
@@ -72,12 +57,10 @@ watch(unit, () => {
:disabled="disabled" :disabled="disabled"
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40" class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
> >
<option :value="DURATION_UNITS.MINUTES"> <option :value="UNIT_TYPES.MINUTES">
{{ t('DURATION_INPUT.MINUTES') }} {{ t('DURATION_INPUT.MINUTES') }}
</option> </option>
<option :value="DURATION_UNITS.HOURS"> <option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option>
{{ t('DURATION_INPUT.HOURS') }} <option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select> </select>
</template> </template>
@@ -1,5 +0,0 @@
export const DURATION_UNITS = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
};
@@ -379,7 +379,7 @@ const shouldRenderMessage = computed(() => {
function openContextMenu(e) { function openContextMenu(e) {
const shouldSkipContextMenu = const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') || e.target?.classList.contains('skip-context-menu') ||
['a', 'img'].includes(e.target?.tagName.toLowerCase()); e.target?.tagName.toLowerCase() === 'a';
if (shouldSkipContextMenu || getSelection().toString()) { if (shouldSkipContextMenu || getSelection().toString()) {
return; return;
} }
@@ -14,8 +14,7 @@ const fromEmail = computed(() => {
}); });
const toEmail = computed(() => { const toEmail = computed(() => {
const { toEmails, email } = contentAttributes.value; return contentAttributes.value?.email?.to ?? [];
return email?.to ?? toEmails ?? [];
}); });
const ccEmail = computed(() => { const ccEmail = computed(() => {
@@ -4,11 +4,9 @@ import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { CONTENT_TYPES } from '../constants.js'; import { CONTENT_TYPES } from '../constants.js';
import { useMessageContext } from '../provider.js'; import { useMessageContext } from '../provider.js';
import { useInbox } from 'dashboard/composables/useInbox';
const { content, contentAttributes, contentType } = useMessageContext(); const { content, contentAttributes, contentType } = useMessageContext();
const { t } = useI18n(); const { t } = useI18n();
const { isAWebWidgetInbox } = useInbox();
const formValues = computed(() => { const formValues = computed(() => {
if (contentType.value === CONTENT_TYPES.FORM) { if (contentType.value === CONTENT_TYPES.FORM) {
@@ -58,7 +56,7 @@ const formValues = computed(() => {
<dd>{{ item.title }}</dd> <dd>{{ item.title }}</dd>
</template> </template>
</dl> </dl>
<div v-else-if="isAWebWidgetInbox" class="my-2 font-medium"> <div v-else class="my-2 font-medium">
{{ t('CONVERSATION.NO_RESPONSE') }} {{ t('CONVERSATION.NO_RESPONSE') }}
</div> </div>
</BaseBubble> </BaseBubble>
@@ -109,58 +109,49 @@ const downloadAudio = async () => {
</audio> </audio>
<div <div
v-bind="$attrs" v-bind="$attrs"
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]" class="rounded-xl w-full gap-1 p-1.5 bg-n-alpha-white flex items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
> >
<div class="flex gap-1 w-full flex-1 items-center justify-start"> <button class="p-0 border-0 size-8" @click="playOrPause">
<button class="p-0 border-0 size-8" @click="playOrPause"> <Icon
<Icon v-if="isPlaying"
v-if="isPlaying" class="size-8"
class="size-8" icon="i-teenyicons-pause-small-solid"
icon="i-teenyicons-pause-small-solid" />
/> <Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" /> </button>
</button> <div class="tabular-nums text-xs">
<div class="tabular-nums text-xs"> {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div> </div>
<div class="flex-1 items-center flex px-2">
<div <input
v-if="attachment.transcribedText" type="range"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words" min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
> >
{{ attachment.transcribedText }} <span class="text-xs text-n-slate-11 font-medium">
</div> {{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div> </div>
</template> </template>
@@ -87,7 +87,7 @@ const newReportRoutes = () => [
{ {
name: 'Reports Label', name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'), label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports_index'), to: accountScopedRoute('label_reports'),
}, },
{ {
name: 'Reports Inbox', name: 'Reports Inbox',
@@ -235,12 +235,6 @@ const menuItems = computed(() => {
), ),
activeOn: ['contacts_dashboard_index', 'contacts_edit'], activeOn: ['contacts_dashboard_index', 'contacts_edit'],
}, },
{
name: 'Active',
label: t('SIDEBAR.ACTIVE'),
to: accountScopedRoute('contacts_dashboard_active'),
activeOn: ['contacts_dashboard_active'],
},
{ {
name: 'Segments', name: 'Segments',
icon: 'i-lucide-group', icon: 'i-lucide-group',
@@ -483,7 +477,7 @@ const menuItems = computed(() => {
<template> <template>
<aside <aside
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1" class="w-[12.5rem] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
> >
<section class="grid gap-2 mt-2 mb-4"> <section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2"> <div class="flex items-center min-w-0 gap-2 px-2">
@@ -525,7 +519,7 @@ const menuItems = computed(() => {
</div> </div>
</section> </section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar"> <nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
<ul class="flex flex-col gap-1.5 m-0 list-none"> <ul class="flex flex-col gap-2 m-0 list-none">
<SidebarGroup <SidebarGroup
v-for="item in menuItems" v-for="item in menuItems"
:key="item.name" :key="item.name"
@@ -22,7 +22,6 @@ import {
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable // https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue'; import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue'; import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue'; import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
@@ -83,7 +82,6 @@ const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings(); const { uiSettings } = useUISettings();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const route = useRoute();
const store = useStore(); const store = useStore();
const conversationListRef = ref(null); const conversationListRef = ref(null);
@@ -648,30 +646,6 @@ function openLastItemAfterDeleteInFolder() {
} }
} }
function redirectToConversationList() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
}
async function assignPriority(priority, conversationId = null) { async function assignPriority(priority, conversationId = null) {
store.dispatch('setCurrentChatPriority', { store.dispatch('setCurrentChatPriority', {
priority, priority,
@@ -696,7 +670,26 @@ async function markAsUnread(conversationId) {
await store.dispatch('markMessagesUnread', { await store.dispatch('markMessagesUnread', {
id: conversationId, id: conversationId,
}); });
redirectToConversationList(); const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = useRoute();
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
} catch (error) { } catch (error) {
// Ignore error // Ignore error
} }
@@ -710,7 +703,6 @@ async function markAsRead(conversationId) {
// Ignore error // Ignore error
} }
} }
async function onAssignTeam(team, conversationId = null) { async function onAssignTeam(team, conversationId = null) {
try { try {
await store.dispatch('assignTeam', { await store.dispatch('assignTeam', {
@@ -756,7 +748,6 @@ function toggleSelectAll(check) {
} }
useEmitter('fetch_conversation_stats', () => { useEmitter('fetch_conversation_stats', () => {
if (hasAppliedFiltersOrActiveFolders.value) return;
store.dispatch('conversationStats/get', conversationFilters.value); store.dispatch('conversationStats/get', conversationFilters.value);
}); });
@@ -773,26 +764,6 @@ onMounted(() => {
} }
}); });
const deleteConversationDialogRef = ref(null);
const selectedConversationId = ref(null);
async function deleteConversation() {
try {
await store.dispatch('deleteConversation', selectedConversationId.value);
redirectToConversationList();
selectedConversationId.value = null;
deleteConversationDialogRef.value.close();
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
const handleDelete = conversationId => {
selectedConversationId.value = conversationId;
deleteConversationDialogRef.value.open();
};
provide('selectConversation', selectConversation); provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation); provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent); provide('assignAgent', onAssignAgent);
@@ -804,7 +775,6 @@ provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead); provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority); provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected); provide('isConversationSelected', isConversationSelected);
provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData()); watch(activeTeam, () => resetAndFetchData());
@@ -844,7 +814,7 @@ watch(conversationFilters, (newVal, oldVal) => {
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap" class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
:class="[ :class="[
{ hidden: !showConversationList }, { hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]', isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
]" ]"
> >
<slot /> <slot />
@@ -854,8 +824,6 @@ watch(conversationFilters, (newVal, oldVal) => {
:has-active-folders="hasActiveFolders" :has-active-folders="hasActiveFolders"
:active-status="activeStatus" :active-status="activeStatus"
:is-on-expanded-layout="isOnExpandedLayout" :is-on-expanded-layout="isOnExpandedLayout"
:conversation-stats="conversationStats"
:is-list-loading="chatListLoading && !conversationList.length"
@add-folders="onClickOpenAddFoldersModal" @add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal" @delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal" @filters-modal="onToggleAdvanceFiltersModal"
@@ -970,19 +938,6 @@ watch(conversationFilters, (newVal, oldVal) => {
</template> </template>
</DynamicScroller> </DynamicScroller>
</div> </div>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: selectedConversationId,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
@close="selectedConversationId = null"
/>
<TeleportWithDirection <TeleportWithDirection
v-if="showAdvancedFilters" v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget" to="#conversationFilterTeleportTarget"
@@ -2,7 +2,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store.js'; import { useMapGetter } from 'dashboard/composables/store.js';
import { formatNumber } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -11,13 +10,26 @@ import SwitchLayout from 'dashboard/routes/dashboard/conversation/search/SwitchL
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
pageTitle: { type: String, required: true }, pageTitle: {
hasAppliedFilters: { type: Boolean, required: true }, type: String,
hasActiveFolders: { type: Boolean, required: true }, required: true,
activeStatus: { type: String, required: true }, },
isOnExpandedLayout: { type: Boolean, required: true }, hasAppliedFilters: {
conversationStats: { type: Object, required: true }, type: Boolean,
isListLoading: { type: Boolean, required: true }, required: true,
},
hasActiveFolders: {
type: Boolean,
required: true,
},
activeStatus: {
type: String,
required: true,
},
isOnExpandedLayout: {
type: Boolean,
required: true,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -50,9 +62,6 @@ const showV4View = computed(() => {
); );
}); });
const allCount = computed(() => props.conversationStats?.allCount || 0);
const formattedAllCount = computed(() => formatNumber(allCount.value));
const toggleConversationLayout = () => { const toggleConversationLayout = () => {
const { LAYOUT_TYPES } = wootConstants; const { LAYOUT_TYPES } = wootConstants;
const { const {
@@ -71,27 +80,20 @@ const toggleConversationLayout = () => {
<template> <template>
<div <div
class="flex items-center justify-between gap-2 px-3 h-12" class="flex items-center justify-between gap-2 px-4"
:class="{ :class="{
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders, 'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
'pt-3 pb-2': showV4View,
'mb-2 pb-0': !showV4View,
}" }"
> >
<div class="flex items-center justify-center min-w-0"> <div class="flex items-center justify-center min-w-0">
<h1 <h1
class="text-base font-medium truncate text-n-slate-12" class="text-lg font-medium truncate text-n-slate-12"
:title="pageTitle" :title="pageTitle"
> >
{{ pageTitle }} {{ pageTitle }}
</h1> </h1>
<span
v-if="
allCount > 0 && hasAppliedFiltersOrActiveFolders && !isListLoading
"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
:title="allCount"
>
{{ formattedAllCount }}
</span>
<span <span
v-if="!hasAppliedFiltersOrActiveFolders" v-if="!hasAppliedFiltersOrActiveFolders"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0" class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
@@ -16,7 +16,6 @@ export default {
'markAsRead', 'markAsRead',
'assignPriority', 'assignPriority',
'isConversationSelected', 'isConversationSelected',
'deleteConversation',
], ],
props: { props: {
source: { source: {
@@ -68,6 +67,5 @@ export default {
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@mark-as-read="markAsRead" @mark-as-read="markAsRead"
@assign-priority="assignPriority" @assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/> />
</template> </template>
@@ -20,7 +20,7 @@ export default {
<template> <template>
<div> <div>
<div <div
class="shadow-sm bg-n-slate-12 dark:bg-n-slate-7 rounded-lg items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left" class="shadow-sm bg-slate-800 dark:bg-slate-700 rounded-[4px] items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
> >
<div class="text-sm font-medium text-white dark:text-white"> <div class="text-sm font-medium text-white dark:text-white">
{{ message }} {{ message }}
@@ -29,7 +29,7 @@ export default {
<router-link <router-link
v-if="action.type == 'link'" v-if="action.type == 'link'"
:to="action.to" :to="action.to"
class="font-medium cursor-pointer select-none text-n-blue-10 hover:text-n-brand" class="font-medium cursor-pointer select-none text-woot-500 dark:text-woot-500 hover:text-woot-600 dark:hover:text-woot-600"
> >
{{ action.message }} {{ action.message }}
</router-link> </router-link>
@@ -1,72 +1,62 @@
<script setup> <script>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import WootSnackbar from './Snackbar.vue'; import WootSnackbar from './Snackbar.vue';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n';
const props = defineProps({ export default {
duration: { components: {
type: Number, WootSnackbar,
default: 2500, },
props: {
duration: {
type: Number,
default: 2500,
},
}, },
});
const { t } = useI18n(); data() {
return {
snackMessages: [],
};
},
const snackMessages = ref([]); mounted() {
const snackbarContainer = ref(null); emitter.on('newToastMessage', this.onNewToastMessage);
},
unmounted() {
emitter.off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage({ message: originalMessage, action }) {
// FIX ME: This is a temporary workaround to pass string from functions
// that doesn't have the context of the VueApp.
const usei18n = action?.usei18n;
const duration = action?.duration || this.duration;
const message = usei18n ? this.$t(originalMessage) : originalMessage;
const showPopover = () => { this.snackMessages.push({
try { key: new Date().getTime(),
const el = snackbarContainer.value; message,
if (el?.matches(':popover-open')) { action,
el.hidePopover(); });
} window.setTimeout(() => {
el?.showPopover(); this.snackMessages.splice(0, 1);
} catch (e) { }, duration);
// ignore },
} },
}; };
const onNewToastMessage = ({ message: originalMessage, action }) => {
const message = action?.usei18n ? t(originalMessage) : originalMessage;
const duration = action?.duration || props.duration;
snackMessages.value.push({
key: Date.now(),
message,
action,
});
nextTick(showPopover);
setTimeout(() => {
snackMessages.value.shift();
}, duration);
};
onMounted(() => {
emitter.on('newToastMessage', onNewToastMessage);
});
onUnmounted(() => {
emitter.off('newToastMessage', onNewToastMessage);
});
</script> </script>
<template> <template>
<div <transition-group
ref="snackbarContainer" name="toast-fade"
popover="manual" tag="div"
class="fixed top-4 left-1/2 -translate-x-1/2 max-w-[25rem] w-[calc(100%-2rem)] text-center bg-transparent border-0 p-0 m-0 outline-none overflow-visible" class="left-0 my-0 mx-auto max-w-[25rem] overflow-hidden absolute right-0 text-center top-4 z-[9999]"
> >
<transition-group name="toast-fade" tag="div"> <WootSnackbar
<WootSnackbar v-for="snackMessage in snackMessages"
v-for="snackMessage in snackMessages" :key="snackMessage.key"
:key="snackMessage.key" :message="snackMessage.message"
:message="snackMessage.message" :action="snackMessage.action"
:action="snackMessage.action" />
/> </transition-group>
</transition-group>
</div>
</template> </template>
@@ -134,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template> <template>
<div class="relative flex items-center justify-end resolve-actions"> <div class="relative flex items-center justify-end resolve-actions">
<div <div
class="rounded-lg shadow outline-1 outline flex-shrink-0" class="rounded-lg shadow outline-1 outline"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'" :class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
> >
<Button <Button
@@ -178,7 +178,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<div <div
v-if="showActionsDropdown" v-if="showActionsDropdown"
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 start-0 xl:start-auto xl:end-0 max-w-[12.5rem] min-w-[9.75rem]" class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 ltr:right-0 rtl:left-0 max-w-[12.5rem] min-w-[9.75rem]"
> >
<WootDropdownMenu class="mb-0"> <WootDropdownMenu class="mb-0">
<WootDropdownItem v-if="!isPending"> <WootDropdownItem v-if="!isPending">
@@ -1,11 +1,16 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted, watchEffect } from 'vue';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue'; import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import ConversationAPI from 'dashboard/api/inbox/conversation';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
defineProps({ const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
conversationInboxType: { conversationInboxType: {
type: String, type: String,
required: true, required: true,
@@ -15,25 +20,13 @@ defineProps({
const store = useStore(); const store = useStore();
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords'); const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const inboxAssistant = useMapGetter('getCopilotAssistant'); const inboxAssistant = useMapGetter('getCopilotAssistant');
const currentChat = useMapGetter('getSelectedChat');
const selectedCopilotThreadId = ref(null);
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](
selectedCopilotThreadId.value
)
);
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const selectedAssistantId = ref(null);
const { uiSettings, updateUISettings } = useUISettings(); const { uiSettings, updateUISettings } = useUISettings();
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null);
const activeAssistant = computed(() => { const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id; const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -62,57 +55,68 @@ const setAssistant = async assistant => {
}); });
}; };
const shouldShowCopilotPanel = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
const { is_copilot_panel_open: isCopilotPanelOpen } = uiSettings.value;
return isCaptainEnabled && isCopilotPanelOpen && !uiFlags.value.fetchingList;
});
const handleReset = () => { const handleReset = () => {
selectedCopilotThreadId.value = null; messages.value = [];
}; };
const sendMessage = async message => { const sendMessage = async message => {
if (selectedCopilotThreadId.value) { // Add user message
await store.dispatch('copilotMessages/create', { messages.value.push({
assistant_id: activeAssistant.value.id, id: messages.value.length + 1,
conversation_id: currentChat.value?.id, role: 'user',
threadId: selectedCopilotThreadId.value, content: message,
message, });
isCaptainTyping.value = true;
try {
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
assistant_id: selectedAssistantId.value,
}
);
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.message,
}); });
} else { } catch (error) {
const response = await store.dispatch('copilotThreads/create', { // eslint-disable-next-line
assistant_id: activeAssistant.value.id, console.log(error);
conversation_id: currentChat.value?.id, } finally {
message, isCaptainTyping.value = false;
});
selectedCopilotThreadId.value = response.id;
} }
}; };
onMounted(() => { onMounted(() => {
store.dispatch('captainAssistants/get'); store.dispatch('captainAssistants/get');
}); });
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
}
});
</script> </script>
<template> <template>
<div <Copilot
v-if="shouldShowCopilotPanel" :messages="messages"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background" :support-agent="currentUser"
> :is-captain-typing="isCaptainTyping"
<Copilot :conversation-inbox-type="conversationInboxType"
:messages="messages" :assistants="assistants"
:support-agent="currentUser" :active-assistant="activeAssistant"
:conversation-inbox-type="conversationInboxType" @set-assistant="setAssistant"
:assistants="assistants" @send-message="sendMessage"
:active-assistant="activeAssistant" @reset="handleReset"
@set-assistant="setAssistant" />
@send-message="sendMessage"
@reset="handleReset"
/>
</div>
<template v-else />
</template> </template>
@@ -9,7 +9,6 @@ const contacts = accountId => ({
'contacts_edit', 'contacts_edit',
'contacts_edit_segment', 'contacts_edit_segment',
'contacts_edit_label', 'contacts_edit_label',
'contacts_dashboard_active',
], ],
menuItems: [ menuItems: [
{ {
@@ -19,13 +18,6 @@ const contacts = accountId => ({
toState: frontendURL(`accounts/${accountId}/contacts?page=1`), toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index', toStateName: 'contacts_dashboard_index',
}, },
{
icon: 'visitor-contacts',
label: 'ACTIVE',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts/active`),
toStateName: 'contacts_dashboard_active',
},
], ],
}); });
@@ -21,7 +21,7 @@ const props = defineProps({
const isRelaxed = computed(() => props.type === 'relaxed'); const isRelaxed = computed(() => props.type === 'relaxed');
const headerClass = computed(() => const headerClass = computed(() =>
isRelaxed.value isRelaxed.value
? 'ltr:first:rounded-bl-lg ltr:first:rounded-tl-lg ltr:last:rounded-br-lg ltr:last:rounded-tr-lg rtl:first:rounded-br-lg rtl:first:rounded-tr-lg rtl:last:rounded-bl-lg rtl:last:rounded-tl-lg' ? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
: '' : ''
); );
</script> </script>
@@ -48,13 +48,12 @@ useKeyboardEvents(keyboardEvents);
<template> <template>
<woot-tabs <woot-tabs
:index="activeTabIndex" :index="activeTabIndex"
class="w-full px-3 -mt-1 py-0 tab--chat-type" class="w-full px-4 py-0 tab--chat-type"
@change="onTabChange" @change="onTabChange"
> >
<woot-tabs-item <woot-tabs-item
v-for="(item, index) in items" v-for="(item, index) in items"
:key="item.key" :key="item.key"
class="text-sm"
:index="index" :index="index"
:name="item.name" :name="item.name"
:count="item.count" :count="item.count"
@@ -25,7 +25,10 @@ defineProps({
:username="user.name" :username="user.name"
:status="user.availability_status" :status="user.availability_status"
/> />
<span class="my-0 truncate text-capitalize" :class="textClass"> <span
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
:class="textClass"
>
{{ user.name }} {{ user.name }}
</span> </span>
</div> </div>
@@ -4,14 +4,17 @@ import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue'; import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue'; import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue'; import MessagesView from './MessagesView.vue';
import ConversationSidebar from './ConversationSidebar.vue';
export default { export default {
components: { components: {
ConversationSidebar,
ConversationHeader, ConversationHeader,
DashboardAppFrame, DashboardAppFrame,
EmptyState, EmptyState,
MessagesView, MessagesView,
}, },
props: { props: {
inboxId: { inboxId: {
type: [Number, String], type: [Number, String],
@@ -31,6 +34,7 @@ export default {
default: true, default: true,
}, },
}, },
emits: ['contactPanelToggle'],
data() { data() {
return { activeIndex: 0 }; return { activeIndex: 0 };
}, },
@@ -82,6 +86,9 @@ export default {
} }
this.$store.dispatch('conversationLabels/get', this.currentChat.id); this.$store.dispatch('conversationLabels/get', this.currentChat.id);
}, },
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
onDashboardAppTabChange(index) { onDashboardAppTabChange(index) {
this.activeIndex = index; this.activeIndex = index;
}, },
@@ -91,7 +98,7 @@ export default {
<template> <template>
<div <div
class="conversation-details-wrap bg-n-background relative" class="conversation-details-wrap bg-n-background"
:class="{ :class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout, 'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}" }"
@@ -99,12 +106,15 @@ export default {
<ConversationHeader <ConversationHeader
v-if="currentChat.id" v-if="currentChat.id"
:chat="currentChat" :chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout && !isInboxView" :show-back-button="isOnExpandedLayout && !isInboxView"
@contact-panel-toggle="onToggleContactPanel"
/> />
<woot-tabs <woot-tabs
v-if="dashboardApps.length && currentChat.id" v-if="dashboardApps.length && currentChat.id"
:index="activeIndex" :index="activeIndex"
class="-mt-px dashboard-app--tabs border-t border-t-n-background" class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
@change="onDashboardAppTabChange" @change="onDashboardAppTabChange"
> >
<woot-tabs-item <woot-tabs-item
@@ -120,12 +130,18 @@ export default {
v-if="currentChat.id" v-if="currentChat.id"
:inbox-id="inboxId" :inbox-id="inboxId"
:is-inbox-view="isInboxView" :is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/> />
<EmptyState <EmptyState
v-if="!currentChat.id && !isInboxView" v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout" :is-on-expanded-layout="isOnExpandedLayout"
/> />
<slot /> <ConversationSidebar
v-if="showContactPanel"
:current-chat="currentChat"
@toggle-contact-panel="onToggleContactPanel"
/>
</div> </div>
<DashboardAppFrame <DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps" v-for="(dashboardApp, index) in dashboardApps"
@@ -78,7 +78,6 @@ export default {
'markAsRead', 'markAsRead',
'assignPriority', 'assignPriority',
'updateConversationStatus', 'updateConversationStatus',
'deleteConversation',
], ],
data() { data() {
return { return {
@@ -238,17 +237,13 @@ export default {
this.$emit('assignPriority', priority, this.chat.id); this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu(); this.closeContextMenu();
}, },
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
}, },
}; };
</script> </script>
<template> <template>
<div <div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group" class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{ :class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak': 'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat, isActiveChat,
@@ -283,7 +278,7 @@ export default {
:badge="inboxBadge" :badge="inboxBadge"
:username="currentContact.name" :username="currentContact.name"
:status="currentContact.availability_status" :status="currentContact.availability_status"
size="32px" size="40px"
/> />
</div> </div>
<div <div
@@ -368,7 +363,6 @@ export default {
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@mark-as-read="markAsRead" @mark-as-read="markAsRead"
@assign-priority="assignPriority" @assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/> />
</ContextMenu> </ContextMenu>
</div> </div>
@@ -1,9 +1,8 @@
<script setup> <script>
import { computed, ref } from 'vue'; import { mapGetters } from 'vuex';
import { useRoute } from 'vue-router'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue'; import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue'; import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue'; import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue'; import Thumbnail from '../Thumbnail.vue';
@@ -11,140 +10,205 @@ import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper'; import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useI18n } from 'vue-i18n'; import Linear from './linear/index.vue';
const props = defineProps({ import NextButton from 'dashboard/components-next/button/Button.vue';
chat: {
type: Object, export default {
default: () => ({}), components: {
BackButton,
InboxName,
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
NextButton,
}, },
showBackButton: { mixins: [inboxMixin],
type: Boolean, props: {
default: false, chat: {
type: Object,
default: () => {},
},
isContactPanelOpen: {
type: Boolean,
default: false,
},
showBackButton: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
}, },
}); emits: ['contactPanelToggle'],
setup(props, { emit }) {
const { t } = useI18n(); const keyboardEvents = {
const store = useStore(); 'Alt+KeyO': {
const route = useRoute(); action: () => emit('contactPanelToggle'),
const conversationHeader = ref(null); },
const { width } = useElementSize(conversationHeader); };
const { isAWebWidgetInbox } = useInbox(); useKeyboardEvents(keyboardEvents);
},
const currentChat = computed(() => store.getters.getSelectedChat); computed: {
const accountId = computed(() => store.getters.getCurrentAccountId); ...mapGetters({
currentChat: 'getSelectedChat',
const chatMetadata = computed(() => props.chat.meta); accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
const backButtonUrl = computed(() => { appIntegrations: 'integrations/getAppIntegrations',
const { }),
params: { inbox_id: inboxId, label, teamId }, chatMetadata() {
name, return this.chat.meta;
} = route; },
return conversationListPageURL({ backButtonUrl() {
accountId, const {
inboxId, params: { accountId, inbox_id: inboxId, label, teamId },
label, name,
teamId, } = this.$route;
conversationType: name === 'conversation_mentions' ? 'mention' : '', return conversationListPageURL({
}); accountId,
}); inboxId,
label,
const isHMACVerified = computed(() => { teamId,
if (!isAWebWidgetInbox.value) { conversationType: name === 'conversation_mentions' ? 'mention' : '',
return true; });
} },
return chatMetadata.value.hmac_verified; isHMACVerified() {
}); if (!this.isAWebWidgetInbox) {
return true;
const currentContact = computed(() => }
store.getters['contacts/getContact'](props.chat.meta.sender.id) return this.chatMetadata.hmac_verified;
); },
currentContact() {
const isSnoozed = computed( return this.$store.getters['contacts/getContact'](
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED this.chat.meta.sender.id
); );
},
const snoozedDisplayText = computed(() => { isSnoozed() {
const { snoozed_until: snoozedUntil } = currentChat.value; return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
if (snoozedUntil) { },
return `${t('CONVERSATION.HEADER.SNOOZED_UNTIL')} ${snoozedReopenTime(snoozedUntil)}`; snoozedDisplayText() {
} const { snoozed_until: snoozedUntil } = this.currentChat;
return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY'); if (snoozedUntil) {
}); return `${this.$t(
'CONVERSATION.HEADER.SNOOZED_UNTIL'
const inbox = computed(() => { )} ${snoozedReopenTime(snoozedUntil)}`;
const { inbox_id: inboxId } = props.chat; }
return store.getters['inboxes/getInbox'](inboxId); return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
}); },
contactPanelToggleText() {
const hasMultipleInboxes = computed( return `${
() => store.getters['inboxes/getInboxes'].length > 1 this.isContactPanelOpen
); ? this.$t('CONVERSATION.HEADER.CLOSE')
: this.$t('CONVERSATION.HEADER.OPEN')
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); } ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
},
hasMultipleInboxes() {
return this.$store.getters['inboxes/getInboxes'].length > 1;
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
};
</script> </script>
<template> <template>
<div <div
ref="conversationHeader" class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
> >
<div <div
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1" class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
> >
<BackButton <div class="flex items-center justify-start max-w-full min-w-0 w-fit">
v-if="showBackButton" <BackButton
:back-url="backButtonUrl" v-if="showBackButton"
class="ltr:mr-2 rtl:ml-2" :back-url="backButtonUrl"
/> class="ltr:mr-2 rtl:ml-2"
<Thumbnail />
:src="currentContact.thumbnail" <Thumbnail
:username="currentContact.name" :src="currentContact.thumbnail"
:status="currentContact.availability_status" :badge="inboxBadge"
size="32px" :username="currentContact.name"
class="flex-shrink-0" :status="currentContact.availability_status"
/> />
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
>
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px] flex-shrink-0"
icon="warning"
/>
</div>
<div <div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap" class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
> >
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" /> <div
<span v-if="isSnoozed" class="font-medium text-n-amber-10"> class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
{{ snoozedDisplayText }} >
</span> <NextButton link slate @click.prevent="$emit('contactPanelToggle')">
<span
class="text-base font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
</NextButton>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
<NextButton
link
xs
blue
:label="contactPanelToggleText"
@click="$emit('contactPanelToggle')"
/>
</div>
</div> </div>
</div> </div>
</div> <div
<div class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
class="flex flex-row items-center justify-start xl:justify-end flex-shrink-0 gap-2 w-full xl:w-auto header-actions-wrap" :class="{ 'justify-end': isContactPanelOpen }"
> >
<SLACardLabel <SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
v-if="hasSlaPolicyId" <Linear
:chat="chat" v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
show-extended-info :conversation-id="currentChat.id"
:parent-width="width" />
class="hidden md:flex" <MoreActions :conversation-id="currentChat.id" />
/> </div>
<MoreActions :conversation-id="currentChat.id" />
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.conversation--header--actions {
::v-deep .inbox--name {
@apply m-0;
}
}
</style>
@@ -1,36 +1,81 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue'; import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import { useUISettings } from 'dashboard/composables/useUISettings'; import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
defineProps({ const props = defineProps({
currentChat: { currentChat: {
required: true, required: true,
type: Object, type: Object,
}, },
}); });
const { uiSettings } = useUISettings(); const emit = defineEmits(['toggleContactPanel']);
const activeTab = computed(() => { const { t } = useI18n();
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
if (isContactSidebarOpen) { const channelType = computed(() => props.currentChat?.meta?.channel || '');
return 0;
} const CONTACT_TABS_OPTIONS = [
return null; { key: 'CONTACT', value: 'contact' },
{ key: 'COPILOT', value: 'copilot' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
value: tab.value,
}));
}); });
const activeTab = ref(0);
const toggleContactPanel = () => {
emit('toggleContactPanel');
};
const handleTabChange = selectedTab => {
activeTab.value = tabs.value.findIndex(
tabItem => tabItem.value === selectedTab.value
);
};
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
</script> </script>
<template> <template>
<div <div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background" class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
> >
<div v-if="showCopilotTab" class="p-2">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTab"
class="w-full [&>button]:w-full"
@tab-changed="handleTabChange"
/>
</div>
<div class="flex flex-1 overflow-auto"> <div class="flex flex-1 overflow-auto">
<ContactPanel <ContactPanel
v-show="activeTab === 0" v-if="!activeTab"
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id" :inbox-id="currentChat.inbox_id"
:on-toggle="toggleContactPanel"
/>
<CopilotContainer
v-else-if="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
class="flex-1"
/> />
</div> </div>
</div> </div>
@@ -38,6 +38,8 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags'; import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
Message, Message,
@@ -45,8 +47,20 @@ export default {
ReplyBox, ReplyBox,
Banner, Banner,
ConversationLabelSuggestion, ConversationLabelSuggestion,
NextButton,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
props: {
isContactPanelOpen: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
emits: ['contactPanelToggle'],
setup() { setup() {
const isPopOutReplyBox = ref(false); const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null); const conversationPanelRef = ref(null);
@@ -189,6 +203,12 @@ export default {
isATweet() { isATweet() {
return this.conversationType === 'tweet'; return this.conversationType === 'tweet';
}, },
isRightOrLeftIcon() {
if (this.isContactPanelOpen) {
return 'arrow-chevron-right';
}
return 'arrow-chevron-left';
},
getLastSeenAt() { getLastSeenAt() {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat; const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt; return contactLastSeenAt;
@@ -424,6 +444,9 @@ export default {
relevantMessages relevantMessages
); );
}, },
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
setScrollParams() { setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight; this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop; this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
@@ -507,6 +530,19 @@ export default {
class="mx-2 mt-2 overflow-hidden rounded-lg" class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')" :banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/> />
<div class="flex justify-end">
<NextButton
faded
xs
slate
class="!rounded-r-none rtl:rotate-180 !rounded-2xl !fixed z-10"
:icon="
isContactPanelOpen ? 'i-ph-caret-right-fill' : 'i-ph-caret-left-fill'
"
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
@click="onToggleContactPanel"
/>
</div>
<NextMessageList <NextMessageList
v-if="showNextBubbles" v-if="showNextBubbles"
ref="conversationPanelRef" ref="conversationPanelRef"
@@ -1,14 +1,10 @@
<script setup> <script>
import { computed, onUnmounted } from 'vue'; import { mapGetters } from 'vuex';
import { useToggle } from '@vueuse/core';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import EmailTranscriptModal from './EmailTranscriptModal.vue'; import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue'; import ResolveAction from '../../buttons/ResolveAction.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue'; import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { import {
CMD_MUTE_CONVERSATION, CMD_MUTE_CONVERSATION,
@@ -16,111 +12,97 @@ import {
CMD_UNMUTE_CONVERSATION, CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events'; } from 'dashboard/helper/commandbar/events';
// No props needed as we're getting currentChat from the store directly export default {
const store = useStore(); components: {
const { t } = useI18n(); EmailTranscriptModal,
ResolveAction,
const [showEmailActionsModal, toggleEmailModal] = useToggle(false); ButtonV4,
const [showActionsDropdown, toggleDropdown] = useToggle(false); },
data() {
const currentChat = computed(() => store.getters.getSelectedChat); return {
showEmailActionsModal: false,
const actionMenuItems = computed(() => { };
const items = []; },
computed: {
if (!currentChat.value.muted) { ...mapGetters({ currentChat: 'getSelectedChat' }),
items.push({ },
icon: 'i-lucide-volume-off', mounted() {
label: t('CONTACT_PANEL.MUTE_CONTACT'), emitter.on(CMD_MUTE_CONVERSATION, this.mute);
action: 'mute', emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
value: 'mute', emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
}); },
} else { unmounted() {
items.push({ emitter.off(CMD_MUTE_CONVERSATION, this.mute);
icon: 'i-lucide-volume-1', emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
label: t('CONTACT_PANEL.UNMUTE_CONTACT'), emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
action: 'unmute', },
value: 'unmute', methods: {
}); mute() {
} this.$store.dispatch('muteConversation', this.currentChat.id);
useAlert(this.$t('CONTACT_PANEL.MUTED_SUCCESS'));
items.push({ },
icon: 'i-lucide-share', unmute() {
label: t('CONTACT_PANEL.SEND_TRANSCRIPT'), this.$store.dispatch('unmuteConversation', this.currentChat.id);
action: 'send_transcript', useAlert(this.$t('CONTACT_PANEL.UNMUTED_SUCCESS'));
value: 'send_transcript', },
}); toggleEmailActionsModal() {
this.showEmailActionsModal = !this.showEmailActionsModal;
return items; },
}); },
const handleActionClick = ({ action }) => {
toggleDropdown(false);
if (action === 'mute') {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
} else if (action === 'unmute') {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
} else if (action === 'send_transcript') {
toggleEmailModal();
}
}; };
// These functions are needed for the event listeners
const mute = () => {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
};
const unmute = () => {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
};
emitter.on(CMD_MUTE_CONVERSATION, mute);
emitter.on(CMD_UNMUTE_CONVERSATION, unmute);
emitter.on(CMD_SEND_TRANSCRIPT, toggleEmailModal);
onUnmounted(() => {
emitter.off(CMD_MUTE_CONVERSATION, mute);
emitter.off(CMD_UNMUTE_CONVERSATION, unmute);
emitter.off(CMD_SEND_TRANSCRIPT, toggleEmailModal);
});
</script> </script>
<template> <template>
<div class="relative flex items-center gap-2 actions--container"> <div class="relative flex items-center gap-2 actions--container">
<ButtonV4
v-if="!currentChat.muted"
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-off"
@click="mute"
/>
<ButtonV4
v-else
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-1"
@click="unmute"
/>
<ButtonV4
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-share"
@click="toggleEmailActionsModal"
/>
<ResolveAction <ResolveAction
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:status="currentChat.status" :status="currentChat.status"
/> />
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<ButtonV4
v-tooltip="$t('CONVERSATION.HEADER.MORE_ACTIONS')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-more-vertical"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="actionMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleActionClick"
/>
</div>
<EmailTranscriptModal <EmailTranscriptModal
v-if="showEmailActionsModal" v-if="showEmailActionsModal"
:show="showEmailActionsModal" :show="showEmailActionsModal"
:current-chat="currentChat" :current-chat="currentChat"
@cancel="toggleEmailModal" @cancel="toggleEmailActionsModal"
/> />
</div> </div>
</template> </template>
<style scoped lang="scss">
.more--button {
@apply items-center flex ml-2 rtl:ml-0 rtl:mr-2;
}
.dropdown-pane {
@apply -right-2 top-12;
}
.icon {
@apply mr-1 rtl:mr-0 rtl:ml-1 min-w-[1rem];
}
</style>
@@ -1,115 +1,132 @@
<script setup> <script>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils'; import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue'; import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
parentWidth: {
type: Number,
default: 1000,
},
});
const REFRESH_INTERVAL = 60000; const REFRESH_INTERVAL = 60000;
const { t } = useI18n();
const timer = ref(null); export default {
const slaStatus = ref({ components: {
threshold: null, SLAPopoverCard,
isSlaMissed: false, },
type: null, props: {
icon: null, chat: {
}); type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
},
data() {
return {
timer: null,
showSlaPopover: false,
slaStatus: {
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
},
};
},
computed: {
slaPolicyId() {
return this.chat?.sla_policy_id;
},
appliedSLA() {
return this.chat?.applied_sla;
},
slaEvents() {
return this.chat?.sla_events;
},
hasSlaThreshold() {
return this.slaStatus?.threshold;
},
isSlaMissed() {
return this.slaStatus?.isSlaMissed;
},
slaTextStyles() {
return this.isSlaMissed ? 'text-n-ruby-11' : 'text-n-amber-11';
},
slaStatusText() {
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
const appliedSLA = computed(() => props.chat?.applied_sla); return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
const slaEvents = computed(() => props.chat?.sla_events); status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
const hasSlaThreshold = computed(() => slaStatus.value?.threshold); });
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed); },
const slaTextStyles = computed(() => showSlaPopoverCard() {
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11' return (
); this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
);
const slaStatusText = computed(() => { },
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT },
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE'; watch: {
chat() {
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, { this.updateSlaStatus();
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`), },
}); },
}); mounted() {
this.updateSlaStatus();
const showSlaPopoverCard = computed( this.createTimer();
() => props.showExtendedInfo && slaEvents.value?.length > 0 },
); unmounted() {
if (this.timer) {
const groupClass = computed(() => { clearTimeout(this.timer);
return props.showExtendedInfo }
? 'h-[26px] rounded-lg bg-n-alpha-1' },
: 'rounded h-5 border border-n-strong'; methods: {
}); createTimer() {
this.timer = setTimeout(() => {
const updateSlaStatus = () => { this.updateSlaStatus();
slaStatus.value = evaluateSLAStatus({ this.createTimer();
appliedSla: appliedSLA.value, }, REFRESH_INTERVAL);
chat: props.chat, },
}); updateSlaStatus() {
this.slaStatus = evaluateSLAStatus({
appliedSla: this.appliedSLA,
chat: this.chat,
});
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
this.showSlaPopover = true;
},
closeSlaPopover() {
this.showSlaPopover = false;
},
},
}; };
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
watch(
() => props.chat,
() => {
updateSlaStatus();
}
);
const slaPopoverClass = computed(() => {
return props.showExtendedInfo
? 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
: '';
});
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
</script> </script>
<!-- eslint-disable-next-line vue/no-root-v-if --> <!-- eslint-disable-next-line vue/no-root-v-if -->
<template> <template>
<div <div
v-if="hasSlaThreshold" v-if="hasSlaThreshold"
class="relative flex items-center cursor-pointer min-w-fit group" class="relative flex items-center cursor-pointer min-w-fit"
:class="groupClass" :class="
showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong'
"
> >
<div <div
class="flex items-center w-full truncate px-1.5" v-on-clickaway="closeSlaPopover"
:class="showExtendedInfo ? '' : 'gap-1'" class="flex items-center w-full truncate"
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
@mouseover="openSlaPopover()"
> >
<div class="flex items-center gap-1" :class="slaPopoverClass"> <div
class="flex items-center gap-1"
:class="
showExtendedInfo &&
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
"
>
<fluent-icon <fluent-icon
size="12" size="14"
:icon="slaStatus.icon" :icon="slaStatus.icon"
type="outline" type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'" :icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
@@ -117,7 +134,7 @@ onUnmounted(() => {
:class="slaTextStyles" :class="slaTextStyles"
/> />
<span <span
v-if="showExtendedInfo && parentWidth > 650" v-if="showExtendedInfo"
class="text-xs font-medium" class="text-xs font-medium"
:class="slaTextStyles" :class="slaTextStyles"
> >
@@ -134,7 +151,7 @@ onUnmounted(() => {
<SLAPopoverCard <SLAPopoverCard
v-if="showSlaPopoverCard" v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents" :sla-missed-events="slaEvents"
class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex" class="right-0 top-7"
/> />
</div> </div>
</template> </template>
@@ -8,7 +8,6 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue'; import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue'; import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
export default { export default {
components: { components: {
@@ -46,14 +45,7 @@ export default {
'assignAgent', 'assignAgent',
'assignTeam', 'assignTeam',
'assignLabel', 'assignLabel',
'deleteConversation',
], ],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() { data() {
return { return {
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
@@ -129,11 +121,6 @@ export default {
icon: 'people-team-add', icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
}, },
deleteOption: {
key: 'delete',
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
}; };
}, },
computed: { computed: {
@@ -191,9 +178,6 @@ export default {
assignPriority(priority) { assignPriority(priority) {
this.$emit('assignPriority', priority); this.$emit('assignPriority', priority);
}, },
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
show(key) { show(key) {
// If the conversation status is same as the action, then don't display the option // If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved. // i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -293,13 +277,5 @@ export default {
@click.stop="$emit('assignTeam', team)" @click.stop="$emit('assignTeam', team)"
/> />
</MenuItemWithSubmenu> </MenuItemWithSubmenu>
<template v-if="isAdmin">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
variant="icon"
@click.stop="deleteConversation"
/>
</template>
</div> </div>
</template> </template>
@@ -183,18 +183,13 @@ const createIssue = async () => {
state_id: formState.stateId || undefined, state_id: formState.stateId || undefined,
priority: formState.priority || undefined, priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined, label_ids: formState.labelId ? [formState.labelId] : undefined,
conversation_id: props.conversationId,
}; };
try { try {
isCreating.value = true; isCreating.value = true;
const response = await LinearAPI.createIssue(payload); const response = await LinearAPI.createIssue(payload);
const { identifier: issueIdentifier } = response.data; const { id: issueId } = response.data;
await LinearAPI.link_issue( await LinearAPI.link_issue(props.conversationId, issueId, props.title);
props.conversationId,
issueIdentifier,
props.title
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS')); useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
useTrack(LINEAR_EVENTS.CREATE_ISSUE); useTrack(LINEAR_EVENTS.CREATE_ISSUE);
onClose(); onClose();
@@ -0,0 +1,123 @@
<script setup>
import { format } from 'date-fns';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import IssueHeader from './IssueHeader.vue';
import { computed } from 'vue';
const props = defineProps({
issue: {
type: Object,
required: true,
},
linkId: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlinkIssue']);
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const formattedDate = computed(() => {
const { createdAt } = props.issue;
return format(new Date(createdAt), 'hh:mm a, MMM dd');
});
const assignee = computed(() => {
const assigneeDetails = props.issue.assignee;
if (!assigneeDetails) return null;
const { name, avatarUrl } = assigneeDetails;
return {
name,
thumbnail: avatarUrl,
};
});
const labels = computed(() => {
return props.issue.labels?.nodes || [];
});
const priorityLabel = computed(() => {
return priorityMap[props.issue.priority];
});
const unlinkIssue = () => {
emit('unlinkIssue', props.linkId);
};
</script>
<template>
<div
class="absolute flex flex-col items-start bg-n-alpha-3 backdrop-blur-[100px] z-50 px-4 py-3 border border-solid border-n-container w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<div class="flex flex-col w-full">
<IssueHeader
:identifier="issue.identifier"
:link-id="linkId"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<span class="mt-2 text-sm font-medium text-n-slate-12">
{{ issue.title }}
</span>
<span
v-if="issue.description"
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
>
{{ issue.description }}
</span>
</div>
<div class="flex flex-row items-center h-6 gap-2">
<UserAvatarWithName v-if="assignee" :user="assignee" class="py-1" />
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
<div class="flex items-center gap-1 py-1">
<fluent-icon
icon="status"
size="14"
:style="{ color: issue.state.color }"
/>
<h6 class="text-xs text-n-slate-12">
{{ issue.state.name }}
</h6>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
<fluent-icon
:icon="`priority-${priorityLabel.toLowerCase()}`"
size="14"
view-box="0 0 12 12"
/>
<h6 class="text-xs text-n-slate-12">{{ priorityLabel }}</h6>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
<div class="flex items-center">
<span class="text-xs text-n-slate-11">
{{
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
createdAt: formattedDate,
})
}}
</span>
</div>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup> <script setup>
import { inject } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
@@ -14,6 +15,8 @@ const props = defineProps({
const emit = defineEmits(['unlinkIssue']); const emit = defineEmits(['unlinkIssue']);
const isUnlinking = inject('isUnlinking');
const unlinkIssue = () => { const unlinkIssue = () => {
emit('unlinkIssue'); emit('unlinkIssue');
}; };
@@ -24,33 +27,36 @@ const openIssue = () => {
</script> </script>
<template> <template>
<div class="flex items-center justify-between"> <div class="flex flex-row justify-between">
<div <div
class="flex items-center gap-2 px-2 py-1.5 border rounded-lg border-n-strong" class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
> >
<div class="flex items-center gap-1"> <fluent-icon
<fluent-icon icon="linear"
icon="linear" size="19"
size="16" class="text-[#5E6AD2]"
class="text-[#5E6AD2]" view-box="0 0 19 19"
view-box="0 0 19 19" />
/> <span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
<span class="text-xs font-medium text-n-slate-12"> </div>
{{ identifier }} <div class="flex items-center gap-1">
</span>
</div>
<span class="w-px h-3 text-n-weak bg-n-weak" />
<Button <Button
link ghost
xs xs
slate slate
icon="i-lucide-unlink"
class="!transition-none"
:is-loading="isUnlinking"
@click="unlinkIssue"
/>
<Button
ghost
xs
slate
class="!transition-none"
icon="i-lucide-arrow-up-right" icon="i-lucide-arrow-up-right"
class="!size-4"
@click="openIssue" @click="openIssue"
/> />
</div> </div>
<Button ghost xs slate icon="i-lucide-unlink" @click="unlinkIssue" />
</div> </div>
</template> </template>
@@ -1,132 +0,0 @@
<script setup>
import { computed, ref, onMounted, watch } from 'vue';
import { useAlert, useTrack } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import LinearIssueItem from './LinearIssueItem.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const getters = useStoreGetters();
const linkedIssues = ref([]);
const isLoading = ref(false);
const shouldShowCreateModal = ref(false);
const currentAccountId = getters.getCurrentAccountId;
const conversation = computed(
() => getters.getConversationById.value(props.conversationId) || {}
);
const hasIssues = computed(() => linkedIssues.value.length > 0);
const loadLinkedIssues = async () => {
isLoading.value = true;
linkedIssues.value = [];
try {
const response = await LinearAPI.getLinkedIssue(props.conversationId);
linkedIssues.value = response.data || [];
} catch (error) {
// Silent fail - not critical for UX
} finally {
isLoading.value = false;
}
};
const unlinkIssue = async (linkId, issueIdentifier) => {
try {
await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId);
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
linkedIssues.value = linkedIssues.value.filter(
issue => issue.id !== linkId
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
);
useAlert(errorMessage);
}
};
const openCreateModal = () => {
shouldShowCreateModal.value = true;
};
const closeCreateModal = () => {
shouldShowCreateModal.value = false;
loadLinkedIssues();
};
watch(
() => props.conversationId,
() => {
loadLinkedIssues();
}
);
onMounted(() => {
loadLinkedIssues();
});
</script>
<template>
<div>
<div class="px-4 pt-3 pb-2">
<NextButton
ghost
xs
icon="i-lucide-plus"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')"
@click="openCreateModal"
/>
</div>
<div v-if="isLoading" class="flex justify-center p-8">
<Spinner />
</div>
<div v-else-if="!hasIssues" class="flex justify-center p-4">
<p class="text-sm text-n-slate-11">
{{ $t('INTEGRATION_SETTINGS.LINEAR.NO_LINKED_ISSUES') }}
</p>
</div>
<div v-else class="max-h-[300px] overflow-y-auto">
<LinearIssueItem
v-for="linkedIssue in linkedIssues"
:key="linkedIssue.id"
class="px-4 pt-3 pb-4 border-b border-n-weak last:border-b-0"
:linked-issue="linkedIssue"
@unlink-issue="unlinkIssue"
/>
</div>
<woot-modal
v-model:show="shouldShowCreateModal"
:on-close="closeCreateModal"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<CreateOrLinkIssue
:conversation="conversation"
:account-id="currentAccountId"
@close="closeCreateModal"
/>
</woot-modal>
</div>
</template>
@@ -1,113 +0,0 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import IssueHeader from './IssueHeader.vue';
const props = defineProps({
linkedIssue: {
type: Object,
required: true,
},
});
const emit = defineEmits(['unlinkIssue']);
const { linkedIssue } = props;
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const issue = computed(() => linkedIssue.issue);
const assignee = computed(() => {
const assigneeDetails = issue.value.assignee;
if (!assigneeDetails) return null;
return {
name: assigneeDetails.name,
thumbnail: assigneeDetails.avatarUrl,
};
});
const labels = computed(() => issue.value.labels?.nodes || []);
const priorityLabel = computed(() => priorityMap[issue.value.priority]);
const unlinkIssue = () => {
emit('unlinkIssue', linkedIssue.id, linkedIssue.issue.identifier);
};
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col w-full">
<IssueHeader
:identifier="issue.identifier"
:link-id="linkedIssue.id"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<h3 class="mt-2 text-sm font-medium text-n-slate-12">
{{ issue.title }}
</h3>
<p
v-if="issue.description"
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
>
{{ issue.description }}
</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div v-if="assignee" class="flex items-center gap-1.5">
<Avatar :src="assignee.thumbnail" :name="assignee.name" :size="16" />
<span class="text-xs capitalize truncate text-n-slate-12">
{{ assignee.name }}
</span>
</div>
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
<div class="flex items-center gap-1">
<Icon
icon="i-lucide-activity"
class="size-4"
:style="{ color: issue.state?.color }"
/>
<span class="text-xs text-n-slate-12">
{{ issue.state?.name }}
</span>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
<div v-if="priorityLabel" class="flex items-center gap-1.5">
<CardPriorityIcon :priority="priorityLabel.toLowerCase()" />
<span class="text-xs text-n-slate-12">
{{ priorityLabel }}
</span>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
</div>
</div>
</template>

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