Merge branch 'main' into chore/merge-upstream-4.12.0
This commit is contained in:
commit
d3ce2a4cf9
45
.annotaterb.yml
Normal file
45
.annotaterb.yml
Normal file
@ -0,0 +1,45 @@
|
||||
additional_file_patterns: []
|
||||
routes: false
|
||||
models: true
|
||||
position_in_routes: before
|
||||
position_in_class: before
|
||||
position_in_test: before
|
||||
position_in_fixture: before
|
||||
position_in_factory: before
|
||||
position_in_serializer: before
|
||||
show_foreign_keys: true
|
||||
show_complete_foreign_keys: false
|
||||
show_indexes: true
|
||||
simple_indexes: false
|
||||
model_dir:
|
||||
- app/models
|
||||
- enterprise/app/models
|
||||
root_dir: ''
|
||||
include_version: false
|
||||
require: ''
|
||||
exclude_tests: true
|
||||
exclude_fixtures: true
|
||||
exclude_factories: true
|
||||
exclude_serializers: true
|
||||
exclude_scaffolds: true
|
||||
exclude_controllers: true
|
||||
exclude_helpers: true
|
||||
exclude_sti_subclasses: false
|
||||
ignore_model_sub_dir: false
|
||||
ignore_columns: null
|
||||
ignore_routes: null
|
||||
ignore_unknown_models: false
|
||||
hide_limit_column_types: integer,bigint,boolean
|
||||
hide_default_column_types: json,jsonb,hstore
|
||||
skip_on_db_migrate: false
|
||||
format_bare: true
|
||||
format_rdoc: false
|
||||
format_markdown: false
|
||||
sort: false
|
||||
force: false
|
||||
frozen: false
|
||||
classified_sort: true
|
||||
trace: false
|
||||
wrapper_open: null
|
||||
wrapper_close: null
|
||||
with_comment: true
|
||||
3
.claude/settings.json
Normal file
3
.claude/settings.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"additionalDirectories": ["../baileys-api"]
|
||||
}
|
||||
20
.env.example
20
.env.example
@ -137,6 +137,17 @@ AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_REGION=
|
||||
|
||||
# S3-compatible storage (e.g., Cloudflare R2, MinIO, DigitalOcean Spaces)
|
||||
# Set ACTIVE_STORAGE_SERVICE=s3_compatible to use this
|
||||
# STORAGE_ACCESS_KEY_ID=
|
||||
# STORAGE_SECRET_ACCESS_KEY=
|
||||
# STORAGE_REGION=
|
||||
# STORAGE_BUCKET_NAME=
|
||||
# STORAGE_ENDPOINT=
|
||||
# STORAGE_FORCE_PATH_STYLE=true
|
||||
# STORAGE_REQUEST_CHECKSUM_CALCULATION=when_required
|
||||
# STORAGE_RESPONSE_CHECKSUM_VALIDATION=when_required
|
||||
|
||||
# Log settings
|
||||
# Disable if you want to write logs to a file
|
||||
RAILS_LOG_TO_STDOUT=true
|
||||
@ -277,3 +288,12 @@ AZURE_APP_SECRET=
|
||||
|
||||
# REDIS_ALFRED_SIZE=10
|
||||
# REDIS_VELMA_SIZE=10
|
||||
|
||||
# Baileys API Whatsapp provider
|
||||
BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot
|
||||
BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025
|
||||
BAILEYS_PROVIDER_DEFAULT_API_KEY=
|
||||
# Enable WhatsApp group conversations for Baileys provider (default: false)
|
||||
BAILEYS_WHATSAPP_GROUPS_ENABLED=false
|
||||
|
||||
RESEND_API_KEY=
|
||||
|
||||
8
.github/copilot-instructions.md
vendored
Normal file
8
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# GitHub Copilot Instructions
|
||||
|
||||
- Always include pt-BR translations for any new text added to the project.
|
||||
- fazer.ai is always styled as-is, with a dot and lowercase letters. Never use Fazer.ai
|
||||
- Always check if adding specs is necessary when modifying code.
|
||||
- Evaluate if specs added are actually needed and not redundant. Specs should not be for documentation purposes only, they should cover expected behavior.
|
||||
- Always evaluate if frontend changes are needed when modifying backend code, and vice versa.
|
||||
- NEVER use `--` in `pnpm test -- <file>`. Just do `pnpm test <file>` directly
|
||||
8
.github/workflows/frontend-fe.yml
vendored
8
.github/workflows/frontend-fe.yml
vendored
@ -2,11 +2,9 @@ name: Frontend Lint & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
138
.github/workflows/publish_ee_github_docker.yml
vendored
Normal file
138
.github/workflows/publish_ee_github_docker.yml
vendored
Normal file
@ -0,0 +1,138 @@
|
||||
name: Publish Chatwoot Enterprise docker images to GitHub
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_REPO: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || github.ref }}
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set Chatwoot edition
|
||||
run: |
|
||||
echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile
|
||||
|
||||
- name: Update version in app.yml
|
||||
run: |
|
||||
if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then
|
||||
VERSION="${BASH_REMATCH[1]}"
|
||||
echo "Updating version to: $VERSION"
|
||||
sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml
|
||||
else
|
||||
echo "No version tag found, keeping existing version"
|
||||
fi
|
||||
|
||||
- name: Set Docker Tags
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push to GitHub Container Registry
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
|
||||
tags: |
|
||||
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}-ee
|
||||
${{ env.GITHUB_REPO }}:latest-ee
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-ghcr.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/${{ github.repository }}:${SANITIZED_REF}-ee \
|
||||
-t ghcr.io/${{ github.repository }}:latest-ee \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
REPO="ghcr.io/${{ github.repository }}"
|
||||
docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}-ee
|
||||
docker buildx imagetools inspect ${REPO}:latest-ee
|
||||
139
.github/workflows/publish_github_docker.yml
vendored
Normal file
139
.github/workflows/publish_github_docker.yml
vendored
Normal file
@ -0,0 +1,139 @@
|
||||
name: Publish Chatwoot docker images to GitHub
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_REPO: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || github.ref }}
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: Update version in app.yml
|
||||
run: |
|
||||
if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then
|
||||
VERSION="${BASH_REMATCH[1]}"
|
||||
echo "Updating version to: $VERSION"
|
||||
sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml
|
||||
else
|
||||
echo "No version tag found, keeping existing version"
|
||||
fi
|
||||
|
||||
- name: Set Docker Tags
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push to GitHub Container Registry
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
|
||||
tags: |
|
||||
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}
|
||||
${{ env.GITHUB_REPO }}:latest
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-ghcr.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/${{ github.repository }}:${SANITIZED_REF} \
|
||||
-t ghcr.io/${{ github.repository }}:latest \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
REPO="ghcr.io/${{ github.repository }}"
|
||||
docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}
|
||||
docker buildx imagetools inspect ${REPO}:latest
|
||||
139
.github/workflows/publish_github_docker_beta.yml
vendored
Normal file
139
.github/workflows/publish_github_docker_beta.yml
vendored
Normal file
@ -0,0 +1,139 @@
|
||||
name: Publish Chatwoot beta docker images to GitHub
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [prereleased]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_REPO: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || github.ref }}
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: Update version in app.yml
|
||||
run: |
|
||||
if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then
|
||||
VERSION="${BASH_REMATCH[1]}"
|
||||
echo "Updating version to: $VERSION"
|
||||
sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml
|
||||
else
|
||||
echo "No version tag found, keeping existing version"
|
||||
fi
|
||||
|
||||
- name: Set Docker Tags
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push to GitHub Container Registry
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
|
||||
tags: |
|
||||
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}
|
||||
${{ env.GITHUB_REPO }}:beta
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-ghcr.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/${{ github.repository }}:${SANITIZED_REF} \
|
||||
-t ghcr.io/${{ github.repository }}:beta \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
REPO="ghcr.io/${{ github.repository }}"
|
||||
docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}
|
||||
docker buildx imagetools inspect ${REPO}:beta
|
||||
6
.github/workflows/run_foss_spec.yml
vendored
6
.github/workflows/run_foss_spec.yml
vendored
@ -3,10 +3,8 @@ permissions:
|
||||
contents: read
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
pull_request:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
# lint js and vue files
|
||||
npx --no-install lint-staged
|
||||
|
||||
# lint only staged ruby files that still exist (not deleted)
|
||||
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true
|
||||
# lint only staged ruby files
|
||||
git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion
|
||||
|
||||
# stage rubocop changes to files
|
||||
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true
|
||||
# git diff --name-only --cached | xargs git add
|
||||
|
||||
10
.rubocop.yml
10
.rubocop.yml
@ -218,6 +218,7 @@ Style/OneClassPerFile:
|
||||
|
||||
AllCops:
|
||||
NewCops: enable
|
||||
SuggestExtensions: false
|
||||
Exclude:
|
||||
- 'bin/**/*'
|
||||
- 'db/schema.rb'
|
||||
@ -348,3 +349,12 @@ FactoryBot/RedundantFactoryOption:
|
||||
|
||||
FactoryBot/FactoryAssociationWithStrategy:
|
||||
Enabled: false
|
||||
|
||||
Rails/SaveBang:
|
||||
Enabled: true
|
||||
AllowedReceivers:
|
||||
- Stripe::Subscription
|
||||
- Stripe::Customer
|
||||
- Stripe::Invoice
|
||||
- Stripe::InvoiceItem
|
||||
- FactoryBot
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -2,5 +2,6 @@
|
||||
"cSpell.words": [
|
||||
"chatwoot",
|
||||
"dompurify"
|
||||
]
|
||||
],
|
||||
"css.customData": [".vscode/tailwind.json"]
|
||||
}
|
||||
|
||||
55
.vscode/tailwind.json
vendored
Normal file
55
.vscode/tailwind.json
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": 1.1,
|
||||
"atDirectives": [
|
||||
{
|
||||
"name": "@tailwind",
|
||||
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@apply",
|
||||
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@responsive",
|
||||
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@screen",
|
||||
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@variants",
|
||||
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
73
CUSTOM_BRANDING.md
Normal file
73
CUSTOM_BRANDING.md
Normal file
@ -0,0 +1,73 @@
|
||||
# Custom branding
|
||||
|
||||
## Brand configuration
|
||||
|
||||
Export environment variables and run rake task with `bundle exec rails branding:update`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Unset environment variables are reset to default values.
|
||||
|
||||
```bash
|
||||
INSTALLATION_NAME="Chatwoot fazer.ai" \
|
||||
BRAND_NAME="My Company" \
|
||||
LOGO_THUMBNAIL="https://fazer.ai/logo-thumbnail.svg" \
|
||||
LOGO="https://fazer.ai/logo.svg" \
|
||||
bundle exec rails branding:update
|
||||
```
|
||||
|
||||
| Environment variable | Default Value | Description |
|
||||
| :--------------------| :------------------------------------------ | :-------------------------------------------------------------------- |
|
||||
| `INSTALLATION_NAME` | `Chatwoot` | The installation-wide name used in the dashboard, title, etc. |
|
||||
| `LOGO_THUMBNAIL` | `/brand-assets/logo_thumbnail.svg` | The thumbnail used for favicon (512px X 512px). |
|
||||
| `LOGO` | `/brand-assets/logo.svg` | The logo used on the dashboard, login page, etc. |
|
||||
| `LOGO_DARK` | `/brand-assets/logo_dark.svg` | The logo used on the dashboard, login page, etc. for dark mode. |
|
||||
| `BRAND_URL` | `https://www.chatwoot.com` | The URL used in emails under the section “Powered By”. |
|
||||
| `WIDGET_BRAND_URL` | `https://www.chatwoot.com` | The URL used in the widget under the section “Powered By”. |
|
||||
| `BRAND_NAME` | `Chatwoot` | The name used in emails and the widget. |
|
||||
| `TERMS_URL` | `https://www.chatwoot.com/terms-of-service` | The terms of service URL displayed on the Signup Page. |
|
||||
| `PRIVACY_URL` | `https://www.chatwoot.com/privacy-policy` | The privacy policy URL displayed in the app. |
|
||||
| `DISPLAY_MANIFEST` | `true` | Display default Chatwoot metadata like favicons and upgrade warnings. |
|
||||
|
||||
## Favicon and other assets
|
||||
|
||||
Update the favicon files in the [`public/`](public/) folder.
|
||||
|
||||
Can also be done by creating a zip file with relevant files, and running [`deployment/extract_brand_assets.sh`](deployment/extract_brand_assets.sh) to override the existing favicons with your own.
|
||||
In this case, the zip file should be a flat archive containing the following files:
|
||||
|
||||
```
|
||||
android-icon-36x36.png
|
||||
android-icon-48x48.png
|
||||
android-icon-72x72.png
|
||||
android-icon-96x96.png
|
||||
android-icon-144x144.png
|
||||
android-icon-192x192.png
|
||||
apple-icon-57x57.png
|
||||
apple-icon-60x60.png
|
||||
apple-icon-72x72.png
|
||||
apple-icon-76x76.png
|
||||
apple-icon-114x114.png
|
||||
apple-icon-120x120.png
|
||||
apple-icon-144x144.png
|
||||
apple-icon-152x152.png
|
||||
apple-icon-180x180.png
|
||||
apple-icon.png
|
||||
apple-icon-precomposed.png
|
||||
apple-touch-icon.png
|
||||
apple-touch-icon-precomposed.png
|
||||
favicon-16x16.png
|
||||
favicon-32x32.png
|
||||
favicon-96x96.png
|
||||
favicon-512x512.png
|
||||
favicon-badge-16x16.png
|
||||
favicon-badge-32x32.png
|
||||
favicon-badge-96x96.png
|
||||
ms-icon-70x70.png
|
||||
ms-icon-144x144.png
|
||||
ms-icon-150x150.png
|
||||
ms-icon-310x310.png
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> You can include other assets in the zip file, and use them when running the rake task for `LOGO_THUMBNAIL`, `LOGO`, and `LOGO_DARK`.
|
||||
> See [Brand configuration](#brand-configuration).
|
||||
5
Gemfile
5
Gemfile
@ -43,7 +43,7 @@ gem 'down'
|
||||
# authentication type to fetch and send mail over oauth2.0
|
||||
gem 'gmail_xoauth'
|
||||
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
|
||||
gem 'net-smtp', '~> 0.3.4'
|
||||
gem 'net-smtp', '~> 0.3.4'
|
||||
# Prevent CSV injection
|
||||
gem 'csv-safe'
|
||||
|
||||
@ -54,6 +54,7 @@ gem 'aws-sdk-s3', require: false
|
||||
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
|
||||
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
||||
gem 'image_processing'
|
||||
gem 'streamio-ffmpeg', '~> 3.0'
|
||||
|
||||
##-- for actionmailbox --##
|
||||
gem 'aws-actionmailbox-ses', '~> 0'
|
||||
@ -205,6 +206,8 @@ gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'resend', '~> 0.19.0'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
@ -749,6 +749,8 @@ GEM
|
||||
uber (< 0.2.0)
|
||||
request_store (1.5.1)
|
||||
rack (>= 1.4)
|
||||
resend (0.19.0)
|
||||
httparty (>= 0.21.0)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
@ -935,6 +937,8 @@ GEM
|
||||
squasher (0.7.2)
|
||||
stackprof (0.2.25)
|
||||
statsd-ruby (1.5.0)
|
||||
streamio-ffmpeg (3.0.2)
|
||||
multi_json (~> 1.8)
|
||||
stripe (18.0.1)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
@ -1116,6 +1120,7 @@ DEPENDENCIES
|
||||
rails (~> 7.1)
|
||||
redis
|
||||
redis-namespace
|
||||
resend (~> 0.19.0)
|
||||
responders (>= 3.1.1)
|
||||
rest-client
|
||||
reverse_markdown
|
||||
@ -1148,6 +1153,7 @@ DEPENDENCIES
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
stackprof
|
||||
streamio-ffmpeg (~> 3.0)
|
||||
stripe (~> 18.0)
|
||||
telephone_number
|
||||
test-prof
|
||||
|
||||
@ -70,7 +70,7 @@ class ContactIdentifyAction
|
||||
end
|
||||
|
||||
def merge_contacts?(existing_contact, key)
|
||||
return if existing_contact.blank?
|
||||
return false if existing_contact.blank?
|
||||
|
||||
return true if params[:identifier].blank?
|
||||
|
||||
|
||||
@ -55,7 +55,8 @@ class ContactInboxWithContactBuilder
|
||||
email: contact_attributes[:email],
|
||||
identifier: contact_attributes[:identifier],
|
||||
additional_attributes: contact_attributes[:additional_attributes],
|
||||
custom_attributes: contact_attributes[:custom_attributes]
|
||||
custom_attributes: contact_attributes[:custom_attributes],
|
||||
group_type: contact_attributes[:group_type] || :individual
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ class ConversationBuilder
|
||||
def look_up_exising_conversation
|
||||
return unless @contact_inbox.inbox.lock_to_single_conversation?
|
||||
|
||||
@contact_inbox.conversations.last
|
||||
@contact_inbox.inbox.conversations.where(contact_id: @contact_inbox.contact_id).last
|
||||
end
|
||||
|
||||
def create_new_conversation
|
||||
|
||||
@ -183,7 +183,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
end
|
||||
|
||||
def all_unsupported_files?
|
||||
return if attachments.empty?
|
||||
return false if attachments.empty?
|
||||
|
||||
attachments_type = attachments.pluck(:type).uniq.first
|
||||
unsupported_file_type?(attachments_type)
|
||||
|
||||
@ -30,7 +30,7 @@ class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuil
|
||||
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
|
||||
if error_code == 1_609_005
|
||||
@message.attachments.destroy_all
|
||||
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
@message.update!(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
end
|
||||
|
||||
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
|
||||
|
||||
@ -14,7 +14,7 @@ class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::Base
|
||||
rescue Koala::Facebook::ClientError => e
|
||||
# The exception occurs when we are trying fetch the deleted story or blocked story.
|
||||
@message.attachments.destroy_all
|
||||
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
@message.update!(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
Rails.logger.error e
|
||||
{}
|
||||
rescue StandardError => e
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
class Messages::MessageBuilder
|
||||
class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
|
||||
include ::FileTypeHelper
|
||||
include ::EmailHelper
|
||||
include ::DataHelper
|
||||
|
||||
attr_reader :message
|
||||
|
||||
def initialize(user, conversation, params)
|
||||
def initialize(user, conversation, params) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
||||
@params = params
|
||||
@private = params[:private] || false
|
||||
@conversation = conversation
|
||||
@ -13,11 +13,16 @@ class Messages::MessageBuilder
|
||||
@account = conversation.account
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@is_recorded_audio = params[:is_recorded_audio]
|
||||
@transcode_audio = params[:transcode_audio]
|
||||
@attachments_metadata = normalize_attachments_metadata(params[:attachments_metadata])
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
|
||||
@in_reply_to = content_attributes&.dig(:in_reply_to)
|
||||
@is_reaction = content_attributes&.dig(:is_reaction)
|
||||
@items = content_attributes&.dig(:items)
|
||||
@zapi_args = content_attributes&.dig(:zapi_args)
|
||||
end
|
||||
|
||||
def perform
|
||||
@ -55,7 +60,7 @@ class Messages::MessageBuilder
|
||||
account_id: @message.account_id,
|
||||
file: uploaded_attachment
|
||||
)
|
||||
|
||||
attachment.meta = process_metadata(uploaded_attachment)
|
||||
attachment.file_type = if uploaded_attachment.is_a?(String)
|
||||
file_type_by_signed_id(
|
||||
uploaded_attachment
|
||||
@ -63,9 +68,71 @@ class Messages::MessageBuilder
|
||||
else
|
||||
file_type(uploaded_attachment&.content_type)
|
||||
end
|
||||
transcode_attachment(attachment, file_like_source(uploaded_attachment)) if should_transcode?(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def process_metadata(attachment)
|
||||
meta = {}
|
||||
meta.merge!(recorded_audio_metadata(attachment) || {})
|
||||
meta.merge!(custom_attachment_metadata(attachment) || {})
|
||||
meta.presence
|
||||
end
|
||||
|
||||
def recorded_audio_metadata(attachment) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
||||
# NOTE: `is_recorded_audio` can be either a boolean, the string "true", or an array of file names.
|
||||
return unless @is_recorded_audio
|
||||
return { is_recorded_audio: true } if @is_recorded_audio == true || @is_recorded_audio == 'true'
|
||||
|
||||
return { is_recorded_audio: true } if @is_recorded_audio.is_a?(Array) && attachment.original_filename.in?(@is_recorded_audio)
|
||||
|
||||
# FIXME: Remove backwards compatibility with old format.
|
||||
if @is_recorded_audio.is_a?(String)
|
||||
parsed = JSON.parse(@is_recorded_audio)
|
||||
{ is_recorded_audio: true } if parsed.is_a?(Array) && attachment.original_filename.in?(parsed)
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def custom_attachment_metadata(attachment)
|
||||
return unless @attachments_metadata.is_a?(Hash)
|
||||
|
||||
filename = attachment.respond_to?(:original_filename) ? attachment.original_filename : nil
|
||||
return unless filename
|
||||
|
||||
metadata = @attachments_metadata[filename]
|
||||
metadata.to_h if metadata.present?
|
||||
end
|
||||
|
||||
def normalize_attachments_metadata(metadata)
|
||||
return if metadata.blank?
|
||||
|
||||
metadata = metadata.to_unsafe_h if metadata.respond_to?(:to_unsafe_h)
|
||||
metadata.deep_stringify_keys
|
||||
end
|
||||
|
||||
def should_transcode?(attachment)
|
||||
@transcode_audio.present? && attachment.file_type == 'audio'
|
||||
end
|
||||
|
||||
# Returns the uploaded file only when it's a real file-like object (ActionDispatch::Http::UploadedFile,
|
||||
# Tempfile, etc.). Direct-upload signed-ID Strings are not usable as source files for transcoding;
|
||||
# TranscodeService falls back to downloading from the blob in that case.
|
||||
def file_like_source(uploaded_attachment)
|
||||
return uploaded_attachment if uploaded_attachment.respond_to?(:path) || uploaded_attachment.respond_to?(:tempfile)
|
||||
end
|
||||
|
||||
def transcode_attachment(attachment, uploaded_file = nil)
|
||||
Audio::TranscodeService.new(attachment, @transcode_audio, source_file: uploaded_file).perform
|
||||
attachment.meta ||= {}
|
||||
attachment.meta['is_recorded_audio'] = true
|
||||
rescue CustomExceptions::Audio::UnsupportedFormatError, CustomExceptions::Audio::TranscodingError => e
|
||||
Rails.logger.error("Audio transcoding failed, keeping original attachment: #{e.message}")
|
||||
attachment.meta ||= {}
|
||||
attachment.meta['audio_transcoding_failed'] = true
|
||||
end
|
||||
|
||||
def process_emails
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
@ -123,12 +190,32 @@ class Messages::MessageBuilder
|
||||
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
|
||||
end
|
||||
|
||||
def scheduled_message_metadata
|
||||
return {} if @params[:scheduled_message].blank?
|
||||
|
||||
sm = @params[:scheduled_message]
|
||||
scheduled_by = { 'id' => sm.author_id, 'type' => sm.author_type }
|
||||
scheduled_by['name'] = sm.author.name if sm.author.respond_to?(:name)
|
||||
|
||||
{
|
||||
additional_attributes: {
|
||||
scheduled_message_id: sm.id,
|
||||
scheduled_by: scheduled_by,
|
||||
scheduled_at: sm.updated_at.to_i
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def message_sender
|
||||
return if @params[:sender_type] != 'AgentBot'
|
||||
|
||||
AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id])
|
||||
end
|
||||
|
||||
def zapi_args
|
||||
@zapi_args.present? ? { zapi_args: @zapi_args } : {}
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
account_id: @conversation.account_id,
|
||||
@ -141,9 +228,11 @@ class Messages::MessageBuilder
|
||||
content_attributes: content_attributes.presence,
|
||||
items: @items,
|
||||
in_reply_to: @in_reply_to,
|
||||
is_reaction: @is_reaction,
|
||||
echo_id: @params[:echo_id],
|
||||
source_id: @params[:source_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id)
|
||||
.deep_merge(template_params).merge(zapi_args).deep_merge(scheduled_message_metadata)
|
||||
end
|
||||
|
||||
def email_inbox?
|
||||
|
||||
@ -46,7 +46,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
||||
return if response['instagram_business_account'].blank?
|
||||
|
||||
instagram_id = response['instagram_business_account']['id']
|
||||
facebook_channel.update(instagram_id: instagram_id)
|
||||
facebook_channel.update!(instagram_id: instagram_id)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error in set_instagram_id: #{e.message}"
|
||||
end
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
class Api::V1::Accounts::Contacts::GroupAdminController < Api::V1::Accounts::Contacts::BaseController
|
||||
VALID_PROPERTIES = %w[announce restrict join_approval_mode member_add_mode].freeze
|
||||
|
||||
def leave
|
||||
authorize @contact, :update?
|
||||
channel.group_leave(@contact.identifier)
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @contact, :update?
|
||||
property = property_params[:property]
|
||||
enabled = ActiveModel::Type::Boolean.new.cast(property_params[:enabled])
|
||||
return render json: { error: 'invalid_property' }, status: :unprocessable_entity unless property.in?(VALID_PROPERTIES)
|
||||
|
||||
apply_property_change(property, enabled)
|
||||
update_contact_attribute(property, enabled)
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_property_change(property, enabled)
|
||||
case property
|
||||
when 'announce', 'restrict'
|
||||
channel.group_setting_update(@contact.identifier, property, enabled)
|
||||
when 'join_approval_mode'
|
||||
channel.group_join_approval_mode(@contact.identifier, enabled ? 'on' : 'off')
|
||||
when 'member_add_mode'
|
||||
channel.group_member_add_mode(@contact.identifier, enabled ? 'all_member_add' : 'admin_add')
|
||||
end
|
||||
end
|
||||
|
||||
def property_params
|
||||
params.permit(:property, :enabled)
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= @contact.group_channel
|
||||
end
|
||||
|
||||
def resolve_group_conversations
|
||||
Current.account.conversations
|
||||
.where(contact_id: @contact.id, group_type: :group, status: %i[open pending])
|
||||
.find_each { |c| c.update!(status: :resolved) }
|
||||
end
|
||||
|
||||
def update_contact_attribute(key, value)
|
||||
new_attrs = (@contact.additional_attributes || {}).merge(key => value)
|
||||
@contact.update!(additional_attributes: new_attrs)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,27 @@
|
||||
class Api::V1::Accounts::Contacts::GroupInvitesController < Api::V1::Accounts::Contacts::BaseController
|
||||
def show
|
||||
authorize @contact, :show?
|
||||
code = channel.group_invite_code(@contact.identifier)
|
||||
render json: invite_response(code)
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def revoke
|
||||
authorize @contact, :update?
|
||||
code = channel.revoke_group_invite(@contact.identifier)
|
||||
render json: invite_response(code)
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def channel
|
||||
@channel ||= @contact.group_channel
|
||||
end
|
||||
|
||||
def invite_response(code)
|
||||
{ invite_code: code, invite_url: "https://chat.whatsapp.com/#{code}" }
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,37 @@
|
||||
class Api::V1::Accounts::Contacts::GroupJoinRequestsController < Api::V1::Accounts::Contacts::BaseController
|
||||
def index
|
||||
authorize @contact, :show?
|
||||
requests = channel.group_join_requests(@contact.identifier)
|
||||
render json: { payload: requests }
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def handle
|
||||
authorize @contact, :update?
|
||||
channel.handle_group_join_requests(@contact.identifier, handle_params[:participants], handle_params[:request_action])
|
||||
remove_handled_requests(handle_params[:participants])
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_params
|
||||
params.permit(:request_action, participants: [])
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= @contact.group_channel
|
||||
end
|
||||
|
||||
def remove_handled_requests(participants)
|
||||
return if participants.blank?
|
||||
|
||||
current_requests = @contact.additional_attributes&.dig('pending_join_requests') || []
|
||||
updated_requests = current_requests.reject { |r| participants.include?(r['jid']) }
|
||||
new_attrs = (@contact.additional_attributes || {}).merge('pending_join_requests' => updated_requests)
|
||||
@contact.update!(additional_attributes: new_attrs)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,155 @@
|
||||
class Api::V1::Accounts::Contacts::GroupMembersController < Api::V1::Accounts::Contacts::BaseController
|
||||
DEFAULT_PER_PAGE = 10
|
||||
|
||||
before_action :ensure_group_contact, only: %i[create update destroy]
|
||||
|
||||
def index
|
||||
authorize @contact, :show?
|
||||
|
||||
base_query = GroupMember.active
|
||||
.where(group_contact: @contact)
|
||||
.includes(:contact)
|
||||
|
||||
@total_count = base_query.count
|
||||
@page = [(params[:page] || 1).to_i, 1].max
|
||||
@per_page = (params[:per_page] || DEFAULT_PER_PAGE).to_i.clamp(1, 100)
|
||||
@inbox_phone_number = inbox_phone_number
|
||||
@is_inbox_admin = inbox_admin?
|
||||
|
||||
paginated = base_query.order(role: :desc, id: :asc)
|
||||
.offset((@page - 1) * @per_page)
|
||||
.limit(@per_page)
|
||||
|
||||
@group_members = pin_own_member_on_first_page(paginated)
|
||||
end
|
||||
|
||||
def create
|
||||
authorize @contact, :update?
|
||||
participants = create_params[:participants]
|
||||
return render json: { error: 'participants_required' }, status: :unprocessable_entity if participants.blank?
|
||||
|
||||
channel.update_group_participants(@contact.identifier, format_participants(participants), 'add')
|
||||
add_group_members(participants)
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @contact, :update?
|
||||
role = update_params[:role]
|
||||
return render json: { error: 'invalid_role' }, status: :unprocessable_entity unless %w[admin member].include?(role)
|
||||
|
||||
member = group_members.find(params[:member_id])
|
||||
action = role == 'admin' ? 'promote' : 'demote'
|
||||
channel.update_group_participants(@contact.identifier, [jid_for_member(member)], action)
|
||||
member.update!(role: role)
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError
|
||||
render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contact, :update?
|
||||
|
||||
member = group_members.find(params[:id])
|
||||
channel.update_group_participants(@contact.identifier, [jid_for_member(member)], 'remove')
|
||||
member.update!(is_active: false)
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError
|
||||
render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_group_contact
|
||||
return if @contact.group_type_group? && @contact.identifier.present?
|
||||
|
||||
render json: { error: 'Contact is not a valid group' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def group_members
|
||||
GroupMember.where(group_contact: @contact)
|
||||
end
|
||||
|
||||
def create_params
|
||||
params.permit(participants: [])
|
||||
end
|
||||
|
||||
def update_params
|
||||
params.permit(:role)
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= @contact.group_channel
|
||||
end
|
||||
|
||||
def inbox_phone_number
|
||||
channel&.phone_number
|
||||
end
|
||||
|
||||
def inbox_admin?
|
||||
return false if @inbox_phone_number.blank?
|
||||
|
||||
find_own_member&.role == 'admin'
|
||||
end
|
||||
|
||||
def pin_own_member_on_first_page(paginated)
|
||||
return paginated unless @page == 1 && @inbox_phone_number.present?
|
||||
|
||||
ids = paginated.pluck(:id)
|
||||
own = find_own_member
|
||||
return paginated if own.blank? || ids.include?(own.id)
|
||||
|
||||
# Prepend own member; drop the last one so total per-page stays consistent
|
||||
[own] + paginated.where.not(id: own.id).limit(@per_page - 1).to_a
|
||||
end
|
||||
|
||||
def find_own_member
|
||||
clean = @inbox_phone_number.delete('+')
|
||||
GroupMember.active
|
||||
.where(group_contact: @contact)
|
||||
.joins(:contact)
|
||||
.where('REPLACE(contacts.phone_number, \'+\', \'\') = ? OR RIGHT(REPLACE(contacts.phone_number, \'+\', \'\'), 8) = RIGHT(?, 8)',
|
||||
clean, clean)
|
||||
.includes(:contact)
|
||||
.first
|
||||
end
|
||||
|
||||
def format_participants(phone_numbers)
|
||||
Array(phone_numbers).map { |phone| "#{phone.to_s.delete('+')}@s.whatsapp.net" }
|
||||
end
|
||||
|
||||
def jid_for_member(member)
|
||||
"#{member.contact.phone_number.to_s.delete('+')}@s.whatsapp.net"
|
||||
end
|
||||
|
||||
def add_group_members(phone_numbers)
|
||||
inbox = @contact.contact_inboxes.first&.inbox
|
||||
Array(phone_numbers).each do |phone|
|
||||
normalized = normalize_phone(phone)
|
||||
next if normalized.blank?
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: normalized.delete('+'),
|
||||
inbox: inbox,
|
||||
contact_attributes: { name: normalized, phone_number: normalized }
|
||||
).perform
|
||||
next if contact_inbox.blank?
|
||||
|
||||
member = GroupMember.find_or_initialize_by(group_contact: @contact, contact: contact_inbox.contact)
|
||||
member.update!(role: :member, is_active: true) unless member.persisted? && member.is_active?
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_phone(phone)
|
||||
cleaned = phone.to_s.strip
|
||||
return nil if cleaned.blank?
|
||||
|
||||
cleaned.start_with?('+') ? cleaned : "+#{cleaned}"
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,39 @@
|
||||
class Api::V1::Accounts::Contacts::GroupMetadataController < Api::V1::Accounts::Contacts::BaseController
|
||||
def update
|
||||
authorize @contact, :update?
|
||||
update_subject if metadata_params[:subject].present?
|
||||
update_description if metadata_params[:description].present?
|
||||
update_picture if metadata_params[:avatar].present?
|
||||
render json: { id: @contact.id, name: @contact.name, additional_attributes: @contact.additional_attributes }
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metadata_params
|
||||
params.permit(:subject, :description, :avatar)
|
||||
end
|
||||
|
||||
def update_subject
|
||||
channel.update_group_subject(@contact.identifier, metadata_params[:subject])
|
||||
@contact.update!(name: metadata_params[:subject])
|
||||
end
|
||||
|
||||
def update_description
|
||||
channel.update_group_description(@contact.identifier, metadata_params[:description])
|
||||
attrs = @contact.additional_attributes.merge('description' => metadata_params[:description])
|
||||
@contact.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def update_picture
|
||||
avatar = metadata_params[:avatar]
|
||||
image_base64 = Base64.strict_encode64(avatar.read)
|
||||
channel.update_group_picture(@contact.identifier, image_base64)
|
||||
@contact.avatar.attach(avatar)
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= @contact.group_channel
|
||||
end
|
||||
end
|
||||
@ -13,7 +13,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
|
||||
before_action :check_authorization
|
||||
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, :sync_group]
|
||||
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
|
||||
|
||||
def index
|
||||
@ -82,6 +82,15 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
@contact.save!
|
||||
end
|
||||
|
||||
def sync_group
|
||||
authorize @contact, :sync_group?
|
||||
raise ActionController::BadRequest, I18n.t('contacts.sync_group.not_a_group') if @contact.group_type_individual?
|
||||
raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_identifier') if @contact.identifier.blank?
|
||||
|
||||
Contacts::SyncGroupJob.perform_later(@contact)
|
||||
head :accepted
|
||||
end
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
class Api::V1::Accounts::Conversations::AttachmentsController < Api::V1::Accounts::Conversations::BaseController
|
||||
before_action :set_message
|
||||
before_action :set_attachment
|
||||
before_action :validate_meta_size, only: [:update]
|
||||
|
||||
MAX_META_SIZE = 16.kilobytes
|
||||
|
||||
def update
|
||||
@attachment.update!(permitted_params)
|
||||
@attachment.message.send_update_event
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_message
|
||||
@message = @conversation.messages.find(params[:message_id])
|
||||
end
|
||||
|
||||
def set_attachment
|
||||
@attachment = @message.attachments.find(params[:id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(meta: {})
|
||||
end
|
||||
|
||||
def validate_meta_size
|
||||
return if params[:meta].blank?
|
||||
|
||||
return unless params[:meta].to_json.bytesize > MAX_META_SIZE
|
||||
|
||||
render json: { error: "Metadata size exceeds maximum allowed (#{MAX_META_SIZE / 1024}KB)" }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@ -1,4 +1,6 @@
|
||||
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
|
||||
include Events::Types
|
||||
|
||||
before_action :ensure_api_inbox, only: :update
|
||||
|
||||
def index
|
||||
@ -9,6 +11,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
user = Current.user || @resource
|
||||
mb = Messages::MessageBuilder.new(user, @conversation, params)
|
||||
@message = mb.perform
|
||||
|
||||
trigger_typing_event(CONVERSATION_TYPING_OFF)
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
@ -23,14 +27,16 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
|
||||
message.attachments.destroy_all
|
||||
end
|
||||
delete_message_on_channel
|
||||
end
|
||||
|
||||
def retry
|
||||
return if message.blank?
|
||||
return head :unprocessable_entity unless message.failed? && (message.outgoing? || message.template?)
|
||||
|
||||
service = Messages::StatusUpdateService.new(message, 'sent')
|
||||
service.perform
|
||||
message.update!(content_attributes: {})
|
||||
message.update!(content_attributes: {}, source_id: nil)
|
||||
::SendReplyJob.perform_later(message.id)
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
@ -54,6 +60,22 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
render json: { content: translated_content }
|
||||
end
|
||||
|
||||
def edit_content
|
||||
new_content = params[:content]
|
||||
return render json: { error: 'Content is required' }, status: :unprocessable_entity if new_content.blank?
|
||||
return render json: { error: 'Content exceeds maximum length' }, status: :unprocessable_entity if new_content.length > 150_000
|
||||
return render json: { error: 'Only outgoing messages can be edited' }, status: :forbidden unless message.outgoing?
|
||||
|
||||
original_content = message.content
|
||||
# Only save previous_content on first edit to preserve the original message
|
||||
previous_content_to_save = message.is_edited ? message.previous_content : original_content
|
||||
message.update!(content: new_content, is_edited: true, previous_content: previous_content_to_save)
|
||||
|
||||
edit_message_on_channel(new_content, original_content)
|
||||
|
||||
@message = message.reload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def message
|
||||
@ -65,16 +87,48 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :target_language, :status, :external_error)
|
||||
params.permit(:id, :target_language, :status, :external_error, :content)
|
||||
end
|
||||
|
||||
def already_translated_content_available?
|
||||
message.translations.present? && message.translations[permitted_params[:target_language]].present?
|
||||
end
|
||||
|
||||
def delete_message_on_channel
|
||||
return unless @conversation.inbox.channel.respond_to?(:delete_message)
|
||||
return if message.source_id.blank?
|
||||
|
||||
@conversation.inbox.channel.delete_message(message, conversation: @conversation)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to delete message on channel: #{e.message}"
|
||||
end
|
||||
|
||||
def edit_message_on_channel(new_content, original_content)
|
||||
return unless @conversation.inbox.channel.respond_to?(:edit_message)
|
||||
return if message.source_id.blank?
|
||||
|
||||
@conversation.inbox.channel.edit_message(message, new_content, conversation: @conversation)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to edit message on channel: #{e.message}"
|
||||
was_already_edited = message.previous_content != original_content
|
||||
if was_already_edited
|
||||
message.update!(content: original_content)
|
||||
else
|
||||
message.update!(content: original_content, is_edited: false, previous_content: nil)
|
||||
end
|
||||
raise e
|
||||
end
|
||||
|
||||
# API inbox check
|
||||
def ensure_api_inbox
|
||||
# Only API inboxes can update messages
|
||||
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
|
||||
end
|
||||
|
||||
def trigger_typing_event(event)
|
||||
user = Current.user || @resource
|
||||
return unless user.is_a?(User)
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: params[:private])
|
||||
end
|
||||
end
|
||||
|
||||
@ -0,0 +1,176 @@
|
||||
class Api::V1::Accounts::Conversations::RecurringScheduledMessagesController < Api::V1::Accounts::Conversations::BaseController
|
||||
include Events::Types
|
||||
|
||||
before_action :set_recurring_scheduled_message, only: [:update, :destroy]
|
||||
|
||||
MAX_LIMIT = 50
|
||||
|
||||
def index
|
||||
authorize build_recurring_scheduled_message
|
||||
@recurring_scheduled_messages = @conversation.recurring_scheduled_messages
|
||||
.includes(:scheduled_messages, :author)
|
||||
.order(Arel.sql('CASE status WHEN 1 THEN 0 WHEN 0 THEN 1 ELSE 2 END, created_at DESC'))
|
||||
.limit(MAX_LIMIT)
|
||||
end
|
||||
|
||||
def create
|
||||
@recurring_scheduled_message = build_recurring_scheduled_message
|
||||
authorize @recurring_scheduled_message
|
||||
@recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@recurring_scheduled_message.save!
|
||||
create_first_occurrence if @recurring_scheduled_message.active?
|
||||
end
|
||||
|
||||
dispatch_event(RECURRING_SCHEDULED_MESSAGE_CREATED)
|
||||
end
|
||||
|
||||
def update
|
||||
@recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@recurring_scheduled_message.save!
|
||||
@recurring_scheduled_message.attachment.purge if params[:remove_attachment].present? && @recurring_scheduled_message.attachment.attached?
|
||||
|
||||
if @recurring_scheduled_message.active?
|
||||
reschedule_pending_occurrence
|
||||
else
|
||||
@recurring_scheduled_message.scheduled_messages.pending.destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED)
|
||||
end
|
||||
|
||||
def destroy
|
||||
cancel_recurring_message
|
||||
dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_recurring_scheduled_message
|
||||
@recurring_scheduled_message = @conversation.recurring_scheduled_messages.find(params[:id])
|
||||
authorize @recurring_scheduled_message
|
||||
end
|
||||
|
||||
def build_recurring_scheduled_message
|
||||
@conversation.recurring_scheduled_messages.new(account: Current.account, inbox: @conversation.inbox, author: Current.user)
|
||||
end
|
||||
|
||||
def recurring_scheduled_message_params
|
||||
permitted = params.permit(
|
||||
:content,
|
||||
:status,
|
||||
:attachment,
|
||||
template_params: {},
|
||||
recurrence_rule: [:frequency, :interval, :end_type, :end_date, :end_count,
|
||||
:monthly_type, :monthly_week, :monthly_weekday, :month_day,
|
||||
:year_day, :year_month, { week_days: [] }]
|
||||
)
|
||||
|
||||
permitted[:recurrence_rule] = cast_recurrence_rule(permitted[:recurrence_rule].to_h) if permitted[:recurrence_rule].present?
|
||||
|
||||
permitted
|
||||
end
|
||||
|
||||
def cast_recurrence_rule(rule)
|
||||
integer_keys = %w[interval end_count monthly_week monthly_weekday month_day year_day year_month]
|
||||
rule.each_with_object({}) do |(key, value), hash|
|
||||
hash[key] = if key == 'week_days' && value.is_a?(Array)
|
||||
value.map(&:to_i)
|
||||
elsif integer_keys.include?(key)
|
||||
value.to_i
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_first_occurrence
|
||||
scheduled_at = params[:scheduled_at]
|
||||
return if scheduled_at.blank?
|
||||
|
||||
sm = @recurring_scheduled_message.scheduled_messages.create!(
|
||||
content: @recurring_scheduled_message.content,
|
||||
template_params: @recurring_scheduled_message.template_params,
|
||||
scheduled_at: scheduled_at,
|
||||
status: :pending,
|
||||
account: @recurring_scheduled_message.account,
|
||||
conversation: @recurring_scheduled_message.conversation,
|
||||
inbox: @recurring_scheduled_message.inbox,
|
||||
author: @recurring_scheduled_message.author
|
||||
)
|
||||
copy_attachment(sm) if @recurring_scheduled_message.attachment.attached?
|
||||
end
|
||||
|
||||
def reschedule_pending_occurrence
|
||||
@recurring_scheduled_message.scheduled_messages.pending.destroy_all
|
||||
|
||||
next_scheduled_at = compute_next_valid_date
|
||||
return if next_scheduled_at.blank?
|
||||
|
||||
sm = @recurring_scheduled_message.scheduled_messages.create!(
|
||||
content: @recurring_scheduled_message.content,
|
||||
template_params: @recurring_scheduled_message.template_params,
|
||||
scheduled_at: next_scheduled_at,
|
||||
status: :pending,
|
||||
account: @recurring_scheduled_message.account,
|
||||
conversation: @recurring_scheduled_message.conversation,
|
||||
inbox: @recurring_scheduled_message.inbox,
|
||||
author: @recurring_scheduled_message.author
|
||||
)
|
||||
copy_attachment(sm) if @recurring_scheduled_message.attachment.attached?
|
||||
end
|
||||
|
||||
def compute_next_valid_date
|
||||
user_date = params[:scheduled_at].present? ? Time.zone.parse(params[:scheduled_at].to_s) : nil
|
||||
rule = @recurring_scheduled_message.recurrence_rule
|
||||
|
||||
return user_date if user_date.present? && date_matches_rule?(user_date, rule)
|
||||
|
||||
base = [user_date, Time.current].compact.max
|
||||
RecurringScheduledMessages::RecurrenceCalculatorService
|
||||
.new(recurrence_rule: rule, last_date: base)
|
||||
.next_date
|
||||
end
|
||||
|
||||
def date_matches_rule?(date, rule)
|
||||
return true unless rule.is_a?(Hash)
|
||||
|
||||
rule = rule.with_indifferent_access
|
||||
return true unless rule[:frequency] == 'weekly' && rule[:week_days].present?
|
||||
|
||||
rule[:week_days].map(&:to_i).include?(date.wday)
|
||||
end
|
||||
|
||||
def cancel_recurring_message
|
||||
@recurring_scheduled_message.scheduled_messages.pending.destroy_all
|
||||
@recurring_scheduled_message.update!(status: :cancelled)
|
||||
|
||||
I18n.with_locale(@recurring_scheduled_message.account.locale) do
|
||||
@recurring_scheduled_message.conversation.messages.create!(
|
||||
account: @recurring_scheduled_message.account,
|
||||
inbox: @recurring_scheduled_message.inbox,
|
||||
message_type: :activity,
|
||||
content: I18n.t(
|
||||
'conversations.activity.recurring_message_cancelled',
|
||||
agent: @recurring_scheduled_message.author&.name || I18n.t('conversations.activity.unknown_agent')
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def copy_attachment(scheduled_message)
|
||||
scheduled_message.attachment.attach(@recurring_scheduled_message.attachment.blob)
|
||||
end
|
||||
|
||||
def dispatch_event(event_name)
|
||||
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, recurring_scheduled_message: @recurring_scheduled_message)
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::Conversations::RecurringScheduledMessagesController.prepend_mod_with(
|
||||
'Api::V1::Accounts::Conversations::RecurringScheduledMessagesController'
|
||||
)
|
||||
@ -0,0 +1,69 @@
|
||||
class Api::V1::Accounts::Conversations::ScheduledMessagesController < Api::V1::Accounts::Conversations::BaseController
|
||||
include Events::Types
|
||||
|
||||
before_action :scheduled_message, only: [:update, :destroy]
|
||||
|
||||
MAX_LIMIT = 100
|
||||
|
||||
def index
|
||||
authorize build_scheduled_message
|
||||
@scheduled_messages = @conversation.scheduled_messages
|
||||
.includes(:recurring_scheduled_message)
|
||||
.order(scheduled_at: :desc)
|
||||
.limit(MAX_LIMIT)
|
||||
end
|
||||
|
||||
def create
|
||||
@scheduled_message = build_scheduled_message
|
||||
authorize @scheduled_message
|
||||
@scheduled_message.assign_attributes(scheduled_message_params)
|
||||
@scheduled_message.save!
|
||||
dispatch_event(SCHEDULED_MESSAGE_CREATED, scheduled_message: @scheduled_message)
|
||||
end
|
||||
|
||||
def update
|
||||
@scheduled_message.assign_attributes(scheduled_message_params)
|
||||
@scheduled_message.attachment.purge if params[:remove_attachment].present? && @scheduled_message.attachment.attached?
|
||||
@scheduled_message.save!
|
||||
dispatch_event(SCHEDULED_MESSAGE_UPDATED, scheduled_message: @scheduled_message)
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @scheduled_message.sent? || @scheduled_message.failed?
|
||||
return render json: { error: I18n.t('errors.scheduled_messages.cannot_delete_processed') }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
scheduled_message = @scheduled_message
|
||||
scheduled_message.destroy!
|
||||
dispatch_event(SCHEDULED_MESSAGE_DELETED, scheduled_message: scheduled_message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scheduled_message
|
||||
@scheduled_message ||= @conversation.scheduled_messages.find(params[:id])
|
||||
authorize @scheduled_message
|
||||
end
|
||||
|
||||
def build_scheduled_message
|
||||
@conversation.scheduled_messages.new(account: Current.account, inbox: @conversation.inbox, author: Current.user)
|
||||
end
|
||||
|
||||
def scheduled_message_params
|
||||
params.permit(
|
||||
:content,
|
||||
:scheduled_at,
|
||||
:status,
|
||||
:attachment,
|
||||
template_params: {}
|
||||
)
|
||||
end
|
||||
|
||||
def dispatch_event(event_name, data)
|
||||
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, data)
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::Conversations::ScheduledMessagesController.prepend_mod_with(
|
||||
'Api::V1::Accounts::Conversations::ScheduledMessagesController'
|
||||
)
|
||||
@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength
|
||||
include Events::Types
|
||||
include DateRangeHelper
|
||||
include HmacConcern
|
||||
@ -122,10 +122,14 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# No unread messages - apply throttling to limit DB writes
|
||||
return unless should_update_last_seen?
|
||||
|
||||
dispatch_messages_read_event if assignee?
|
||||
|
||||
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
|
||||
end
|
||||
|
||||
def unread
|
||||
Rails.configuration.dispatcher.dispatch(Events::Types::CONVERSATION_UNREAD, Time.zone.now, conversation: @conversation)
|
||||
|
||||
last_incoming_message = @conversation.messages.incoming.last
|
||||
last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present?
|
||||
update_last_seen_on_conversation(last_seen_at, true)
|
||||
@ -162,7 +166,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def unseen_activity?
|
||||
@conversation.last_activity_at.present? &&
|
||||
(@conversation.agent_last_seen_at.blank? || @conversation.last_activity_at > @conversation.agent_last_seen_at)
|
||||
end
|
||||
|
||||
def should_update_last_seen?
|
||||
# Always update when there's unseen activity (e.g. soft-disabled group conversations that don't create messages)
|
||||
return true if unseen_activity?
|
||||
|
||||
# Update if at least one relevant timestamp is older than 1 hour or not set
|
||||
# This prevents redundant DB writes when agents repeatedly view the same conversation
|
||||
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
|
||||
@ -231,6 +243,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
def assignee?
|
||||
@conversation.assignee_id? && Current.user == @conversation.assignee
|
||||
end
|
||||
|
||||
def dispatch_messages_read_event
|
||||
# NOTE: Use old `agent_last_seen_at`, so we reference messages received after that
|
||||
Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation,
|
||||
last_seen_at: @conversation.agent_last_seen_at)
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')
|
||||
|
||||
@ -34,6 +34,7 @@ class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseContro
|
||||
def permitted_payload
|
||||
params.require(:dashboard_app).permit(
|
||||
:title,
|
||||
:show_on_sidebar,
|
||||
content: [:url, :type]
|
||||
)
|
||||
end
|
||||
|
||||
26
app/controllers/api/v1/accounts/groups_controller.rb
Normal file
26
app/controllers/api/v1/accounts/groups_controller.rb
Normal file
@ -0,0 +1,26 @@
|
||||
class Api::V1::Accounts::GroupsController < Api::V1::Accounts::BaseController
|
||||
def create
|
||||
inbox = Current.account.inboxes.find_by(id: group_params[:inbox_id])
|
||||
return render json: { error: 'Access Denied' }, status: :forbidden unless inbox_accessible?(inbox)
|
||||
|
||||
result = Groups::CreateService.new(
|
||||
inbox: inbox,
|
||||
subject: group_params[:subject],
|
||||
participants: Array(group_params[:participants])
|
||||
).perform
|
||||
|
||||
render json: result
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def group_params
|
||||
params.permit(:inbox_id, :subject, participants: [])
|
||||
end
|
||||
|
||||
def inbox_accessible?(inbox)
|
||||
inbox.present? && Current.user.assigned_inboxes.exists?(id: inbox.id) && inbox.channel.try(:allow_group_creation?)
|
||||
end
|
||||
end
|
||||
@ -42,6 +42,26 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def link
|
||||
link_params = params.require(:template).permit(:name, :language, body_variables: {})
|
||||
return render json: { error: 'Template name is required' }, status: :unprocessable_entity if link_params[:name].blank?
|
||||
|
||||
service = CsatTemplateManagementService.new(@inbox)
|
||||
result = service.link_existing_template(
|
||||
link_params[:name], link_params[:language], body_variables: link_params[:body_variables].to_h
|
||||
)
|
||||
|
||||
render_link_result(result)
|
||||
rescue ActionController::ParameterMissing
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def available_templates
|
||||
service = CsatTemplateManagementService.new(@inbox)
|
||||
templates = service.available_templates
|
||||
render json: { templates: templates }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@ -70,6 +90,22 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
|
||||
end
|
||||
|
||||
def render_link_result(result)
|
||||
if result[:success]
|
||||
render json: {
|
||||
template: {
|
||||
name: result[:template_name], template_id: result[:template_id],
|
||||
status: result[:status], language: result[:language], source: result[:source],
|
||||
linked_at: result[:linked_at]
|
||||
}
|
||||
}, status: :ok
|
||||
elsif result[:error]
|
||||
render json: { error: result[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: { error: result[:service_error] || 'An unexpected error occurred' }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
def render_template_creation_result(result)
|
||||
if result[:success]
|
||||
render_successful_template_creation(result)
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength
|
||||
include Api::V1::InboxesHelper
|
||||
before_action :fetch_inbox, except: [:index, :create]
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show, :health]
|
||||
before_action :check_authorization, except: [:show, :health, :setup_channel_provider]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
|
||||
def index
|
||||
@ -65,6 +65,30 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def setup_channel_provider
|
||||
channel = @inbox.channel
|
||||
|
||||
unless channel.respond_to?(:setup_channel_provider)
|
||||
render json: { error: 'Channel does not support setup' }, status: :unprocessable_entity and return
|
||||
end
|
||||
|
||||
channel.setup_channel_provider
|
||||
head :ok
|
||||
end
|
||||
|
||||
def disconnect_channel_provider
|
||||
channel = @inbox.channel
|
||||
|
||||
unless channel.respond_to?(:disconnect_channel_provider)
|
||||
render json: { error: 'Channel does not support disconnect' }, status: :unprocessable_entity and return
|
||||
end
|
||||
|
||||
channel.disconnect_channel_provider
|
||||
head :ok
|
||||
ensure
|
||||
channel.update_provider_connection!(connection: 'close') if channel.respond_to?(:update_provider_connection!)
|
||||
end
|
||||
|
||||
def destroy
|
||||
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
@ -87,6 +111,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def on_whatsapp
|
||||
params.require(:phone_number)
|
||||
phone_number = params[:phone_number]
|
||||
channel = @inbox.channel
|
||||
|
||||
unless channel.respond_to?(:on_whatsapp)
|
||||
render json: { error: 'Channel does not support whatsapp check' }, status: :unprocessable_entity and return
|
||||
end
|
||||
|
||||
response = channel.on_whatsapp(phone_number)
|
||||
|
||||
render json: response, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@ -176,7 +214,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||
{ survey_rules: [:operator, { values: [] }],
|
||||
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
|
||||
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid,
|
||||
:created_at, :linked_at, :language, :source, :status, { body_variables: {} }] }] }]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
|
||||
@ -16,7 +16,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def update
|
||||
@hook = channel_builder.update(permitted_params[:reference_id])
|
||||
@hook = channel_builder.update_reference_id(permitted_params[:reference_id])
|
||||
render json: { error: I18n.t('errors.slack.invalid_channel_id') }, status: :unprocessable_entity if @hook.blank?
|
||||
end
|
||||
|
||||
|
||||
@ -25,17 +25,17 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def update
|
||||
@notification.update(read_at: DateTime.now.utc)
|
||||
@notification.update!(read_at: DateTime.now.utc)
|
||||
render json: @notification
|
||||
end
|
||||
|
||||
def unread
|
||||
@notification.update(read_at: nil)
|
||||
@notification.update!(read_at: nil)
|
||||
render json: @notification
|
||||
end
|
||||
|
||||
def destroy
|
||||
@notification.destroy
|
||||
@notification.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
@ -55,7 +55,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def snooze
|
||||
updated_meta = (@notification.meta || {}).merge('last_snoozed_at' => nil)
|
||||
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s), meta: updated_meta) if params[:snoozed_until]
|
||||
@notification.update!(snoozed_until: parse_date_time(params[:snoozed_until].to_s), meta: updated_meta) if params[:snoozed_until]
|
||||
render json: @notification
|
||||
end
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
|
||||
@portal.update!(merged_portal_params.merge(live_chat_widget_params)) if params[:portal].present?
|
||||
# @portal.custom_domain = parsed_custom_domain
|
||||
process_attached_logo if params[:blob_id].present?
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@ -37,7 +37,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def archive
|
||||
@portal.update(archive: true)
|
||||
@portal.update!(archive: true)
|
||||
head :ok
|
||||
end
|
||||
|
||||
@ -79,10 +79,21 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||
:name, :page_title, :slug, :archived, :custom_head_html, :custom_body_html,
|
||||
{ config: [:default_locale, :show_author, { allowed_locales: [] }] }
|
||||
)
|
||||
end
|
||||
|
||||
def merged_portal_params
|
||||
update_params = portal_params.to_h
|
||||
if update_params.key?('config')
|
||||
base_config = @portal.config.is_a?(Hash) ? @portal.config : {}
|
||||
incoming_config = update_params['config']
|
||||
update_params['config'] = incoming_config.is_a?(Hash) ? base_config.merge(incoming_config) : base_config
|
||||
end
|
||||
update_params
|
||||
end
|
||||
|
||||
def live_chat_widget_params
|
||||
permitted_params = params.permit(:inbox_id)
|
||||
return {} unless permitted_params.key?(:inbox_id)
|
||||
|
||||
@ -7,12 +7,12 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def create
|
||||
@webhook = Current.account.webhooks.new(webhook_params)
|
||||
@webhook = Current.account.webhooks.new(webhook_create_params)
|
||||
@webhook.save!
|
||||
end
|
||||
|
||||
def update
|
||||
@webhook.update!(webhook_params)
|
||||
@webhook.update!(webhook_update_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@ -22,10 +22,14 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
||||
|
||||
private
|
||||
|
||||
def webhook_params
|
||||
def webhook_create_params
|
||||
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
|
||||
end
|
||||
|
||||
def webhook_update_params
|
||||
params.require(:webhook).permit(:name, subscriptions: [])
|
||||
end
|
||||
|
||||
def fetch_webhook
|
||||
@webhook = Current.account.webhooks.find(params[:id])
|
||||
end
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
class Api::V1::Profile::InboxSignaturesController < Api::BaseController
|
||||
before_action :set_user
|
||||
before_action :set_inbox_signature, only: %i[show update destroy]
|
||||
before_action :validate_inbox_access, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
if params[:account_id].present?
|
||||
validate_account_access!
|
||||
return if performed?
|
||||
|
||||
@inbox_signatures = @user.inbox_signatures.joins(:inbox).where(inboxes: { account_id: params[:account_id] })
|
||||
else
|
||||
@inbox_signatures = @user.inbox_signatures
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
head :not_found and return unless @inbox_signature
|
||||
end
|
||||
|
||||
def update
|
||||
if @inbox_signature
|
||||
@inbox_signature.update!(inbox_signature_params)
|
||||
else
|
||||
@inbox_signature = @user.inbox_signatures.create!(
|
||||
inbox_signature_params.merge(inbox_id: params[:inbox_id])
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@inbox_signature&.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = current_user
|
||||
end
|
||||
|
||||
def set_inbox_signature
|
||||
@inbox_signature = @user.inbox_signatures.find_by(inbox_id: params[:inbox_id])
|
||||
end
|
||||
|
||||
def inbox_signature_params
|
||||
params.require(:inbox_signature).permit(:message_signature, :signature_position, :signature_separator)
|
||||
end
|
||||
|
||||
def validate_inbox_access
|
||||
inbox_id = params[:inbox_id]
|
||||
return if InboxMember.exists?(user_id: @user.id, inbox_id: inbox_id)
|
||||
|
||||
head :unauthorized
|
||||
end
|
||||
|
||||
def validate_account_access!
|
||||
account_id = params[:account_id]
|
||||
return if @user.account_ids.include?(account_id.to_i)
|
||||
|
||||
head :unauthorized
|
||||
end
|
||||
end
|
||||
@ -26,10 +26,14 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
|
||||
def availability
|
||||
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(Events::Types::ACCOUNT_PRESENCE_UPDATED, Time.zone.now, account_id: availability_params[:account_id],
|
||||
user_id: @current_user.id,
|
||||
status: availability_params[:availability])
|
||||
end
|
||||
|
||||
def set_active_account
|
||||
@user.account_users.find_by(account_id: profile_params[:account_id]).update(active_at: Time.now.utc)
|
||||
@user.account_users.find_by(account_id: profile_params[:account_id]).update!(active_at: Time.now.utc)
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
contact = @contact
|
||||
end
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||
@contact_inbox.update!(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||
|
||||
identify_contact(contact)
|
||||
end
|
||||
|
||||
@ -47,6 +47,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
case permitted_params[:typing_status]
|
||||
when 'on'
|
||||
trigger_typing_event(CONVERSATION_TYPING_ON)
|
||||
when 'recording'
|
||||
trigger_typing_event(CONVERSATION_RECORDING)
|
||||
when 'off'
|
||||
trigger_typing_event(CONVERSATION_TYPING_OFF)
|
||||
end
|
||||
@ -91,7 +93,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def render_not_found_if_empty
|
||||
return head :not_found if conversation.nil?
|
||||
head :not_found if conversation.nil?
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@ -18,7 +18,7 @@ class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
ui_settings = Current.user.ui_settings || {}
|
||||
ui_settings[cache_key] = data
|
||||
Current.user.update(ui_settings: ui_settings)
|
||||
Current.user.update!(ui_settings: ui_settings)
|
||||
|
||||
render json: data
|
||||
end
|
||||
|
||||
@ -12,7 +12,7 @@ class ApiController < ApplicationController
|
||||
|
||||
def redis_status
|
||||
r = Redis.new(Redis::Config.app)
|
||||
return 'ok' if r.ping
|
||||
'ok' if r.ping
|
||||
rescue Redis::CannotConnectError
|
||||
'failing'
|
||||
end
|
||||
|
||||
@ -6,7 +6,7 @@ module AttachmentConcern
|
||||
return [blobs, actions, nil] if actions.blank?
|
||||
|
||||
sanitized = actions.map do |action|
|
||||
next action unless action[:action_name] == 'send_attachment'
|
||||
next action unless attachment_action?(action)
|
||||
|
||||
result = process_attachment_action(action, record, blobs)
|
||||
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
|
||||
@ -20,15 +20,39 @@ module AttachmentConcern
|
||||
private
|
||||
|
||||
def process_attachment_action(action, record, blobs)
|
||||
blob_id = action[:action_params].first
|
||||
blob_id = attachment_blob_id(action)
|
||||
return action if action[:action_name] == 'create_scheduled_message' && blob_id.blank?
|
||||
|
||||
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
|
||||
|
||||
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
|
||||
return action.merge(action_params: attachment_action_params(action, blob.id)).tap { blobs << blob } if blob.present?
|
||||
return action if blob_already_attached?(record, blob_id)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def attachment_action?(action)
|
||||
%w[send_attachment create_scheduled_message].include?(action[:action_name])
|
||||
end
|
||||
|
||||
def attachment_blob_id(action)
|
||||
return action[:action_params].first unless action[:action_name] == 'create_scheduled_message'
|
||||
|
||||
params = action[:action_params].first
|
||||
params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
|
||||
params&.with_indifferent_access&.dig(:blob_id)
|
||||
end
|
||||
|
||||
def attachment_action_params(action, blob_id)
|
||||
return [blob_id] unless action[:action_name] == 'create_scheduled_message'
|
||||
|
||||
params = action[:action_params].first
|
||||
params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
|
||||
params = params.with_indifferent_access
|
||||
params[:blob_id] = blob_id
|
||||
[params]
|
||||
end
|
||||
|
||||
def blob_already_attached?(record, blob_id)
|
||||
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
|
||||
end
|
||||
|
||||
@ -78,6 +78,7 @@ class DashboardController < ActionController::Base
|
||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
BAILEYS_WHATSAPP_GROUPS_ENABLED: Whatsapp::Providers::WhatsappBaileysService.groups_enabled?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH,
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
||||
|
||||
@ -2,6 +2,6 @@
|
||||
# authentication, and callbacks. Used for health checks
|
||||
class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||
def show
|
||||
render json: { status: 'woot' }
|
||||
render json: { status: 'woot', platform: 'fazer.ai', version: Chatwoot.config[:version] }
|
||||
end
|
||||
end
|
||||
|
||||
@ -12,7 +12,7 @@ class Platform::Api::V1::AccountsController < PlatformController
|
||||
@resource = Account.create!(account_params)
|
||||
update_resource_features
|
||||
@resource.save!
|
||||
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
|
||||
@platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource)
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@ -12,7 +12,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
|
||||
@resource = AgentBot.new(agent_bot_params.except(:avatar_url))
|
||||
@resource.save!
|
||||
process_avatar_from_url
|
||||
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
|
||||
@platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource)
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@ -31,7 +31,7 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
||||
return if params[:identifier_hash].blank? && !@inbox_channel.hmac_mandatory
|
||||
raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac?
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if @contact_inbox.present?
|
||||
@contact_inbox.update!(hmac_verified: true) if @contact_inbox.present?
|
||||
end
|
||||
|
||||
def valid_hmac?
|
||||
|
||||
@ -30,6 +30,8 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
|
||||
case params[:typing_status]
|
||||
when 'on'
|
||||
trigger_typing_event(CONVERSATION_TYPING_ON)
|
||||
when 'recording'
|
||||
trigger_typing_event(CONVERSATION_RECORDING)
|
||||
when 'off'
|
||||
trigger_typing_event(CONVERSATION_TYPING_OFF)
|
||||
end
|
||||
|
||||
@ -13,7 +13,7 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
|
||||
resource = resource_class.new(resource_params)
|
||||
authorize_resource(resource)
|
||||
|
||||
notice = resource.save ? translate_with_resource('create.success') : resource.errors.full_messages.first
|
||||
notice = resource.save ? translate_with_resource('create.success') : resource.errors.full_messages.first
|
||||
redirect_back(fallback_location: [namespace, resource.account], notice: notice)
|
||||
end
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
params['app_config'].each do |key, value|
|
||||
next unless @allowed_configs.include?(key)
|
||||
|
||||
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
||||
i = InstallationConfig.where(name: key).first_or_create!(value: value, locked: false)
|
||||
i.value = value
|
||||
errors.concat(i.errors.full_messages) unless i.save
|
||||
end
|
||||
|
||||
@ -7,6 +7,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
||||
redis_metrics
|
||||
chatwoot_edition
|
||||
instance_meta
|
||||
baileys_api_version
|
||||
end
|
||||
|
||||
def chatwoot_edition
|
||||
@ -56,4 +57,10 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
||||
rescue Redis::CannotConnectError
|
||||
@metrics['Redis alive'] = false
|
||||
end
|
||||
|
||||
def baileys_api_version
|
||||
@metrics['Baileys API version'] = Whatsapp::Providers::WhatsappBaileysService.status[:packageInfo][:version]
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
@metrics['Baileys API version'] = e.message
|
||||
end
|
||||
end
|
||||
|
||||
@ -8,11 +8,26 @@ class Webhooks::WhatsappController < ActionController::API
|
||||
return
|
||||
end
|
||||
|
||||
perform_whatsapp_events_job
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def perform_whatsapp_events_job
|
||||
perform_sync if params[:awaitResponse].present?
|
||||
return if performed?
|
||||
|
||||
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
def perform_sync
|
||||
Webhooks::WhatsappEventsJob.perform_now(params.to_unsafe_hash)
|
||||
rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken
|
||||
head :unauthorized
|
||||
rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def valid_token?(token)
|
||||
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
||||
|
||||
@ -18,7 +18,8 @@ class AsyncDispatcher < BaseDispatcher
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
ReportingEventListener.instance,
|
||||
WebhookListener.instance
|
||||
WebhookListener.instance,
|
||||
ChannelListener.instance
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@ -24,8 +24,33 @@ class ConversationDrop < BaseDrop
|
||||
custom_attributes.transform_keys(&:to_s)
|
||||
end
|
||||
|
||||
def first_reply_created_at
|
||||
format_datetime(@obj.try(:first_reply_created_at))
|
||||
end
|
||||
|
||||
def first_reply_created_at_time
|
||||
format_datetime(@obj.try(:first_reply_created_at), include_time: true)
|
||||
end
|
||||
|
||||
def last_activity_at
|
||||
format_datetime(@obj.try(:last_activity_at))
|
||||
end
|
||||
|
||||
def last_activity_at_time
|
||||
format_datetime(@obj.try(:last_activity_at), include_time: true)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_datetime(datetime, include_time: false)
|
||||
return '' if datetime.blank?
|
||||
|
||||
locale = @obj.try(:account)&.locale || 'en'
|
||||
date_format = locale == 'pt_BR' ? '%d/%m/%Y' : '%b %d, %Y'
|
||||
date_format += ' %H:%M' if include_time
|
||||
datetime.strftime(date_format)
|
||||
end
|
||||
|
||||
def message_sender_name(sender)
|
||||
return 'Bot' if sender.blank?
|
||||
return contact_name if sender.instance_of?(Contact)
|
||||
|
||||
@ -80,6 +80,7 @@ class ConversationFinder
|
||||
|
||||
find_all_conversations
|
||||
filter_by_status unless params[:q]
|
||||
filter_by_group_type
|
||||
filter_by_team
|
||||
filter_by_labels
|
||||
filter_by_query
|
||||
@ -134,6 +135,12 @@ class ConversationFinder
|
||||
@conversations
|
||||
end
|
||||
|
||||
def filter_by_group_type
|
||||
return unless params[:group_type].present? && params[:group_type] != 'all'
|
||||
|
||||
@conversations = @conversations.where(group_type: params[:group_type])
|
||||
end
|
||||
|
||||
def filter_by_conversation_type
|
||||
case @params[:conversation_type]
|
||||
when 'mention'
|
||||
|
||||
52
app/helpers/baileys_helper.rb
Normal file
52
app/helpers/baileys_helper.rb
Normal file
@ -0,0 +1,52 @@
|
||||
module BaileysHelper
|
||||
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY = 'BAILEYS::CHANNEL_LOCK_ON_OUTGOING_MESSAGE::%<channel_id>s'.freeze
|
||||
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT = 60.seconds
|
||||
|
||||
def baileys_extract_message_timestamp(timestamp)
|
||||
# NOTE: Timestamp might be in this format {"low"=>1748003165, "high"=>0, "unsigned"=>true}
|
||||
if timestamp.is_a?(Hash) && timestamp.key?('low')
|
||||
low = timestamp['low'].to_i
|
||||
high = timestamp.fetch('high', 0).to_i
|
||||
return (high << 32) | low
|
||||
end
|
||||
|
||||
# NOTE: Timestamp might be a string or a number
|
||||
timestamp.to_i
|
||||
end
|
||||
|
||||
def with_baileys_channel_lock_on_outgoing_message(channel_id, timeout: CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT)
|
||||
raise ArgumentError, 'A block is required for with_baileys_channel_lock_on_outgoing_message' unless block_given?
|
||||
|
||||
start_time = Time.now.to_i
|
||||
lock_acquired = false
|
||||
|
||||
# NOTE: On timeout, we log a warning and proceed with the block execution.
|
||||
# The re-check inside the contact lock handles potential duplicates.
|
||||
while (Time.now.to_i - start_time) < timeout
|
||||
if baileys_lock_channel_on_outgoing_message(channel_id, timeout)
|
||||
lock_acquired = true
|
||||
break
|
||||
end
|
||||
|
||||
sleep(0.1)
|
||||
end
|
||||
|
||||
Rails.logger.warn "Baileys channel lock timeout for channel #{channel_id} after #{timeout}s - proceeding anyway" unless lock_acquired
|
||||
|
||||
yield
|
||||
ensure
|
||||
baileys_clear_channel_lock_on_outgoing_message(channel_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def baileys_lock_channel_on_outgoing_message(channel_id, timeout)
|
||||
key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id)
|
||||
Redis::Alfred.set(key, 1, nx: true, ex: timeout)
|
||||
end
|
||||
|
||||
def baileys_clear_channel_lock_on_outgoing_message(channel_id)
|
||||
key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id)
|
||||
Redis::Alfred.delete(key)
|
||||
end
|
||||
end
|
||||
@ -10,6 +10,6 @@ module CacheKeysHelper
|
||||
return value_from_cache if value_from_cache.present?
|
||||
|
||||
# zero epoch time: 1970-01-01 00:00:00 UTC
|
||||
'0000000000'
|
||||
'0000000000000'
|
||||
end
|
||||
end
|
||||
|
||||
@ -100,6 +100,10 @@ module Filters::FilterHelper
|
||||
values.map { |x| Conversation.priorities[x.to_sym] }
|
||||
end
|
||||
|
||||
def conversation_group_type_values(values)
|
||||
values.map { |x| Conversation.group_types[x.to_sym] }
|
||||
end
|
||||
|
||||
def message_type_values(values)
|
||||
values.map { |x| Message.message_types[x.to_sym] }
|
||||
end
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
module FrontendUrlsHelper
|
||||
def frontend_url(path, **query_params)
|
||||
url_params = query_params.blank? ? '' : "?#{query_params.to_query}"
|
||||
"#{root_url}app/#{path}#{url_params}"
|
||||
host = ENV.fetch('FRONTEND_URL', root_url)
|
||||
host = "#{host}/" unless host.end_with?('/')
|
||||
"#{host}app/#{path}#{url_params}"
|
||||
end
|
||||
end
|
||||
|
||||
@ -65,8 +65,8 @@ module ReportingEventHelper
|
||||
end
|
||||
|
||||
def format_time(hour, minute)
|
||||
hour = hour < 10 ? "0#{hour}" : hour
|
||||
minute = minute < 10 ? "0#{minute}" : minute
|
||||
hour = "0#{hour}" if hour < 10
|
||||
minute = "0#{minute}" if minute < 10
|
||||
"#{hour}:#{minute}"
|
||||
end
|
||||
end
|
||||
|
||||
@ -14,6 +14,6 @@ module TimezoneHelper
|
||||
zone.now.utc_offset == offset_in_seconds
|
||||
end
|
||||
|
||||
return matching_zone.name if matching_zone
|
||||
matching_zone&.name
|
||||
end
|
||||
end
|
||||
|
||||
71
app/javascript/dashboard/api/groupMembers.js
Normal file
71
app/javascript/dashboard/api/groupMembers.js
Normal file
@ -0,0 +1,71 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class GroupMembersAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('contacts', { accountScoped: true });
|
||||
}
|
||||
|
||||
getGroupMembers(contactId, page = 1) {
|
||||
return axios.get(`${this.url}/${contactId}/group_members`, {
|
||||
params: { page },
|
||||
});
|
||||
}
|
||||
|
||||
syncGroup(contactId) {
|
||||
return axios.post(`${this.url}/${contactId}/sync_group`);
|
||||
}
|
||||
|
||||
createGroup(params) {
|
||||
return axios.post(`${this.baseUrl()}/groups`, params);
|
||||
}
|
||||
|
||||
updateGroupMetadata(contactId, params) {
|
||||
return axios.patch(`${this.url}/${contactId}/group_metadata`, params);
|
||||
}
|
||||
|
||||
addMembers(contactId, participants) {
|
||||
return axios.post(`${this.url}/${contactId}/group_members`, {
|
||||
participants,
|
||||
});
|
||||
}
|
||||
|
||||
removeMembers(contactId, memberId) {
|
||||
return axios.delete(`${this.url}/${contactId}/group_members/${memberId}`);
|
||||
}
|
||||
|
||||
updateMemberRole(contactId, memberId, role) {
|
||||
return axios.patch(`${this.url}/${contactId}/group_members/${memberId}`, {
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
getInviteLink(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/group_invite`);
|
||||
}
|
||||
|
||||
revokeInviteLink(contactId) {
|
||||
return axios.post(`${this.url}/${contactId}/group_invite/revoke`);
|
||||
}
|
||||
|
||||
getPendingRequests(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/group_join_requests`);
|
||||
}
|
||||
|
||||
handleJoinRequest(contactId, params) {
|
||||
return axios.post(
|
||||
`${this.url}/${contactId}/group_join_requests/handle`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
leaveGroup(contactId) {
|
||||
return axios.post(`${this.url}/${contactId}/group_admin/leave`);
|
||||
}
|
||||
|
||||
updateGroupProperty(contactId, params) {
|
||||
return axios.patch(`${this.url}/${contactId}/group_admin`, params);
|
||||
}
|
||||
}
|
||||
|
||||
export default new GroupMembersAPI();
|
||||
@ -16,6 +16,7 @@ class ConversationApi extends ApiClient {
|
||||
conversationType,
|
||||
sortBy,
|
||||
updatedWithin,
|
||||
groupType,
|
||||
}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
@ -28,6 +29,7 @@ class ConversationApi extends ApiClient {
|
||||
conversation_type: conversationType,
|
||||
sort_by: sortBy,
|
||||
updated_within: updatedWithin,
|
||||
group_type: groupType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ export const buildCreatePayload = ({
|
||||
contentAttributes,
|
||||
echoId,
|
||||
files,
|
||||
isRecordedAudio,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
@ -22,6 +23,13 @@ export const buildCreatePayload = ({
|
||||
files.forEach(file => {
|
||||
payload.append('attachments[]', file);
|
||||
});
|
||||
if (isRecordedAudio === true) {
|
||||
payload.append('is_recorded_audio', true);
|
||||
} else if (Array.isArray(isRecordedAudio)) {
|
||||
isRecordedAudio.forEach(filename => {
|
||||
payload.append('is_recorded_audio[]', filename);
|
||||
});
|
||||
}
|
||||
payload.append('private', isPrivate);
|
||||
payload.append('echo_id', echoId);
|
||||
payload.append('cc_emails', ccEmails);
|
||||
@ -60,6 +68,7 @@ class MessageApi extends ApiClient {
|
||||
contentAttributes,
|
||||
echo_id: echoId,
|
||||
files,
|
||||
isRecordedAudio,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
@ -74,6 +83,7 @@ class MessageApi extends ApiClient {
|
||||
contentAttributes,
|
||||
echoId,
|
||||
files,
|
||||
isRecordedAudio,
|
||||
ccEmails,
|
||||
bccEmails,
|
||||
toEmails,
|
||||
@ -86,6 +96,13 @@ class MessageApi extends ApiClient {
|
||||
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
|
||||
}
|
||||
|
||||
editContent(conversationID, messageId, content) {
|
||||
return axios.patch(
|
||||
`${this.url}/${conversationID}/messages/${messageId}/edit_content`,
|
||||
{ content }
|
||||
);
|
||||
}
|
||||
|
||||
retry(conversationID, messageId) {
|
||||
return axios.post(
|
||||
`${this.url}/${conversationID}/messages/${messageId}/retry`
|
||||
|
||||
25
app/javascript/dashboard/api/inboxSignatures.js
Normal file
25
app/javascript/dashboard/api/inboxSignatures.js
Normal file
@ -0,0 +1,25 @@
|
||||
/* global axios */
|
||||
|
||||
const API_BASE = '/api/v1/profile/inbox_signatures';
|
||||
|
||||
export default {
|
||||
getAll(accountId) {
|
||||
return axios.get(API_BASE, {
|
||||
params: { account_id: accountId },
|
||||
});
|
||||
},
|
||||
|
||||
get(inboxId) {
|
||||
return axios.get(`${API_BASE}/${inboxId}`);
|
||||
},
|
||||
|
||||
upsert(inboxId, params) {
|
||||
return axios.put(`${API_BASE}/${inboxId}`, {
|
||||
inbox_signature: params,
|
||||
});
|
||||
},
|
||||
|
||||
delete(inboxId) {
|
||||
return axios.delete(`${API_BASE}/${inboxId}`);
|
||||
},
|
||||
};
|
||||
@ -48,6 +48,26 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
template,
|
||||
});
|
||||
}
|
||||
|
||||
linkCSATTemplate(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/csat_template/link`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
|
||||
getAvailableCSATTemplates(inboxId) {
|
||||
return axios.get(
|
||||
`${this.url}/${inboxId}/csat_template/available_templates`
|
||||
);
|
||||
}
|
||||
|
||||
setupChannelProvider(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/setup_channel_provider`);
|
||||
}
|
||||
|
||||
disconnectChannelProvider(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/disconnect_channel_provider`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
81
app/javascript/dashboard/api/recurringScheduledMessages.js
Normal file
81
app/javascript/dashboard/api/recurringScheduledMessages.js
Normal file
@ -0,0 +1,81 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
export const buildRecurringScheduledMessagePayload = ({
|
||||
content,
|
||||
status,
|
||||
scheduledAt,
|
||||
templateParams,
|
||||
attachment,
|
||||
removeAttachment,
|
||||
recurrenceRule,
|
||||
} = {}) => {
|
||||
if (!attachment) {
|
||||
return {
|
||||
content,
|
||||
status,
|
||||
scheduled_at: scheduledAt,
|
||||
template_params: templateParams,
|
||||
remove_attachment: removeAttachment || undefined,
|
||||
recurrence_rule: recurrenceRule,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = new FormData();
|
||||
if (content) payload.append('content', content);
|
||||
if (scheduledAt) payload.append('scheduled_at', scheduledAt);
|
||||
if (status) payload.append('status', status);
|
||||
payload.append('attachment', attachment);
|
||||
if (templateParams) {
|
||||
payload.append('template_params', JSON.stringify(templateParams));
|
||||
}
|
||||
if (recurrenceRule) {
|
||||
Object.entries(recurrenceRule).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v =>
|
||||
payload.append(`recurrence_rule[${key}][]`, String(v))
|
||||
);
|
||||
} else {
|
||||
payload.append(`recurrence_rule[${key}]`, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
class RecurringScheduledMessagesAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('conversations', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(conversationId) {
|
||||
return axios.get(
|
||||
`${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages`
|
||||
);
|
||||
}
|
||||
|
||||
create(conversationId, payload) {
|
||||
return axios({
|
||||
method: 'post',
|
||||
url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages`,
|
||||
data: buildRecurringScheduledMessagePayload(payload),
|
||||
});
|
||||
}
|
||||
|
||||
update(conversationId, recurringScheduledMessageId, payload) {
|
||||
return axios({
|
||||
method: 'patch',
|
||||
url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}`,
|
||||
data: buildRecurringScheduledMessagePayload(payload),
|
||||
});
|
||||
}
|
||||
|
||||
delete(conversationId, recurringScheduledMessageId) {
|
||||
return axios.delete(
|
||||
`${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new RecurringScheduledMessagesAPI();
|
||||
68
app/javascript/dashboard/api/scheduledMessages.js
Normal file
68
app/javascript/dashboard/api/scheduledMessages.js
Normal file
@ -0,0 +1,68 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
export const buildScheduledMessagePayload = ({
|
||||
content,
|
||||
status,
|
||||
scheduledAt,
|
||||
templateParams,
|
||||
attachment,
|
||||
removeAttachment,
|
||||
} = {}) => {
|
||||
if (!attachment) {
|
||||
return {
|
||||
content,
|
||||
status,
|
||||
scheduled_at: scheduledAt,
|
||||
template_params: templateParams,
|
||||
remove_attachment: removeAttachment || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = new FormData();
|
||||
if (content) payload.append('content', content);
|
||||
if (scheduledAt) payload.append('scheduled_at', scheduledAt);
|
||||
if (status) payload.append('status', status);
|
||||
payload.append('attachment', attachment);
|
||||
if (templateParams) {
|
||||
payload.append('template_params', JSON.stringify(templateParams));
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
class ScheduledMessagesAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('conversations', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(conversationId) {
|
||||
return axios.get(
|
||||
`${this.baseUrl()}/conversations/${conversationId}/scheduled_messages`
|
||||
);
|
||||
}
|
||||
|
||||
create(conversationId, payload) {
|
||||
return axios({
|
||||
method: 'post',
|
||||
url: `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages`,
|
||||
data: buildScheduledMessagePayload(payload),
|
||||
});
|
||||
}
|
||||
|
||||
update(conversationId, scheduledMessageId, payload) {
|
||||
return axios({
|
||||
method: 'patch',
|
||||
url: `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages/${scheduledMessageId}`,
|
||||
data: buildScheduledMessagePayload(payload),
|
||||
});
|
||||
}
|
||||
|
||||
delete(conversationId, scheduledMessageId) {
|
||||
return axios.delete(
|
||||
`${this.baseUrl()}/conversations/${conversationId}/scheduled_messages/${scheduledMessageId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ScheduledMessagesAPI();
|
||||
77
app/javascript/dashboard/api/specs/scheduledMessages.spec.js
Normal file
77
app/javascript/dashboard/api/specs/scheduledMessages.spec.js
Normal file
@ -0,0 +1,77 @@
|
||||
import ScheduledMessagesAPI, {
|
||||
buildScheduledMessagePayload,
|
||||
} from '../scheduledMessages';
|
||||
|
||||
describe('#ScheduledMessagesAPI', () => {
|
||||
describe('#buildScheduledMessagePayload', () => {
|
||||
it('builds object payload without attachment or FormData with attachment', () => {
|
||||
const objectPayload = buildScheduledMessagePayload({
|
||||
content: 'Hello',
|
||||
scheduledAt: '2025-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
expect(objectPayload).toEqual({
|
||||
content: 'Hello',
|
||||
scheduled_at: '2025-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
private: undefined,
|
||||
template_params: undefined,
|
||||
content_attributes: undefined,
|
||||
additional_attributes: undefined,
|
||||
});
|
||||
|
||||
const formPayload = buildScheduledMessagePayload({
|
||||
content: 'Hello',
|
||||
attachment: new Blob(['test'], { type: 'text/plain' }),
|
||||
});
|
||||
|
||||
expect(formPayload).toBeInstanceOf(FormData);
|
||||
expect(formPayload.get('content')).toEqual('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const originalPathname = window.location.pathname;
|
||||
const axiosMock = Object.assign(
|
||||
vi.fn(() => Promise.resolve()),
|
||||
{ delete: vi.fn(() => Promise.resolve()) }
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.mockClear();
|
||||
axiosMock.delete.mockClear();
|
||||
window.axios = axiosMock;
|
||||
window.history.pushState({}, '', '/app/accounts/1/inbox');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
window.history.pushState({}, '', originalPathname);
|
||||
});
|
||||
|
||||
it('calls correct endpoints for create, update, and delete', () => {
|
||||
ScheduledMessagesAPI.create(12, { content: 'Hello' });
|
||||
expect(axiosMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'post',
|
||||
url: '/api/v1/accounts/1/conversations/12/scheduled_messages',
|
||||
})
|
||||
);
|
||||
|
||||
ScheduledMessagesAPI.update(12, 7, { status: 'pending' });
|
||||
expect(axiosMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'patch',
|
||||
url: '/api/v1/accounts/1/conversations/12/scheduled_messages/7',
|
||||
})
|
||||
);
|
||||
|
||||
ScheduledMessagesAPI.delete(12, 7);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/conversations/12/scheduled_messages/7'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
9
app/javascript/dashboard/assets/images/curved-arrow.svg
Normal file
9
app/javascript/dashboard/assets/images/curved-arrow.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg fill="#2781F6"
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 302.816 302.816">
|
||||
<path d="M298.423,152.996c-5.857-5.858-15.354-5.858-21.213,0l-35.137,35.136
|
||||
c-5.871-59.78-50.15-111.403-112.001-123.706c-45.526-9.055-92.479,5.005-125.596,37.612c-5.903,5.813-5.977,15.31-0.165,21.213
|
||||
c5.813,5.903,15.31,5.977,21.212,0.164c26.029-25.628,62.923-36.679,98.695-29.565c48.865,9.72,83.772,50.677,88.07,97.978
|
||||
l-38.835-38.835c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l62.485,62.485
|
||||
c2.929,2.929,6.768,4.393,10.606,4.393s7.678-1.464,10.607-4.393l62.483-62.482C304.281,168.352,304.281,158.854,298.423,152.996z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |
@ -0,0 +1,526 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { fromUnixTime } from 'date-fns';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { buildRecurrenceDescription } from 'dashboard/helper/recurrenceHelpers';
|
||||
|
||||
const props = defineProps({
|
||||
scheduledMessage: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
writtenBy: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
allowEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowDelete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'stop']);
|
||||
const noteContentRef = useTemplateRef('noteContentRef');
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const showToggle = ref(false);
|
||||
const showHistory = ref(false);
|
||||
const showStopConfirm = ref(false);
|
||||
const { t, locale } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const normalizedLocale = computed(() => locale.value.replace('_', '-'));
|
||||
|
||||
const isRecurring = computed(() =>
|
||||
Boolean(props.scheduledMessage?.recurrence_rule)
|
||||
);
|
||||
|
||||
const statusConfig = {
|
||||
draft: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.DRAFT',
|
||||
class: 'bg-n-slate-9/10 text-n-slate-12',
|
||||
},
|
||||
pending: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.PENDING',
|
||||
class: 'bg-n-brand/10 text-n-blue-text',
|
||||
},
|
||||
sent: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.SENT',
|
||||
class: 'bg-n-teal-9/10 text-n-teal-11',
|
||||
},
|
||||
failed: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.FAILED',
|
||||
class: 'bg-n-ruby-9/10 text-n-ruby-11',
|
||||
},
|
||||
active: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_ACTIVE',
|
||||
class: 'bg-n-brand/10 text-n-blue-text',
|
||||
},
|
||||
completed: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_COMPLETED',
|
||||
class: 'bg-n-slate-3 text-n-slate-11',
|
||||
},
|
||||
cancelled: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_CANCELLED',
|
||||
class: 'bg-n-ruby-3 text-n-ruby-11',
|
||||
},
|
||||
};
|
||||
|
||||
const author = computed(() => props.scheduledMessage?.author || null);
|
||||
const authorType = computed(() => props.scheduledMessage?.author_type);
|
||||
const isUserAuthor = computed(
|
||||
() => authorType.value === 'User' && Boolean(author.value?.id)
|
||||
);
|
||||
const avatarSrc = computed(() => {
|
||||
if (isUserAuthor.value) {
|
||||
return author.value?.thumbnail || '';
|
||||
}
|
||||
return '/assets/images/chatwoot_bot.png';
|
||||
});
|
||||
const avatarName = computed(() => {
|
||||
if (isUserAuthor.value) {
|
||||
return author.value?.name || t('CONVERSATION.BOT');
|
||||
}
|
||||
return t('CONVERSATION.BOT');
|
||||
});
|
||||
const status = computed(() => props.scheduledMessage?.status || 'draft');
|
||||
const statusBadge = computed(() => {
|
||||
const config = statusConfig[status.value] || statusConfig.draft;
|
||||
return {
|
||||
class: config.class,
|
||||
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
|
||||
label: t(config.labelKey),
|
||||
};
|
||||
});
|
||||
|
||||
const recurrenceDescription = computed(() => {
|
||||
if (!isRecurring.value) return '';
|
||||
return buildRecurrenceDescription(
|
||||
props.scheduledMessage.recurrence_rule,
|
||||
t,
|
||||
normalizedLocale.value
|
||||
);
|
||||
});
|
||||
|
||||
const scheduledAt = computed(() => {
|
||||
if (isRecurring.value) {
|
||||
const pending =
|
||||
props.scheduledMessage.pending_scheduled_message ||
|
||||
props.scheduledMessage.scheduled_messages?.find(
|
||||
sm => sm.status === 'pending'
|
||||
);
|
||||
return pending?.scheduled_at || null;
|
||||
}
|
||||
return props.scheduledMessage?.scheduled_at;
|
||||
});
|
||||
|
||||
const formattedScheduledTime = computed(() => {
|
||||
if (!scheduledAt.value) return '';
|
||||
const date = fromUnixTime(scheduledAt.value);
|
||||
const now = new Date();
|
||||
|
||||
const options = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
if (date.getFullYear() !== now.getFullYear()) {
|
||||
options.year = 'numeric';
|
||||
}
|
||||
|
||||
return date.toLocaleString(normalizedLocale.value, options);
|
||||
});
|
||||
|
||||
const templateName = computed(() => {
|
||||
const templateParams = props.scheduledMessage?.template_params || {};
|
||||
return templateParams.name || templateParams.id;
|
||||
});
|
||||
|
||||
const hasTemplate = computed(() => Boolean(templateName.value));
|
||||
|
||||
const attachment = computed(() => props.scheduledMessage?.attachment);
|
||||
const attachmentName = computed(() => attachment.value?.filename);
|
||||
const attachmentUrl = computed(() => attachment.value?.file_url);
|
||||
const shouldShowAttachmentLine = computed(() => Boolean(attachmentName.value));
|
||||
|
||||
const previewContent = computed(() => {
|
||||
if (props.scheduledMessage?.content) {
|
||||
return props.scheduledMessage.content;
|
||||
}
|
||||
if (templateName.value) {
|
||||
return t('SCHEDULED_MESSAGES.ITEM.TEMPLATE_PREVIEW', {
|
||||
name: templateName.value,
|
||||
});
|
||||
}
|
||||
if (attachmentName.value) {
|
||||
return '';
|
||||
}
|
||||
return t('SCHEDULED_MESSAGES.ITEM.EMPTY_PREVIEW');
|
||||
});
|
||||
|
||||
const hasPreviewContent = computed(() => Boolean(previewContent.value));
|
||||
|
||||
const formattedContent = computed(() => formatMessage(previewContent.value));
|
||||
|
||||
// Recurring: completed children history
|
||||
const completedChildren = computed(() => {
|
||||
if (!isRecurring.value) return [];
|
||||
const children = props.scheduledMessage.scheduled_messages || [];
|
||||
return children
|
||||
.filter(m => ['sent', 'failed'].includes(m.status))
|
||||
.sort((a, b) => (b.scheduled_at || 0) - (a.scheduled_at || 0));
|
||||
});
|
||||
|
||||
const hasCompletedChildren = computed(() => completedChildren.value.length > 0);
|
||||
|
||||
const formatChildTime = childScheduledAt => {
|
||||
if (!childScheduledAt) return '';
|
||||
const date = new Date(childScheduledAt * 1000);
|
||||
const now = new Date();
|
||||
const options = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
if (date.getFullYear() !== now.getFullYear()) {
|
||||
options.year = 'numeric';
|
||||
}
|
||||
return date.toLocaleString(normalizedLocale.value, options);
|
||||
};
|
||||
|
||||
const canNavigateToChild = child =>
|
||||
child.status === 'sent' && Boolean(child.message_id);
|
||||
|
||||
const scrollToChildMessage = child => {
|
||||
if (!canNavigateToChild(child)) return;
|
||||
router.replace({
|
||||
...route,
|
||||
query: { ...route.query, messageId: child.message_id },
|
||||
});
|
||||
};
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (!props.collapsible) {
|
||||
showToggle.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = noteContentRef.value;
|
||||
if (el && !isExpanded.value) {
|
||||
showToggle.value = el.scrollHeight > el.clientHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const onEdit = () => emit('edit', props.scheduledMessage);
|
||||
const onDelete = () => {
|
||||
if (isRecurring.value) {
|
||||
showStopConfirm.value = true;
|
||||
} else {
|
||||
emit('delete', props.scheduledMessage);
|
||||
}
|
||||
};
|
||||
const confirmStop = () => {
|
||||
emit('stop', props.scheduledMessage);
|
||||
showStopConfirm.value = false;
|
||||
};
|
||||
|
||||
const canScrollToMessage = computed(
|
||||
() =>
|
||||
!isRecurring.value &&
|
||||
props.scheduledMessage?.status === 'sent' &&
|
||||
Boolean(props.scheduledMessage?.message_id)
|
||||
);
|
||||
|
||||
const scrollToMessage = () => {
|
||||
if (!canScrollToMessage.value) return;
|
||||
const messageId = props.scheduledMessage.message_id;
|
||||
router.replace({
|
||||
...route,
|
||||
query: { ...route.query, messageId },
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkOverflow();
|
||||
});
|
||||
|
||||
watch(previewContent, () => {
|
||||
nextTick(checkOverflow);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 border-b border-n-strong py-3 group/scheduled rounded-md transition-colors"
|
||||
:class="{
|
||||
'cursor-pointer hover:bg-n-alpha-1': canScrollToMessage,
|
||||
}"
|
||||
:title="
|
||||
canScrollToMessage
|
||||
? t('SCHEDULED_MESSAGES.ITEM.GO_TO_MESSAGE')
|
||||
: undefined
|
||||
"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<!-- Recurrence description header -->
|
||||
<div
|
||||
v-if="isRecurring"
|
||||
class="flex items-center gap-1.5 text-xs text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-repeat" class="size-3 shrink-0" />
|
||||
<span class="truncate">{{ recurrenceDescription }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar
|
||||
:name="avatarName"
|
||||
:src="avatarSrc"
|
||||
:size="30"
|
||||
rounded-full
|
||||
class="shrink-0"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm font-medium text-n-slate-12 mb-0.5 line-clamp-1"
|
||||
:title="writtenBy"
|
||||
>
|
||||
{{ writtenBy }}
|
||||
</p>
|
||||
<p
|
||||
v-if="formattedScheduledTime"
|
||||
class="flex items-center gap-1 text-xs text-n-slate-11 mb-0"
|
||||
>
|
||||
<Icon icon="i-lucide-alarm-clock" class="size-3 shrink-0" />
|
||||
{{
|
||||
isRecurring
|
||||
? t('SCHEDULED_MESSAGES.RECURRENCE.NEXT_SEND', {
|
||||
time: formattedScheduledTime,
|
||||
})
|
||||
: formattedScheduledTime
|
||||
}}
|
||||
</p>
|
||||
<p v-else class="text-xs text-n-slate-11 mb-0">
|
||||
{{ t('SCHEDULED_MESSAGES.ITEM.NO_SCHEDULE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-2 shrink-0">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="statusBadge.class"
|
||||
>
|
||||
{{ statusBadge.label }}
|
||||
</span>
|
||||
<div
|
||||
v-if="allowEdit || allowDelete"
|
||||
class="flex items-center gap-1 opacity-0 group-hover/scheduled:opacity-100"
|
||||
>
|
||||
<Button
|
||||
v-if="allowEdit"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="xs"
|
||||
icon="i-lucide-pencil"
|
||||
@click.stop="onEdit"
|
||||
/>
|
||||
<Button
|
||||
v-if="allowDelete"
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="xs"
|
||||
:icon="isRecurring ? 'i-lucide-square' : 'i-lucide-trash'"
|
||||
@click.stop="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="hasPreviewContent"
|
||||
ref="noteContentRef"
|
||||
v-dompurify-html="formattedContent"
|
||||
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
:class="{
|
||||
'line-clamp-4': collapsible && !isExpanded,
|
||||
}"
|
||||
/>
|
||||
|
||||
<div v-if="hasPreviewContent && collapsible && showToggle">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="blue"
|
||||
size="xs"
|
||||
:icon="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
@click="() => toggleExpanded()"
|
||||
>
|
||||
<template v-if="isExpanded">
|
||||
{{ t('SCHEDULED_MESSAGES.ITEM.COLLAPSE') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('SCHEDULED_MESSAGES.ITEM.EXPAND') }}
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasTemplate"
|
||||
class="flex items-center gap-1.5 text-xs text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-zap" class="size-3 shrink-0" />
|
||||
<span class="truncate">
|
||||
{{
|
||||
t('SCHEDULED_MESSAGES.ITEM.TEMPLATE_LABEL', {
|
||||
name: templateName,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="shouldShowAttachmentLine"
|
||||
class="flex items-center gap-1.5 text-xs text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-paperclip" class="size-3 shrink-0" />
|
||||
<a
|
||||
v-if="attachmentUrl"
|
||||
:href="attachmentUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate hover:underline"
|
||||
>
|
||||
{{
|
||||
t('SCHEDULED_MESSAGES.ITEM.ATTACHMENT_LABEL', {
|
||||
filename: attachmentName,
|
||||
})
|
||||
}}
|
||||
</a>
|
||||
<span v-else class="truncate">
|
||||
{{
|
||||
t('SCHEDULED_MESSAGES.ITEM.ATTACHMENT_LABEL', {
|
||||
filename: attachmentName,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recurring: sent/failed history toggle -->
|
||||
<div v-if="isRecurring && hasCompletedChildren" class="text-xs">
|
||||
<button
|
||||
class="flex items-center gap-1 text-n-slate-10 hover:text-n-slate-12 cursor-pointer transition-colors"
|
||||
@click.stop="showHistory = !showHistory"
|
||||
>
|
||||
<Icon
|
||||
:icon="showHistory ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="size-3"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
t('SCHEDULED_MESSAGES.RECURRENCE.OCCURRENCES_SENT', {
|
||||
count: completedChildren.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recurring: expanded history list -->
|
||||
<div
|
||||
v-if="isRecurring && showHistory && hasCompletedChildren"
|
||||
class="flex flex-col gap-1 border-t border-n-weak pt-2"
|
||||
>
|
||||
<div
|
||||
v-for="child in completedChildren"
|
||||
:key="child.id"
|
||||
class="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-xs transition-colors"
|
||||
:class="{
|
||||
'cursor-pointer hover:bg-n-alpha-2': canNavigateToChild(child),
|
||||
}"
|
||||
@click.stop="scrollToChildMessage(child)"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Icon
|
||||
:icon="
|
||||
child.status === 'sent'
|
||||
? 'i-lucide-check-circle'
|
||||
: 'i-lucide-x-circle'
|
||||
"
|
||||
class="size-3 shrink-0"
|
||||
:class="
|
||||
child.status === 'sent' ? 'text-n-teal-11' : 'text-n-ruby-11'
|
||||
"
|
||||
/>
|
||||
<span class="text-n-slate-11">
|
||||
{{ formatChildTime(child.scheduled_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0"
|
||||
:class="
|
||||
child.status === 'sent'
|
||||
? 'bg-n-teal-9/10 text-n-teal-11'
|
||||
: 'bg-n-ruby-9/10 text-n-ruby-11'
|
||||
"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
child.status === 'sent'
|
||||
? 'SCHEDULED_MESSAGES.STATUS.SENT'
|
||||
: 'SCHEDULED_MESSAGES.STATUS.FAILED'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stop recurrence confirmation modal -->
|
||||
<woot-modal
|
||||
v-if="isRecurring"
|
||||
v-model:show="showStopConfirm"
|
||||
size="small"
|
||||
@close="() => (showStopConfirm = false)"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-4 px-6 py-6">
|
||||
<h3 class="text-lg font-semibold text-n-slate-12">
|
||||
{{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.TITLE') }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.MESSAGE') }}
|
||||
</p>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.CANCEL')"
|
||||
@click="showStopConfirm = false"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="ruby"
|
||||
:label="t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.CONFIRM')"
|
||||
@click="confirmStop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
@ -7,7 +7,13 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, helpers, url } from '@vuelidate/validators';
|
||||
import {
|
||||
required,
|
||||
minLength,
|
||||
maxLength,
|
||||
helpers,
|
||||
url,
|
||||
} from '@vuelidate/validators';
|
||||
import { isValidSlug } from 'shared/helpers/Validators';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@ -15,6 +21,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activePortal: {
|
||||
@ -34,6 +41,7 @@ const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const CUSTOM_HTML_MAX_LENGTH = 15_000;
|
||||
|
||||
const state = reactive({
|
||||
name: '',
|
||||
@ -45,6 +53,9 @@ const state = reactive({
|
||||
liveChatWidgetInboxId: '',
|
||||
logoUrl: '',
|
||||
avatarBlobId: '',
|
||||
showAuthor: true,
|
||||
customHeadHtml: '',
|
||||
customBodyHtml: '',
|
||||
});
|
||||
|
||||
const originalState = reactive({ ...state });
|
||||
@ -80,6 +91,8 @@ const rules = {
|
||||
),
|
||||
},
|
||||
homePageLink: { url },
|
||||
customHeadHtml: { maxLength: maxLength(CUSTOM_HTML_MAX_LENGTH) },
|
||||
customBodyHtml: { maxLength: maxLength(CUSTOM_HTML_MAX_LENGTH) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
@ -98,6 +111,18 @@ const homePageLinkError = computed(() =>
|
||||
: ''
|
||||
);
|
||||
|
||||
const customHeadHtmlError = computed(() =>
|
||||
v$.value.customHeadHtml.$error
|
||||
? t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const customBodyHtmlError = computed(() =>
|
||||
v$.value.customBodyHtml.$error
|
||||
? t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const isUpdatingPortal = computed(() => {
|
||||
const slug = props.activePortal?.slug;
|
||||
if (slug) return getters['portals/uiFlagsIn'].value(slug)?.isUpdating;
|
||||
@ -117,6 +142,9 @@ watch(
|
||||
homePageLink: newVal.homepage_link,
|
||||
slug: newVal.slug,
|
||||
liveChatWidgetInboxId: newVal.inbox?.id || '',
|
||||
showAuthor: newVal.config?.show_author !== false,
|
||||
customHeadHtml: newVal.custom_head_html || '',
|
||||
customBodyHtml: newVal.custom_body_html || '',
|
||||
});
|
||||
if (newVal.logo) {
|
||||
const {
|
||||
@ -149,6 +177,9 @@ const handleUpdatePortal = () => {
|
||||
homepage_link: state.homePageLink,
|
||||
blob_id: state.avatarBlobId,
|
||||
inbox_id: state.liveChatWidgetInboxId,
|
||||
config: { show_author: state.showAuthor },
|
||||
custom_head_html: state.customHeadHtml,
|
||||
custom_body_html: state.customBodyHtml,
|
||||
};
|
||||
emit('updatePortal', portal);
|
||||
};
|
||||
@ -335,6 +366,89 @@ const handleAvatarDelete = () => {
|
||||
<ColorPicker v-model="state.widgetColor" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SHOW_AUTHOR.LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-1 py-2.5">
|
||||
<Switch v-model="state.showAuthor" />
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SHOW_AUTHOR.HELP_TEXT') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-1">
|
||||
<textarea
|
||||
v-model="state.customHeadHtml"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.PLACEHOLDER')
|
||||
"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 text-sm border rounded-lg resize-y bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-1 font-mono"
|
||||
:class="
|
||||
customHeadHtmlError
|
||||
? 'border-n-ruby-9 focus:ring-n-ruby-9'
|
||||
: 'border-n-weak focus:ring-n-brand'
|
||||
"
|
||||
@input="v$.customHeadHtml.$touch()"
|
||||
/>
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="customHeadHtmlError ? 'text-n-ruby-9' : 'text-n-slate-11'"
|
||||
>
|
||||
{{
|
||||
customHeadHtmlError ||
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.HELP_TEXT')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-1">
|
||||
<textarea
|
||||
v-model="state.customBodyHtml"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.PLACEHOLDER')
|
||||
"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 text-sm border rounded-lg resize-y bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-1 font-mono"
|
||||
:class="
|
||||
customBodyHtmlError
|
||||
? 'border-n-ruby-9 focus:ring-n-ruby-9'
|
||||
: 'border-n-weak focus:ring-n-brand'
|
||||
"
|
||||
@input="v$.customBodyHtml.$touch()"
|
||||
/>
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="customBodyHtmlError ? 'text-n-ruby-9' : 'text-n-slate-11'"
|
||||
>
|
||||
{{
|
||||
customBodyHtmlError ||
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.HELP_TEXT')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end w-full gap-2">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SAVE_CHANGES')"
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||
import { reactive, ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||
@ -18,9 +20,12 @@ import {
|
||||
processContactableInboxes,
|
||||
mergeInboxDetails,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { pendingGroupNavigation } from 'dashboard/helper/pendingGroupNavigation';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||
import ComposeNewGroupForm from 'dashboard/components-next/NewConversation/components/ComposeNewGroupForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
alignPosition: {
|
||||
@ -41,6 +46,8 @@ const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||
@ -58,6 +65,8 @@ const isCreatingContact = ref(false);
|
||||
const isFetchingInboxes = ref(false);
|
||||
const isSearching = ref(false);
|
||||
const showComposeNewConversation = ref(false);
|
||||
const composeMode = ref('conversation');
|
||||
const groupFormRef = ref(null);
|
||||
|
||||
const formState = reactive({
|
||||
message: '',
|
||||
@ -83,6 +92,84 @@ const globalConfig = useMapGetter('globalConfig/get');
|
||||
const uiFlags = useMapGetter('contactConversations/getUIFlags');
|
||||
const messageSignature = useMapGetter('getMessageSignature');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const groupUiFlags = useMapGetter('groupMembers/getUIFlags');
|
||||
|
||||
const groupCreationInboxes = computed(() =>
|
||||
inboxesList.value.filter(inbox => inbox.allow_group_creation)
|
||||
);
|
||||
|
||||
const isGroupMode = computed(() => composeMode.value === 'group');
|
||||
const hasGroupInboxes = computed(() => groupCreationInboxes.value.length > 0);
|
||||
const isGroupsDisabled = computed(
|
||||
() => !globalConfig.value.baileysWhatsappGroupsEnabled
|
||||
);
|
||||
|
||||
const resetContacts = () => {
|
||||
contacts.value = [];
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
showComposeNewConversation.value = false;
|
||||
composeMode.value = 'conversation';
|
||||
if (!props.contactId) {
|
||||
selectedContact.value = null;
|
||||
}
|
||||
targetInbox.value = null;
|
||||
resetContacts();
|
||||
groupFormRef.value?.resetForm();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const discardCompose = () => {
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
closeCompose();
|
||||
};
|
||||
|
||||
const switchMode = mode => {
|
||||
if (composeMode.value === mode) return;
|
||||
composeMode.value = mode;
|
||||
selectedContact.value = null;
|
||||
targetInbox.value = null;
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
resetContacts();
|
||||
groupFormRef.value?.resetForm();
|
||||
};
|
||||
|
||||
const createGroup = async ({ inboxId, subject, participants }) => {
|
||||
try {
|
||||
const data = await store.dispatch('groupMembers/createGroup', {
|
||||
inbox_id: inboxId,
|
||||
subject,
|
||||
participants,
|
||||
});
|
||||
pendingGroupNavigation.set(data.group_jid);
|
||||
groupFormRef.value?.resetForm();
|
||||
discardCompose();
|
||||
useAlert(t('GROUP.CREATE.SUCCESS_MESSAGE'));
|
||||
} catch {
|
||||
useAlert(t('GROUP.CREATE.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
fetchInboxSignatures,
|
||||
getSignatureForInbox,
|
||||
getSignatureSettingsForInbox,
|
||||
} = useInboxSignatures();
|
||||
|
||||
fetchInboxSignatures();
|
||||
|
||||
const resolvedMessageSignature = computed(() => {
|
||||
if (!targetInbox.value?.id) return messageSignature.value;
|
||||
return getSignatureForInbox(targetInbox.value.id);
|
||||
});
|
||||
|
||||
const resolvedSignatureSettings = computed(() => {
|
||||
if (!targetInbox.value?.id) return null;
|
||||
return getSignatureSettingsForInbox(targetInbox.value.id);
|
||||
});
|
||||
|
||||
const sendWithSignature = computed(() =>
|
||||
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
|
||||
@ -119,10 +206,6 @@ const onContactSearch = debounce(
|
||||
false
|
||||
);
|
||||
|
||||
const resetContacts = () => {
|
||||
contacts.value = [];
|
||||
};
|
||||
|
||||
const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
let contact;
|
||||
if (action === 'create') {
|
||||
@ -167,24 +250,6 @@ const clearSelectedContact = () => {
|
||||
clearFormState();
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
showComposeNewConversation.value = false;
|
||||
if (!props.contactId) {
|
||||
// If contactId is passed as prop
|
||||
// Then don't allow to remove the selected contact
|
||||
selectedContact.value = null;
|
||||
}
|
||||
targetInbox.value = null;
|
||||
resetContacts();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const discardCompose = () => {
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
closeCompose();
|
||||
};
|
||||
|
||||
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||
try {
|
||||
const data = await store.dispatch('contactConversations/create', {
|
||||
@ -250,7 +315,24 @@ const onModalBackdropClick = () => {
|
||||
handleClickOutside();
|
||||
};
|
||||
|
||||
onMounted(() => resetContacts());
|
||||
const navigateToGroup = ({ conversationId }) => {
|
||||
const url = frontendURL(
|
||||
conversationUrl({
|
||||
accountId: route.params.accountId,
|
||||
id: conversationId,
|
||||
})
|
||||
);
|
||||
router.push({ path: url });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
resetContacts();
|
||||
emitter.on(BUS_EVENTS.NAVIGATE_TO_GROUP, navigateToGroup);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(BUS_EVENTS.NAVIGATE_TO_GROUP, navigateToGroup);
|
||||
});
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
@ -293,30 +375,106 @@ useKeyboardEvents(keyboardEvents);
|
||||
}"
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<ComposeNewConversationForm
|
||||
:form-state="formState"
|
||||
<div
|
||||
v-if="!isGroupMode"
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
:current-user="currentUser"
|
||||
:selected-contact="selectedContact"
|
||||
:target-inbox="targetInbox"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
:is-direct-uploads-enabled="directUploadsEnabled"
|
||||
:contact-conversations-ui-flags="uiFlags"
|
||||
:contacts-ui-flags="contactsUiFlags"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@search-contacts="onContactSearch"
|
||||
@reset-contact-search="resetContacts"
|
||||
@update-selected-contact="handleSelectedContact"
|
||||
@update-target-inbox="handleTargetInbox"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@create-conversation="createConversation"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
class="w-[42rem] flex flex-col"
|
||||
>
|
||||
<div
|
||||
v-if="hasGroupInboxes"
|
||||
class="flex gap-1 px-4 pt-3 pb-0 bg-n-alpha-3 border border-b-0 border-n-strong backdrop-blur-[100px] rounded-t-xl"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
!isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('conversation')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_CONVERSATION') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('group')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_GROUP') }}
|
||||
</button>
|
||||
</div>
|
||||
<ComposeNewConversationForm
|
||||
:form-state="formState"
|
||||
:class="{ '!rounded-t-none !border-t-0': hasGroupInboxes }"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
:current-user="currentUser"
|
||||
:selected-contact="selectedContact"
|
||||
:target-inbox="targetInbox"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
:is-direct-uploads-enabled="directUploadsEnabled"
|
||||
:contact-conversations-ui-flags="uiFlags"
|
||||
:contacts-ui-flags="contactsUiFlags"
|
||||
:message-signature="resolvedMessageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:signature-settings="resolvedSignatureSettings"
|
||||
@search-contacts="onContactSearch"
|
||||
@reset-contact-search="resetContacts"
|
||||
@update-selected-contact="handleSelectedContact"
|
||||
@update-target-inbox="handleTargetInbox"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@create-conversation="createConversation"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
class="w-[42rem] flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex gap-1 px-4 pt-3 pb-0 bg-n-alpha-3 border border-b-0 border-n-strong backdrop-blur-[100px] rounded-t-xl"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
!isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('conversation')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_CONVERSATION') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('group')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_GROUP') }}
|
||||
</button>
|
||||
</div>
|
||||
<ComposeNewGroupForm
|
||||
ref="groupFormRef"
|
||||
class="!rounded-t-none !border-t-0"
|
||||
:inboxes="groupCreationInboxes"
|
||||
:is-creating="groupUiFlags.isCreating"
|
||||
:is-groups-disabled="isGroupsDisabled"
|
||||
@create-group="createGroup"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -17,6 +17,8 @@ import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isWhatsappBaileysInbox: { type: Boolean, default: false },
|
||||
isWhatsappZapiInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
@ -78,7 +80,11 @@ const shouldShowEmojiButton = computed(() => {
|
||||
});
|
||||
|
||||
const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
return (
|
||||
(!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox) ||
|
||||
props.isWhatsappBaileysInbox ||
|
||||
props.isWhatsappZapiInbox
|
||||
);
|
||||
});
|
||||
|
||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||
|
||||
@ -4,8 +4,6 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
stripUnsupportedMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
@ -37,6 +35,7 @@ const props = defineProps({
|
||||
contactsUiFlags: { type: Object, default: null },
|
||||
messageSignature: { type: String, default: '' },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
signatureSettings: { type: Object, default: null },
|
||||
formState: { type: Object, required: true },
|
||||
});
|
||||
|
||||
@ -70,6 +69,12 @@ const inboxTypes = computed(() => ({
|
||||
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
|
||||
isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
|
||||
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
|
||||
isWhatsappBaileys:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
props.targetInbox?.provider === 'baileys',
|
||||
isWhatsappZapi:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
props.targetInbox?.provider === 'zapi',
|
||||
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
|
||||
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
|
||||
isEmailOrWebWidget:
|
||||
@ -91,12 +96,6 @@ const whatsappMessageTemplates = computed(() =>
|
||||
|
||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
targetInbox: { required },
|
||||
@ -131,6 +130,9 @@ const newMessagePayload = () => {
|
||||
currentUser: props.currentUser,
|
||||
attachedFiles,
|
||||
directUploadsEnabled: props.isDirectUploadsEnabled,
|
||||
sendWithSignature: props.sendWithSignature,
|
||||
messageSignature: props.messageSignature,
|
||||
signatureSettings: props.signatureSettings,
|
||||
});
|
||||
};
|
||||
|
||||
@ -222,21 +224,8 @@ const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const removeSignatureFromMessage = () => {
|
||||
// Always remove the signature from message content when inbox/contact is removed
|
||||
// to ensure no leftover signature content remains
|
||||
if (props.messageSignature) {
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
props.messageSignature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
removeSignatureFromMessage();
|
||||
|
||||
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||
|
||||
@ -245,7 +234,6 @@ const removeTargetInbox = value => {
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
state.attachedFiles = [];
|
||||
@ -255,22 +243,6 @@ const onClickInsertEmoji = emoji => {
|
||||
state.message += emoji;
|
||||
};
|
||||
|
||||
const handleAddSignature = signature => {
|
||||
state.message = appendSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveSignature = signature => {
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleAttachFile = files => {
|
||||
state.attachedFiles = files;
|
||||
};
|
||||
@ -333,7 +305,9 @@ const handleSendTwilioMessage = async ({ message, templateParams }) => {
|
||||
|
||||
const shouldShowMessageEditor = computed(() => {
|
||||
return (
|
||||
!inboxTypes.value.isWhatsapp &&
|
||||
(!inboxTypes.value.isWhatsapp ||
|
||||
inboxTypes.value.isWhatsappBaileys ||
|
||||
inboxTypes.value.isWhatsappZapi) &&
|
||||
!showNoInboxAlert.value &&
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
@ -408,6 +382,8 @@ const shouldShowMessageEditor = computed(() => {
|
||||
<ActionButtons
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-whatsapp-baileys-inbox="inboxTypes.isWhatsappBaileys"
|
||||
:is-whatsapp-zapi-inbox="inboxTypes.isWhatsappZapi"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
@ -421,8 +397,6 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:is-dropdown-active="isAnyDropdownActive"
|
||||
:message-signature="messageSignature"
|
||||
@insert-emoji="onClickInsertEmoji"
|
||||
@add-signature="handleAddSignature"
|
||||
@remove-signature="handleRemoveSignature"
|
||||
@attach-file="handleAttachFile"
|
||||
@discard="$emit('discard')"
|
||||
@send-message="handleSendMessage"
|
||||
|
||||
@ -0,0 +1,305 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import ContactsAPI from 'dashboard/api/contacts';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
isCreating: { type: Boolean, default: false },
|
||||
isGroupsDisabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['createGroup', 'discard']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const groupName = ref('');
|
||||
const selectedInbox = ref(null);
|
||||
const showInboxDropdown = ref(false);
|
||||
const participants = ref([]);
|
||||
const contactResults = ref([]);
|
||||
const showContactsDropdown = ref(false);
|
||||
const isSearching = ref(false);
|
||||
const nameTouched = ref(false);
|
||||
const participantsTouched = ref(false);
|
||||
const participantsFocused = ref(false);
|
||||
|
||||
const inboxMenuItems = computed(() =>
|
||||
props.inboxes.map(inbox => ({
|
||||
label: inbox.name,
|
||||
value: inbox.id,
|
||||
action: 'select',
|
||||
}))
|
||||
);
|
||||
|
||||
const contactMenuItems = computed(() =>
|
||||
contactResults.value.map(contact => ({
|
||||
id: contact.id,
|
||||
label: contact.phone_number
|
||||
? `${contact.name} (${contact.phone_number})`
|
||||
: contact.name,
|
||||
value: contact.id,
|
||||
action: 'contact',
|
||||
thumbnail: { name: contact.name, src: contact.thumbnail },
|
||||
phoneNumber: contact.phone_number,
|
||||
name: contact.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const participantTags = computed(() =>
|
||||
participants.value.map(p => p.name || p.phone_number)
|
||||
);
|
||||
|
||||
const showNameError = computed(
|
||||
() => nameTouched.value && !groupName.value.trim()
|
||||
);
|
||||
const showParticipantsError = computed(
|
||||
() => participantsTouched.value && participants.value.length === 0
|
||||
);
|
||||
|
||||
const isFormValid = computed(
|
||||
() =>
|
||||
selectedInbox.value &&
|
||||
groupName.value.trim() &&
|
||||
participants.value.length > 0
|
||||
);
|
||||
|
||||
const searchContacts = debounce(
|
||||
async query => {
|
||||
if (!query || query.length < 2) {
|
||||
contactResults.value = [];
|
||||
showContactsDropdown.value = false;
|
||||
return;
|
||||
}
|
||||
isSearching.value = true;
|
||||
try {
|
||||
const { data } = await ContactsAPI.search(query);
|
||||
const selectedIds = participants.value.map(p => p.id);
|
||||
contactResults.value = (data.payload || []).filter(
|
||||
contact => contact.phone_number && !selectedIds.includes(contact.id)
|
||||
);
|
||||
showContactsDropdown.value = contactResults.value.length > 0;
|
||||
} catch {
|
||||
contactResults.value = [];
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
},
|
||||
300,
|
||||
false
|
||||
);
|
||||
|
||||
const handleInboxAction = item => {
|
||||
const inbox = props.inboxes.find(i => i.id === item.value);
|
||||
selectedInbox.value = inbox;
|
||||
showInboxDropdown.value = false;
|
||||
};
|
||||
|
||||
const clearInbox = () => {
|
||||
selectedInbox.value = null;
|
||||
};
|
||||
|
||||
const handleAddParticipant = item => {
|
||||
const contact = contactResults.value.find(c => c.id === item.value);
|
||||
if (contact) {
|
||||
participants.value = [...participants.value, contact];
|
||||
participantsTouched.value = true;
|
||||
contactResults.value = [];
|
||||
showContactsDropdown.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveParticipant = index => {
|
||||
participants.value = participants.value.filter((_, i) => i !== index);
|
||||
participantsTouched.value = true;
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
nameTouched.value = true;
|
||||
};
|
||||
|
||||
const handleParticipantsFocus = () => {
|
||||
participantsFocused.value = true;
|
||||
};
|
||||
|
||||
const handleParticipantsBlur = () => {
|
||||
showContactsDropdown.value = false;
|
||||
if (participantsFocused.value && participants.value.length === 0) {
|
||||
participantsTouched.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
groupName.value = '';
|
||||
selectedInbox.value = null;
|
||||
participants.value = [];
|
||||
contactResults.value = [];
|
||||
showContactsDropdown.value = false;
|
||||
showInboxDropdown.value = false;
|
||||
nameTouched.value = false;
|
||||
participantsTouched.value = false;
|
||||
participantsFocused.value = false;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isFormValid.value) return;
|
||||
emit('createGroup', {
|
||||
inboxId: selectedInbox.value.id,
|
||||
subject: groupName.value.trim(),
|
||||
participants: participants.value.map(p => p.phone_number),
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({ resetForm });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||
>
|
||||
<div class="flex-1 divide-y divide-n-strong overflow-visible">
|
||||
<div
|
||||
v-if="isGroupsDisabled"
|
||||
class="flex items-center gap-2 mx-4 mt-3 px-3 py-2 rounded-lg text-sm text-n-amber-11 bg-n-amber-2"
|
||||
>
|
||||
<span class="i-lucide-triangle-alert text-base flex-shrink-0" />
|
||||
<span>
|
||||
{{ t('GROUP.CREATE.GROUPS_DISABLED') }}
|
||||
<a
|
||||
:href="wootConstants.FAZER_AI_GUIDES_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline font-medium"
|
||||
>
|
||||
{{ t('GROUP.CREATE.GROUPS_DISABLED_CTA') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
|
||||
>
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
{{ t('GROUP.CREATE.INBOX_LABEL') }}
|
||||
</label>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div
|
||||
v-if="selectedInbox"
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
|
||||
>
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{ selectedInbox.name }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="flex-shrink-0"
|
||||
@click="clearInbox"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative">
|
||||
<Button
|
||||
:label="t('GROUP.CREATE.INBOX_PLACEHOLDER')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="slate"
|
||||
class="hover:!no-underline"
|
||||
@click="showInboxDropdown = !showInboxDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showInboxDropdown"
|
||||
:menu-items="inboxMenuItems"
|
||||
class="z-[100] top-9 w-full max-h-48 overflow-y-auto dark:!outline-n-slate-5"
|
||||
@action="handleInboxAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
|
||||
>
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium whitespace-nowrap mt-1"
|
||||
:class="showNameError ? 'text-n-ruby-9' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ t('GROUP.CREATE.NAME_LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<input
|
||||
v-model="groupName"
|
||||
type="text"
|
||||
class="w-full px-2 py-1 text-sm rounded-md bg-transparent text-n-slate-12 placeholder:text-n-slate-10 focus:outline-none border"
|
||||
:class="showNameError ? 'border-n-ruby-9' : 'border-transparent'"
|
||||
:placeholder="t('GROUP.CREATE.NAME_PLACEHOLDER')"
|
||||
@blur="handleNameBlur"
|
||||
/>
|
||||
<span v-if="showNameError" class="text-xs text-n-ruby-9 mt-0.5 px-2">
|
||||
{{ t('GROUP.CREATE.NAME_REQUIRED') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col gap-1 px-4 py-3">
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium whitespace-nowrap"
|
||||
:class="showParticipantsError ? 'text-n-ruby-9' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ t('GROUP.CREATE.PARTICIPANTS_LABEL') }}
|
||||
</label>
|
||||
<TagInput
|
||||
:model-value="participantTags"
|
||||
:placeholder="t('GROUP.CREATE.PARTICIPANTS_PLACEHOLDER')"
|
||||
mode="multiple"
|
||||
:menu-items="contactMenuItems"
|
||||
:show-dropdown="showContactsDropdown"
|
||||
:is-loading="isSearching"
|
||||
skip-label-dedup
|
||||
:auto-open-dropdown="false"
|
||||
:class="showParticipantsError ? '!border-n-ruby-9' : ''"
|
||||
@input="searchContacts"
|
||||
@focus="handleParticipantsFocus"
|
||||
@on-click-outside="handleParticipantsBlur"
|
||||
@add="handleAddParticipant"
|
||||
@remove="handleRemoveParticipant"
|
||||
/>
|
||||
<span v-if="showParticipantsError" class="text-xs text-n-ruby-9">
|
||||
{{ t('GROUP.CREATE.PARTICIPANTS_REQUIRED') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 px-4 py-3">
|
||||
<div />
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@click="
|
||||
resetForm();
|
||||
emit('discard');
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="t('GROUP.CREATE.SUBMIT_BUTTON')"
|
||||
color="blue"
|
||||
size="sm"
|
||||
:disabled="!isFormValid || isCreating || isGroupsDisabled"
|
||||
:is-loading="isCreating"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,5 +1,6 @@
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { appendSignature } from 'dashboard/helper/editorHelper';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
@ -129,12 +130,24 @@ export const prepareNewMessagePayload = ({
|
||||
currentUser,
|
||||
attachedFiles = [],
|
||||
directUploadsEnabled = false,
|
||||
sendWithSignature = false,
|
||||
messageSignature = '',
|
||||
signatureSettings = null,
|
||||
}) => {
|
||||
let finalMessage = message;
|
||||
if (sendWithSignature && messageSignature) {
|
||||
const settings = signatureSettings || {
|
||||
position: currentUser?.ui_settings?.signature_position || 'top',
|
||||
separator: currentUser?.ui_settings?.signature_separator || 'blank',
|
||||
};
|
||||
finalMessage = appendSignature(message, messageSignature, settings);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
inboxId: targetInbox.id,
|
||||
sourceId: targetInbox.sourceId,
|
||||
contactId: Number(selectedContact.id),
|
||||
message: { content: message },
|
||||
message: { content: finalMessage },
|
||||
assigneeId: currentUser.id,
|
||||
};
|
||||
|
||||
|
||||
127
app/javascript/dashboard/components-next/banner/PromoBanner.vue
Normal file
127
app/javascript/dashboard/components-next/banner/PromoBanner.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
validator: value => ['info', 'success', 'warning'].includes(value),
|
||||
},
|
||||
ctaText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
ctaLink: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
ctaExternal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
logoSrc: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
logoAlt: {
|
||||
type: String,
|
||||
default: 'Logo',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['ctaClick']);
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
const variants = {
|
||||
info: {
|
||||
container: 'bg-woot-50 border-woot-200',
|
||||
icon: 'i-lucide-info text-woot-600',
|
||||
text: 'text-woot-700',
|
||||
description: 'text-woot-600',
|
||||
},
|
||||
success: {
|
||||
container: 'bg-green-50 border-green-200',
|
||||
icon: 'i-lucide-sparkles text-green-600',
|
||||
text: 'text-green-700',
|
||||
description: 'text-green-600',
|
||||
},
|
||||
warning: {
|
||||
container: 'bg-yellow-50 border-yellow-200',
|
||||
icon: 'i-lucide-alert-circle text-yellow-600',
|
||||
text: 'text-yellow-700',
|
||||
description: 'text-yellow-600',
|
||||
},
|
||||
};
|
||||
return variants[props.variant];
|
||||
});
|
||||
|
||||
const handleCtaClick = () => {
|
||||
emit('ctaClick');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-start gap-3 p-4 rounded-lg border"
|
||||
:class="variantClasses.container"
|
||||
>
|
||||
<div v-if="logoSrc || showIcon" class="flex-shrink-0 mt-0.5">
|
||||
<img
|
||||
v-if="logoSrc"
|
||||
:src="logoSrc"
|
||||
:alt="logoAlt"
|
||||
class="w-8 h-8 object-contain"
|
||||
/>
|
||||
<i v-else class="w-5 h-5" :class="variantClasses.icon" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-semibold mb-1" :class="variantClasses.text">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="text-sm leading-relaxed" :class="variantClasses.description">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div v-if="ctaText" class="mt-3">
|
||||
<a
|
||||
v-if="ctaLink"
|
||||
:href="ctaLink"
|
||||
:target="ctaExternal ? '_blank' : '_self'"
|
||||
:rel="ctaExternal ? 'noopener noreferrer' : undefined"
|
||||
class="inline-block"
|
||||
>
|
||||
<NextButton
|
||||
sm
|
||||
:color-scheme="variant === 'success' ? 'primary' : 'secondary'"
|
||||
type="button"
|
||||
>
|
||||
{{ ctaText }}
|
||||
</NextButton>
|
||||
</a>
|
||||
<NextButton
|
||||
v-else
|
||||
sm
|
||||
:color-scheme="variant === 'success' ? 'primary' : 'secondary'"
|
||||
type="button"
|
||||
@click="handleCtaClick"
|
||||
>
|
||||
{{ ctaText }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -44,7 +44,7 @@ const triggerClick = () => {
|
||||
<component
|
||||
:is="componentIs"
|
||||
v-bind="$attrs"
|
||||
class="flex text-left rtl:text-right items-center p-2 reset-base text-sm text-n-slate-12 w-full border-0"
|
||||
class="flex text-left rtl:text-right items-center p-2 reset-base text-sm text-n-slate-12 w-full border-0 cursor-pointer"
|
||||
:class="{
|
||||
'hover:bg-n-alpha-2 rounded-lg w-full gap-3': !$slots.default,
|
||||
}"
|
||||
|
||||
@ -14,6 +14,7 @@ export const CONVERSATION_ATTRIBUTES = {
|
||||
REFERER: 'referer',
|
||||
CREATED_AT: 'created_at',
|
||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||
GROUP_TYPE: 'group_type',
|
||||
};
|
||||
|
||||
export const CONTACT_ATTRIBUTES = {
|
||||
|
||||
@ -247,6 +247,20 @@ export function useConversationFilterContext() {
|
||||
filterOperators: dateOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.GROUP_TYPE,
|
||||
value: CONVERSATION_ATTRIBUTES.GROUP_TYPE,
|
||||
attributeName: t('FILTER.ATTRIBUTES.GROUP_TYPE'),
|
||||
label: t('FILTER.ATTRIBUTES.GROUP_TYPE'),
|
||||
inputType: 'multiSelect',
|
||||
options: ['individual', 'group'].map(id => ({
|
||||
id,
|
||||
name: t(`GROUP.FILTER.${id.toUpperCase()}`),
|
||||
})),
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
...customFilterTypes.value,
|
||||
]);
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<script setup>
|
||||
import { onMounted, computed, ref, toRefs } from 'vue';
|
||||
import { onMounted, onUnmounted, computed, ref, toRefs } from 'vue';
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
import { provideMessageContext } from './provider.js';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
@ -97,11 +97,12 @@ import { useBranding } from 'shared/composables/useBranding';
|
||||
* @property {boolean} [isEmailInbox=false] - Whether the message is from an email inbox
|
||||
* @property {number} conversationId - The ID of the conversation to which the message belongs
|
||||
* @property {number} inboxId - The ID of the inbox to which the message belongs
|
||||
* @property {Object} [additionalAttributes={}] - Additional attributes of the message
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line vue/define-macros-order
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
id: { type: [Number, String], required: true },
|
||||
messageType: {
|
||||
type: Number,
|
||||
required: true,
|
||||
@ -120,14 +121,19 @@ const props = defineProps({
|
||||
default: 'text',
|
||||
validator: value => Object.values(CONTENT_TYPES).includes(value),
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
conversationId: { type: Number, required: true },
|
||||
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
groupWithNext: { type: Boolean, default: false },
|
||||
groupWithPrevious: { type: Boolean, default: false },
|
||||
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
|
||||
inboxSupportsEdit: { type: Boolean, default: false },
|
||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
isEmailInbox: { type: Boolean, default: false },
|
||||
isGroupConversation: { type: Boolean, default: false },
|
||||
private: { type: Boolean, default: false },
|
||||
sender: { type: Object, default: null },
|
||||
senderId: { type: Number, default: null },
|
||||
@ -144,6 +150,7 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
const router = useRouter();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
/**
|
||||
@ -241,7 +248,21 @@ const flexOrientationClass = computed(() => {
|
||||
return map[orientation.value];
|
||||
});
|
||||
|
||||
const isGroupIncoming = computed(() => {
|
||||
return (
|
||||
props.isGroupConversation && props.messageType === MESSAGE_TYPES.INCOMING
|
||||
);
|
||||
});
|
||||
|
||||
const showGroupSenderAvatar = computed(() => {
|
||||
return isGroupIncoming.value && !props.groupWithPrevious;
|
||||
});
|
||||
|
||||
const gridClass = computed(() => {
|
||||
if (orientation.value === ORIENTATION.LEFT && isGroupIncoming.value) {
|
||||
return 'grid grid-cols-[24px_1fr]';
|
||||
}
|
||||
|
||||
const map = {
|
||||
[ORIENTATION.LEFT]: 'grid grid-cols-1fr',
|
||||
[ORIENTATION.RIGHT]: 'grid grid-cols-[1fr_24px]',
|
||||
@ -251,6 +272,13 @@ const gridClass = computed(() => {
|
||||
});
|
||||
|
||||
const gridTemplate = computed(() => {
|
||||
if (orientation.value === ORIENTATION.LEFT && isGroupIncoming.value) {
|
||||
return `
|
||||
"avatar bubble"
|
||||
"spacer meta"
|
||||
`;
|
||||
}
|
||||
|
||||
const map = {
|
||||
[ORIENTATION.LEFT]: `
|
||||
"bubble"
|
||||
@ -382,6 +410,12 @@ const contextMenuEnabledOptions = computed(() => {
|
||||
!props.private &&
|
||||
props.inboxSupportsReplyTo.outgoing &&
|
||||
!isFailedOrProcessing,
|
||||
edit:
|
||||
isOutgoing &&
|
||||
hasText &&
|
||||
!isFailedOrProcessing &&
|
||||
!isMessageDeleted.value &&
|
||||
props.inboxSupportsEdit,
|
||||
};
|
||||
});
|
||||
|
||||
@ -450,8 +484,16 @@ const avatarInfo = computed(() => {
|
||||
};
|
||||
}
|
||||
|
||||
// If no sender, return bot info
|
||||
// If no sender, check for external sender name
|
||||
if (!props.sender) {
|
||||
const externalSenderName = props.contentAttributes?.externalSenderName;
|
||||
if (externalSenderName === 'WhatsApp') {
|
||||
return {
|
||||
name: t('CONVERSATION.WHATSAPP'),
|
||||
src: '',
|
||||
iconName: 'i-woot-whatsapp',
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: t('CONVERSATION.BOT'),
|
||||
src: '',
|
||||
@ -484,6 +526,47 @@ const avatarTooltip = computed(() => {
|
||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||
});
|
||||
|
||||
// Colors for group sender names, matching AVATAR_COLORS from Avatar component
|
||||
const SENDER_NAME_COLORS = {
|
||||
light: ['#C2298A', '#99543A', '#60646C', '#008573', '#4747C2', '#3A5BC7'],
|
||||
dark: ['#FF8DCC', '#FFA366', '#ADB1B8', '#0BD8B6', '#A19EFF', '#9EB1FF'],
|
||||
};
|
||||
|
||||
const showGroupSenderName = computed(() => {
|
||||
return (
|
||||
props.isGroupConversation &&
|
||||
props.messageType === MESSAGE_TYPES.INCOMING &&
|
||||
!props.groupWithPrevious &&
|
||||
props.sender?.name
|
||||
);
|
||||
});
|
||||
|
||||
const senderNameStyle = computed(() => {
|
||||
if (!showGroupSenderName.value) return {};
|
||||
const name = props.sender?.name || '';
|
||||
const index = name.length % SENDER_NAME_COLORS.light.length;
|
||||
return {
|
||||
color: SENDER_NAME_COLORS.light[index],
|
||||
'--dark-sender-color': SENDER_NAME_COLORS.dark[index],
|
||||
};
|
||||
});
|
||||
|
||||
const navigateToGroupSender = event => {
|
||||
if (
|
||||
!isGroupIncoming.value ||
|
||||
!props.sender?.id ||
|
||||
props.sender.type?.toLowerCase() !== 'contact'
|
||||
)
|
||||
return;
|
||||
const accountId = route.params.accountId;
|
||||
const url = `/app/accounts/${accountId}/contacts/${props.sender.id}`;
|
||||
if (event?.ctrlKey || event?.metaKey) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
};
|
||||
|
||||
const setupHighlightTimer = () => {
|
||||
if (Number(route.query.messageId) !== Number(props.id)) {
|
||||
return;
|
||||
@ -496,7 +579,23 @@ const setupHighlightTimer = () => {
|
||||
}, HIGHLIGHT_TIMER);
|
||||
};
|
||||
|
||||
onMounted(setupHighlightTimer);
|
||||
const HIGHLIGHT_DURATION = 1000;
|
||||
const onHighlightMessage = ({ messageId } = {}) => {
|
||||
if (Number(messageId) !== Number(props.id)) return;
|
||||
showBackgroundHighlight.value = true;
|
||||
useTimeoutFn(() => {
|
||||
showBackgroundHighlight.value = false;
|
||||
}, HIGHLIGHT_DURATION);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupHighlightTimer();
|
||||
emitter.on(BUS_EVENTS.HIGHLIGHT_MESSAGE, onHighlightMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(BUS_EVENTS.HIGHLIGHT_MESSAGE, onHighlightMessage);
|
||||
});
|
||||
|
||||
provideMessageContext({
|
||||
...toRefs(props),
|
||||
@ -540,6 +639,18 @@ provideMessageContext({
|
||||
gridTemplateAreas: gridTemplate,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="showGroupSenderAvatar"
|
||||
class="[grid-area:avatar] flex items-end"
|
||||
>
|
||||
<Avatar
|
||||
v-tooltip.right-end="avatarTooltip"
|
||||
v-bind="avatarInfo"
|
||||
:size="24"
|
||||
class="cursor-pointer"
|
||||
@click="navigateToGroupSender($event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!shouldGroupWithNext && shouldShowAvatar"
|
||||
v-tooltip.left-end="avatarTooltip"
|
||||
@ -547,16 +658,25 @@ provideMessageContext({
|
||||
>
|
||||
<Avatar v-bind="avatarInfo" :size="24" />
|
||||
</div>
|
||||
<div
|
||||
class="[grid-area:bubble] flex"
|
||||
:class="{
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<Component :is="componentToRender" />
|
||||
<div class="[grid-area:bubble]" @contextmenu="openContextMenu($event)">
|
||||
<span
|
||||
v-if="showGroupSenderName"
|
||||
class="text-xs font-medium mb-0.5 inline-block ltr:mr-8 rtl:ml-8 cursor-pointer hover:underline dark:!text-[var(--dark-sender-color)]"
|
||||
:style="senderNameStyle"
|
||||
@click="navigateToGroupSender($event)"
|
||||
>
|
||||
{{ sender?.name }}
|
||||
</span>
|
||||
<div
|
||||
class="flex"
|
||||
:class="{
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
}"
|
||||
>
|
||||
<Component :is="componentToRender" />
|
||||
</div>
|
||||
</div>
|
||||
<MessageError
|
||||
v-if="contentAttributes.externalError"
|
||||
|
||||
@ -14,6 +14,7 @@ import MessageApi from 'dashboard/api/inbox/message.js';
|
||||
* @property {Number} currentUserId - ID of the current user
|
||||
* @property {Boolean} isAnEmailChannel - Whether this is an email channel
|
||||
* @property {Object} inboxSupportsReplyTo - Inbox reply support configuration
|
||||
* @property {Boolean} inboxSupportsEdit - Whether the inbox supports message editing
|
||||
* @property {Array} messages - Array of all messages [These are not in camelcase]
|
||||
*/
|
||||
const props = defineProps({
|
||||
@ -22,7 +23,7 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
firstUnreadId: {
|
||||
type: Number,
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
isAnEmailChannel: {
|
||||
@ -33,6 +34,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({ incoming: false, outgoing: false }),
|
||||
},
|
||||
inboxSupportsEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@ -50,6 +55,10 @@ const allMessages = computed(() => {
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
|
||||
const isGroupConversation = computed(
|
||||
() => currentChat.value?.group_type === 'group'
|
||||
);
|
||||
|
||||
// Cache for fetched reply messages to avoid duplicate API calls
|
||||
const fetchedReplyMessages = reactive(new Map());
|
||||
|
||||
@ -175,7 +184,12 @@ const getInReplyToMessage = parentMessage => {
|
||||
:is-email-inbox="isAnEmailChannel"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
:group-with-next="shouldGroupWithNext(index, allMessages)"
|
||||
:group-with-previous="
|
||||
index > 0 && shouldGroupWithNext(index - 1, allMessages)
|
||||
"
|
||||
:is-group-conversation="isGroupConversation"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:inbox-supports-edit="inboxSupportsEdit"
|
||||
:current-user-id="currentUserId"
|
||||
data-clarity-mask="True"
|
||||
@retry="emit('retry', message)"
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||
|
||||
import MessageStatus from './MessageStatus.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
@ -23,6 +25,8 @@ const {
|
||||
isATiktokChannel,
|
||||
} = useInbox();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const {
|
||||
status,
|
||||
isPrivate,
|
||||
@ -30,12 +34,96 @@ const {
|
||||
sourceId,
|
||||
messageType,
|
||||
contentAttributes,
|
||||
additionalAttributes,
|
||||
sender,
|
||||
currentUserId,
|
||||
} = useMessageContext();
|
||||
|
||||
const readableTime = computed(() =>
|
||||
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
||||
messageTimestamp(
|
||||
contentAttributes?.value?.externalCreatedAt ?? createdAt.value,
|
||||
'LLL d, h:mm a'
|
||||
)
|
||||
);
|
||||
|
||||
const isScheduledMessage = computed(
|
||||
() => !!additionalAttributes.value?.scheduledMessageId
|
||||
);
|
||||
const scheduledBy = computed(() => additionalAttributes.value?.scheduledBy);
|
||||
const scheduledById = computed(() => scheduledBy.value?.id);
|
||||
const scheduledByType = computed(() =>
|
||||
scheduledBy.value?.type ? String(scheduledBy.value.type) : ''
|
||||
);
|
||||
const scheduledByTypeNormalized = computed(() =>
|
||||
scheduledByType.value.toLowerCase()
|
||||
);
|
||||
const scheduledByAgent = useFunctionGetter(
|
||||
'agents/getAgentById',
|
||||
scheduledById
|
||||
);
|
||||
|
||||
const isScheduledByCurrentUser = computed(() => {
|
||||
if (!scheduledById.value || !currentUserId.value) return false;
|
||||
return Number(scheduledById.value) === Number(currentUserId.value);
|
||||
});
|
||||
|
||||
const scheduledAt = computed(() => additionalAttributes.value?.scheduledAt);
|
||||
const scheduledAtTimestamp = computed(() => {
|
||||
if (!scheduledAt.value) return null;
|
||||
return Math.floor(scheduledAt.value);
|
||||
});
|
||||
|
||||
const scheduledAtLabel = computed(() => {
|
||||
if (!scheduledAtTimestamp.value) {
|
||||
return t('SCHEDULED_MESSAGES.ITEM.NO_SCHEDULE');
|
||||
}
|
||||
const date = new Date(scheduledAtTimestamp.value * 1000);
|
||||
const now = new Date();
|
||||
|
||||
const options = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
if (date.getFullYear() !== now.getFullYear()) {
|
||||
options.year = 'numeric';
|
||||
}
|
||||
|
||||
return date.toLocaleString(locale.value.replace('_', '-'), options);
|
||||
});
|
||||
|
||||
const scheduledByLabel = computed(() => {
|
||||
if (!isScheduledMessage.value) return '';
|
||||
if (isScheduledByCurrentUser.value) {
|
||||
const userName = scheduledByAgent.value?.name;
|
||||
return t('SCHEDULED_MESSAGES.META.AUTHOR_YOU', { name: userName });
|
||||
}
|
||||
if (scheduledByTypeNormalized.value.includes('automation')) {
|
||||
const automationLabel = t('SCHEDULED_MESSAGES.META.AUTOMATION');
|
||||
if (scheduledBy.value?.name) {
|
||||
return `${scheduledBy.value.name} (${automationLabel})`;
|
||||
}
|
||||
return automationLabel;
|
||||
}
|
||||
if (scheduledByAgent.value?.name) {
|
||||
return scheduledByAgent.value.name;
|
||||
}
|
||||
if (sender.value?.name) {
|
||||
return sender.value.name;
|
||||
}
|
||||
return t('SCHEDULED_MESSAGES.META.UNKNOWN_AUTHOR');
|
||||
});
|
||||
|
||||
const scheduledTooltip = computed(() => {
|
||||
if (!isScheduledMessage.value) return '';
|
||||
return t('SCHEDULED_MESSAGES.META.TOOLTIP', {
|
||||
time: scheduledAtLabel.value,
|
||||
author: scheduledByLabel.value,
|
||||
});
|
||||
});
|
||||
|
||||
const showStatusIndicator = computed(() => {
|
||||
if (isPrivate.value) return false;
|
||||
// Don't show status for failed messages, we already show error message
|
||||
@ -123,6 +211,14 @@ const statusToShow = computed(() => {
|
||||
|
||||
return MESSAGE_STATUS.PROGRESS;
|
||||
});
|
||||
|
||||
const isEdited = computed(() => {
|
||||
return contentAttributes.value?.isEdited === true;
|
||||
});
|
||||
|
||||
const previousContent = computed(() => {
|
||||
return contentAttributes.value?.previousContent || '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -130,8 +226,27 @@ const statusToShow = computed(() => {
|
||||
<div class="inline">
|
||||
<time class="inline">{{ readableTime }}</time>
|
||||
</div>
|
||||
<span
|
||||
v-if="isScheduledMessage"
|
||||
v-tooltip.top-start="{
|
||||
content: scheduledTooltip,
|
||||
delay: { show: 300, hide: 0 },
|
||||
}"
|
||||
class="inline-flex items-center gap-0.5"
|
||||
>
|
||||
<Icon icon="i-lucide-alarm-clock" class="size-3" />
|
||||
</span>
|
||||
<span
|
||||
v-if="isEdited"
|
||||
v-tooltip.top="{
|
||||
content: previousContent,
|
||||
delay: { show: 300, hide: 0 },
|
||||
}"
|
||||
class="inline-flex items-center gap-0.5"
|
||||
>
|
||||
<Icon icon="i-lucide-pencil" class="size-3" />
|
||||
</span>
|
||||
<Icon v-if="isPrivate" icon="i-lucide-lock-keyhole" class="size-3" />
|
||||
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
|
||||
</div>
|
||||
</template>
|
||||
`
|
||||
|
||||
@ -15,8 +15,13 @@ const props = defineProps({
|
||||
hideMeta: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
|
||||
useMessageContext();
|
||||
const {
|
||||
variant,
|
||||
orientation,
|
||||
inReplyTo,
|
||||
shouldGroupWithNext,
|
||||
additionalAttributes,
|
||||
} = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const varaintBaseMap = {
|
||||
@ -51,6 +56,16 @@ const flexOrientationClass = computed(() => {
|
||||
return map[orientation.value];
|
||||
});
|
||||
|
||||
const isScheduledMessage = computed(
|
||||
() => !!additionalAttributes.value?.scheduledMessageId
|
||||
);
|
||||
|
||||
const scheduledMessageClass = computed(() => {
|
||||
if (!isScheduledMessage.value) return '';
|
||||
if (variant.value === MESSAGE_VARIANTS.AGENT) return 'bg-n-solid-iris';
|
||||
return '';
|
||||
});
|
||||
|
||||
const messageClass = computed(() => {
|
||||
const classToApply = [varaintBaseMap[variant.value]];
|
||||
|
||||
@ -60,6 +75,10 @@ const messageClass = computed(() => {
|
||||
classToApply.push('rounded-lg');
|
||||
}
|
||||
|
||||
if (scheduledMessageClass.value) {
|
||||
classToApply.push(scheduledMessageClass.value);
|
||||
}
|
||||
|
||||
return classToApply;
|
||||
});
|
||||
|
||||
|
||||
@ -20,7 +20,9 @@ const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry();
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
type: 'image',
|
||||
});
|
||||
|
||||
const showGallery = ref(false);
|
||||
const isDownloading = ref(false);
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { MESSAGE_VARIANTS } from '../../constants';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
@ -12,7 +15,16 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const { variant } = useMessageContext();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const {
|
||||
variant,
|
||||
contentAttributes,
|
||||
shouldGroupWithNext,
|
||||
additionalAttributes,
|
||||
sender,
|
||||
currentUserId,
|
||||
} = useMessageContext();
|
||||
|
||||
const formattedContent = computed(() => {
|
||||
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
|
||||
@ -21,8 +33,137 @@ const formattedContent = computed(() => {
|
||||
|
||||
return new MessageFormatter(props.content).formattedMessage;
|
||||
});
|
||||
|
||||
// Show edited indicator inline when meta is hidden (grouped messages)
|
||||
const isEdited = computed(() => {
|
||||
return contentAttributes.value?.isEdited === true;
|
||||
});
|
||||
|
||||
const previousContent = computed(() => {
|
||||
return contentAttributes.value?.previousContent || '';
|
||||
});
|
||||
|
||||
const shouldShowEditedIndicator = computed(() => {
|
||||
return isEdited.value && shouldGroupWithNext.value;
|
||||
});
|
||||
|
||||
// Scheduled message indicator
|
||||
const isScheduledMessage = computed(
|
||||
() => !!additionalAttributes.value?.scheduledMessageId
|
||||
);
|
||||
const scheduledBy = computed(() => additionalAttributes.value?.scheduledBy);
|
||||
const scheduledById = computed(() => scheduledBy.value?.id);
|
||||
const scheduledByType = computed(() =>
|
||||
scheduledBy.value?.type ? String(scheduledBy.value.type) : ''
|
||||
);
|
||||
const scheduledByTypeNormalized = computed(() =>
|
||||
scheduledByType.value.toLowerCase()
|
||||
);
|
||||
const scheduledByAgent = useFunctionGetter(
|
||||
'agents/getAgentById',
|
||||
scheduledById
|
||||
);
|
||||
|
||||
const isScheduledByCurrentUser = computed(() => {
|
||||
if (!scheduledById.value || !currentUserId.value) return false;
|
||||
return Number(scheduledById.value) === Number(currentUserId.value);
|
||||
});
|
||||
|
||||
const scheduledAt = computed(() => additionalAttributes.value?.scheduledAt);
|
||||
const scheduledAtTimestamp = computed(() => {
|
||||
if (!scheduledAt.value) return null;
|
||||
return Math.floor(scheduledAt.value);
|
||||
});
|
||||
|
||||
const scheduledAtLabel = computed(() => {
|
||||
if (!scheduledAtTimestamp.value) {
|
||||
return t('SCHEDULED_MESSAGES.ITEM.NO_SCHEDULE');
|
||||
}
|
||||
const date = new Date(scheduledAtTimestamp.value * 1000);
|
||||
const now = new Date();
|
||||
|
||||
const options = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
if (date.getFullYear() !== now.getFullYear()) {
|
||||
options.year = 'numeric';
|
||||
}
|
||||
|
||||
return date.toLocaleString(locale.value.replace('_', '-'), options);
|
||||
});
|
||||
|
||||
const scheduledByLabel = computed(() => {
|
||||
if (!isScheduledMessage.value) return '';
|
||||
if (isScheduledByCurrentUser.value) {
|
||||
const userName = scheduledByAgent.value?.name;
|
||||
return t('SCHEDULED_MESSAGES.META.AUTHOR_YOU', { name: userName });
|
||||
}
|
||||
if (scheduledByTypeNormalized.value.includes('automation')) {
|
||||
const automationLabel = t('SCHEDULED_MESSAGES.META.AUTOMATION');
|
||||
if (scheduledBy.value?.name) {
|
||||
return `${scheduledBy.value.name} (${automationLabel})`;
|
||||
}
|
||||
return automationLabel;
|
||||
}
|
||||
if (scheduledByAgent.value?.name) {
|
||||
return scheduledByAgent.value.name;
|
||||
}
|
||||
if (sender.value?.name) {
|
||||
return sender.value.name;
|
||||
}
|
||||
return t('SCHEDULED_MESSAGES.META.UNKNOWN_AUTHOR');
|
||||
});
|
||||
|
||||
const scheduledTooltip = computed(() => {
|
||||
if (!isScheduledMessage.value) return '';
|
||||
return t('SCHEDULED_MESSAGES.META.TOOLTIP', {
|
||||
time: scheduledAtLabel.value,
|
||||
author: scheduledByLabel.value,
|
||||
});
|
||||
});
|
||||
|
||||
const shouldShowScheduledIndicator = computed(() => {
|
||||
return isScheduledMessage.value && shouldGroupWithNext.value;
|
||||
});
|
||||
|
||||
const iconColorClass = computed(() => {
|
||||
return variant.value === MESSAGE_VARIANTS.PRIVATE
|
||||
? 'text-n-amber-12/50'
|
||||
: 'text-n-slate-11';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-dompurify-html="formattedContent" class="prose prose-bubble" />
|
||||
<span class="inline">
|
||||
<span
|
||||
v-dompurify-html="formattedContent"
|
||||
class="prose prose-bubble [&_.prosemirror-mention-contact]:bg-n-blue-3 [&_.prosemirror-mention-contact]:rounded [&_.prosemirror-mention-contact]:px-1 [&_.prosemirror-mention-contact]:font-medium"
|
||||
/>
|
||||
<span
|
||||
v-if="shouldShowScheduledIndicator"
|
||||
v-tooltip.top="{
|
||||
content: scheduledTooltip,
|
||||
delay: { show: 300, hide: 0 },
|
||||
}"
|
||||
:class="iconColorClass"
|
||||
class="inline-flex items-center ml-1 align-middle"
|
||||
>
|
||||
<Icon icon="i-lucide-alarm-clock" class="size-3" />
|
||||
</span>
|
||||
<span
|
||||
v-if="shouldShowEditedIndicator"
|
||||
v-tooltip.top="{
|
||||
content: previousContent,
|
||||
delay: { show: 300, hide: 0 },
|
||||
}"
|
||||
:class="iconColorClass"
|
||||
class="inline-flex items-center ml-1 align-middle"
|
||||
>
|
||||
<Icon icon="i-lucide-pencil" class="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@ -6,6 +6,8 @@ import {
|
||||
ref,
|
||||
getCurrentInstance,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
@ -27,6 +29,11 @@ defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
type: 'audio',
|
||||
});
|
||||
|
||||
const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
});
|
||||
@ -42,19 +49,20 @@ const playbackSpeed = ref(1);
|
||||
const { uid } = getCurrentInstance();
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
if (audioPlayer.value) {
|
||||
duration.value = audioPlayer.value.duration;
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
}
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => {
|
||||
return `${playbackSpeed.value}x`;
|
||||
});
|
||||
|
||||
// There maybe a chance that the audioPlayer ref is not available
|
||||
// When the onLoadMetadata is called, so we need to set the duration
|
||||
// value when the component is mounted
|
||||
onMounted(() => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
if (attachment.dataUrl) {
|
||||
loadWithRetry(attachment.dataUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for global audio play events and pause if it's not this audio
|
||||
@ -125,71 +133,83 @@ const downloadAudio = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
controls
|
||||
class="hidden"
|
||||
playsinline
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnd"
|
||||
>
|
||||
<source :src="timeStampURL" />
|
||||
</audio>
|
||||
<div
|
||||
v-if="hasError"
|
||||
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="flex items-center gap-1 text-center rounded-lg p-2 bg-n-alpha-white border border-n-container"
|
||||
>
|
||||
<div class="flex gap-1 w-full flex-1 items-center justify-start">
|
||||
<button class="p-0 border-0 size-8" @click="playOrPause">
|
||||
<Icon
|
||||
v-if="isPlaying"
|
||||
class="size-8"
|
||||
icon="i-teenyicons-pause-small-solid"
|
||||
/>
|
||||
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
|
||||
</button>
|
||||
<div class="tabular-nums text-xs">
|
||||
{{ 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
|
||||
v-if="attachment.transcribedText && showTranscribedText"
|
||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||
>
|
||||
{{ attachment.transcribedText }}
|
||||
</div>
|
||||
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||
<p class="mb-0 text-n-slate-11 text-sm">
|
||||
{{ t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else-if="isLoaded">
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
controls
|
||||
class="hidden"
|
||||
playsinline
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnd"
|
||||
>
|
||||
<source :src="timeStampURL" />
|
||||
</audio>
|
||||
<div
|
||||
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)]"
|
||||
>
|
||||
<div class="flex gap-1 w-full flex-1 items-center justify-start">
|
||||
<button class="p-0 border-0 size-8" @click="playOrPause">
|
||||
<Icon
|
||||
v-if="isPlaying"
|
||||
class="size-8"
|
||||
icon="i-teenyicons-pause-small-solid"
|
||||
/>
|
||||
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
|
||||
</button>
|
||||
<div class="tabular-nums text-xs">
|
||||
{{ 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
|
||||
v-if="attachment.transcribedText && showTranscribedText"
|
||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||
>
|
||||
{{ attachment.transcribedText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@ -96,6 +96,7 @@ const MessageControl = Symbol('MessageControl');
|
||||
* @property {import('vue').Ref<Object|null>} [inReplyTo=null] - The message to which this message is a reply
|
||||
* @property {import('vue').Ref<SenderType>} [senderType=null] - The type of the sender
|
||||
* @property {import('vue').Ref<Sender|null>} [sender=null] - The sender information
|
||||
* @property {import('vue').Ref<Object>} [additionalAttributes={}] - Additional attributes of the message
|
||||
* @property {import('vue').ComputedRef<MessageOrientation>} orientation - The visual variant of the message
|
||||
* @property {import('vue').ComputedRef<MessageVariant>} variant - The visual variant of the message
|
||||
* @property {import('vue').ComputedRef<boolean>} isBotOrAgentMessage - Does the message belong to the current user
|
||||
|
||||
@ -159,6 +159,7 @@ useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const dashboardApps = useMapGetter('dashboardApps/getAppsOnSidebar');
|
||||
const teams = useMapGetter('teams/getMyTeams');
|
||||
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
@ -173,6 +174,7 @@ onMounted(() => {
|
||||
store.dispatch('attributes/get');
|
||||
store.dispatch('customViews/get', 'conversation');
|
||||
store.dispatch('customViews/get', 'contact');
|
||||
store.dispatch('dashboardApps/get');
|
||||
});
|
||||
|
||||
const sortedInboxes = computed(() =>
|
||||
@ -222,7 +224,7 @@ const newReportRoutes = () => [
|
||||
const reportRoutes = computed(() => newReportRoutes());
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
const items = [
|
||||
{
|
||||
name: 'Inbox',
|
||||
label: t('SIDEBAR.INBOX'),
|
||||
@ -315,6 +317,13 @@ const menuItems = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Kanban',
|
||||
label: t('SIDEBAR.KANBAN'),
|
||||
icon: 'i-lucide-columns-3',
|
||||
to: accountScopedRoute('kanban_view'),
|
||||
activeOn: ['kanban_view'],
|
||||
},
|
||||
{
|
||||
name: 'Captain',
|
||||
icon: 'i-woot-captain',
|
||||
@ -721,6 +730,23 @@ const menuItems = computed(() => {
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (dashboardApps.value.length > 0) {
|
||||
const settingsIndex = items.findIndex(item => item.name === 'Settings');
|
||||
items.splice(settingsIndex, 0, {
|
||||
name: 'Apps',
|
||||
label: t('SIDEBAR.APPS'),
|
||||
icon: 'i-lucide-layout-grid',
|
||||
children: dashboardApps.value.map(app => ({
|
||||
name: `app-${app.id}`,
|
||||
label: app.title,
|
||||
to: accountScopedRoute('dashboard_app_view', { appId: app.id }),
|
||||
activeOn: ['dashboard_app_view'],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -95,7 +95,7 @@ const emitNewAccount = () => {
|
||||
</template>
|
||||
<DropdownBody
|
||||
v-if="showAccountSwitcher || isCollapsed"
|
||||
class="min-w-80 z-50"
|
||||
class="min-w-80 z-50 max-h-[80vh] overflow-y-auto"
|
||||
>
|
||||
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
|
||||
<DropdownItem
|
||||
|
||||
@ -158,9 +158,11 @@ const activeChild = computed(() => {
|
||||
return rankedPage ?? activeOnPages[0];
|
||||
}
|
||||
|
||||
return navigableChildren.value.find(
|
||||
child => child.to && route.path.startsWith(resolvePath(child.to))
|
||||
);
|
||||
return navigableChildren.value.find(child => {
|
||||
if (!child.to) return false;
|
||||
const childPath = resolvePath(child.to);
|
||||
return route.path === childPath || route.path.startsWith(childPath + '/');
|
||||
});
|
||||
});
|
||||
|
||||
const hasActiveChild = computed(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user