Files
d1fa8d8c2f refactor: share whatsapp/twilio template logic via @chatwoot/utils (#15001)
# Pull Request Template

## Description
Moves the WhatsApp & Twilio content-template logic to the shared
[`@chatwoot/utils`](https://github.com/chatwoot/utils)
([PR](https://github.com/chatwoot/utils/pull/62)) package so web and
mobile share one implementation. The neutral core takes the raw template
and returns `processed_params`, the same shape the web parsers already
use, so it's a drop-in with no behavior change.

- `templateHelper.js` / `URLHelper.js` → source `MEDIA_FORMATS`,
`findComponentByType`, `processVariable`, `buildTemplateParameters`,
`extractFilenameFromUrl` from the package
- `inboxes.js` → filters with shared `isSendableTemplate`
- `WhatsAppTemplateParser.vue` / `ContentTemplateParser.vue` →
`isFormInvalid` and Twilio media helpers now use the shared
`isWhatsAppComplete` / `isTwilioComplete` / `applyTwilioMediaFilename`

> Depends on the `@chatwoot/utils` release adding the shared template
API, bump `package.json` from `^0.0.55` to the published version before
merge.

Fixes
[CW-7540](https://linear.app/chatwoot/issue/CW-7540/web-templates-integration-with-utils)

## Type of change

- [x] Breaking change (Refactor)

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-21 10:08:23 +04:00

163 lines
4.2 KiB
JavaScript

export const frontendURL = (path, params) => {
const stringifiedParams = params ? `?${new URLSearchParams(params)}` : '';
return `/app/${path}${stringifiedParams}`;
};
export const conversationUrl = ({
accountId,
activeInbox,
id,
label,
teamId,
conversationType = '',
foldersId,
}) => {
let url = `accounts/${accountId}/conversations/${id}`;
if (activeInbox) {
url = `accounts/${accountId}/inbox/${activeInbox}/conversations/${id}`;
} else if (label) {
url = `accounts/${accountId}/label/${label}/conversations/${id}`;
} else if (teamId) {
url = `accounts/${accountId}/team/${teamId}/conversations/${id}`;
} else if (foldersId && foldersId !== 0) {
url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`;
} else if (conversationType === 'mention') {
url = `accounts/${accountId}/mentions/conversations/${id}`;
} else if (conversationType === 'participating') {
url = `accounts/${accountId}/participating/conversations/${id}`;
} else if (conversationType === 'unattended') {
url = `accounts/${accountId}/unattended/conversations/${id}`;
}
return url;
};
export const conversationListPageURL = ({
accountId,
conversationType = '',
inboxId,
label,
teamId,
customViewId,
}) => {
let url = `accounts/${accountId}/dashboard`;
if (label) {
url = `accounts/${accountId}/label/${label}`;
} else if (teamId) {
url = `accounts/${accountId}/team/${teamId}`;
} else if (inboxId) {
url = `accounts/${accountId}/inbox/${inboxId}`;
} else if (customViewId) {
url = `accounts/${accountId}/custom_view/${customViewId}`;
} else if (conversationType) {
const urlMap = {
mention: 'mentions/conversations',
participating: 'participating/conversations',
unattended: 'unattended/conversations',
};
url = `accounts/${accountId}/${urlMap[conversationType]}`;
}
return frontendURL(url);
};
export const isValidURL = value => {
/* eslint-disable no-useless-escape */
const URL_REGEX =
/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/gm;
return URL_REGEX.test(value);
};
export const getArticleSearchURL = ({
host,
portalSlug,
pageNumber,
locale,
status,
authorId,
categorySlug,
sort,
query,
}) => {
const queryParams = new URLSearchParams({});
const params = {
page: pageNumber,
locale,
status,
author_id: authorId,
category_slug: categorySlug,
sort,
query,
};
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
queryParams.set(key, value);
}
});
return `${host}/${portalSlug}/articles?${queryParams.toString()}`;
};
export const hasValidAvatarUrl = avatarUrl => {
try {
const { host: avatarUrlHost } = new URL(avatarUrl);
const isFromGravatar = ['www.gravatar.com', 'gravatar'].includes(
avatarUrlHost
);
return avatarUrl && !isFromGravatar;
} catch (error) {
return false;
}
};
export const timeStampAppendedURL = dataUrl => {
const url = new URL(dataUrl);
if (!url.searchParams.has('t')) {
url.searchParams.append('t', Date.now());
}
return url.toString();
};
export const getHostNameFromURL = url => {
try {
return new URL(url).hostname;
} catch (error) {
return null;
}
};
// Shared with the mobile app via @chatwoot/utils.
export { extractFilenameFromUrl } from '@chatwoot/utils';
/**
* Normalizes a comma/newline separated list of domains
* @param {string} domains - The comma/newline separated list of domains
* @returns {string} - The normalized list of domains
* - Converts newlines to commas
* - Trims whitespace
* - Lowercases entries
* - Removes empty values
* - De-duplicates while preserving original order
*/
export const sanitizeAllowedDomains = domains => {
if (!domains) return '';
const tokens = domains
.replace(/\r\n/g, '\n')
.replace(/\s*\n\s*/g, ',')
.split(',')
.map(d => d.trim().toLowerCase())
.filter(d => d.length > 0);
// De-duplicate while preserving order using Set and filter index
const seen = new Set();
const unique = tokens.filter(d => {
if (seen.has(d)) return false;
seen.add(d);
return true;
});
return unique.join(',');
};