Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,44 @@
export const initOnEvents = ['click', 'touchstart', 'keypress', 'keydown'];
export const getAudioContext = () => {
let audioCtx;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch {
// AudioContext is not available.
}
return audioCtx;
};
// eslint-disable-next-line default-param-last
export const getAlertAudio = async (baseUrl = '', requestContext) => {
const audioCtx = getAudioContext();
const playSound = audioBuffer => {
window.playAudioAlert = () => {
if (audioCtx) {
const source = audioCtx.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioCtx.destination);
source.loop = false;
source.start();
}
};
};
if (audioCtx) {
const { type = 'dashboard', alertTone = 'ding' } = requestContext || {};
const resourceUrl = `${baseUrl}/audio/${type}/${alertTone}.mp3`;
const audioRequest = new Request(resourceUrl);
fetch(audioRequest)
.then(response => response.arrayBuffer())
.then(buffer => {
audioCtx.decodeAudioData(buffer).then(playSound);
// eslint-disable-next-line no-promise-executor-return
return new Promise(res => res());
})
.catch(() => {
// error
});
}
};

View File

@@ -0,0 +1,91 @@
import { createConsumer } from '@rails/actioncable';
const PRESENCE_INTERVAL = 20000;
const RECONNECT_INTERVAL = 1000;
class BaseActionCableConnector {
static isDisconnected = false;
constructor(app, pubsubToken, websocketHost = '') {
const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined;
this.consumer = createConsumer(websocketURL);
this.subscription = this.consumer.subscriptions.create(
{
channel: 'RoomChannel',
pubsub_token: pubsubToken,
account_id: app.$store.getters.getCurrentAccountId,
user_id: app.$store.getters.getCurrentUserID,
},
{
updatePresence() {
this.perform('update_presence');
},
received: this.onReceived,
disconnected: () => {
BaseActionCableConnector.isDisconnected = true;
this.onDisconnected();
this.initReconnectTimer();
},
}
);
this.app = app;
this.events = {};
this.reconnectTimer = null;
this.isAValidEvent = () => true;
this.triggerPresenceInterval = () => {
setTimeout(() => {
this.subscription.updatePresence();
this.triggerPresenceInterval();
}, PRESENCE_INTERVAL);
};
this.triggerPresenceInterval();
}
checkConnection() {
const isConnectionActive = this.consumer.connection.isOpen();
const isReconnected =
BaseActionCableConnector.isDisconnected && isConnectionActive;
if (isReconnected) {
this.clearReconnectTimer();
this.onReconnect();
BaseActionCableConnector.isDisconnected = false;
} else {
this.initReconnectTimer();
}
}
clearReconnectTimer = () => {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
};
initReconnectTimer = () => {
this.clearReconnectTimer();
this.reconnectTimer = setTimeout(() => {
this.checkConnection();
}, RECONNECT_INTERVAL);
};
// eslint-disable-next-line class-methods-use-this
onReconnect = () => {};
// eslint-disable-next-line class-methods-use-this
onDisconnected = () => {};
disconnect() {
this.consumer.disconnect();
}
onReceived = ({ event, data } = {}) => {
if (this.isAValidEvent(data)) {
if (this.events[event] && typeof this.events[event] === 'function') {
this.events[event](data);
}
}
};
}
export default BaseActionCableConnector;

View File

@@ -0,0 +1,15 @@
/* eslint-disable max-classes-per-file */
export class DuplicateContactException extends Error {
constructor(data) {
super('DUPLICATE_CONTACT');
this.data = data;
this.name = 'DuplicateContactException';
}
}
export class ExceptionWithMessage extends Error {
constructor(data) {
super('ERROR_WITH_MESSAGE');
this.data = data;
this.name = 'ExceptionWithMessage';
}
}

View File

@@ -0,0 +1,15 @@
export const createEvent = ({ eventName, data = null }) => {
let event;
if (typeof window.CustomEvent === 'function') {
event = new CustomEvent(eventName, { detail: data });
} else {
event = document.createEvent('CustomEvent');
event.initCustomEvent(eventName, false, false, data);
}
return event;
};
export const dispatchWindowEvent = ({ eventName, data }) => {
const event = createEvent({ eventName, data });
window.dispatchEvent(event);
};

View File

@@ -0,0 +1,43 @@
import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import isToday from 'date-fns/isToday';
import isYesterday from 'date-fns/isYesterday';
import { endOfDay, getUnixTime, startOfDay } from 'date-fns';
export const formatUnixDate = (date, dateFormat = 'MMM dd, yyyy') => {
const unixDate = fromUnixTime(date);
return format(unixDate, dateFormat);
};
export const formatDate = ({ date, todayText, yesterdayText }) => {
const dateValue = new Date(date);
if (isToday(dateValue)) return todayText;
if (isYesterday(dateValue)) return yesterdayText;
return date;
};
export const isTimeAfter = (h1, m1, h2, m2) => {
if (h1 < h2) {
return false;
}
if (h1 === h2) {
return m1 >= m2;
}
return true;
};
/** Get start of day as a UNIX timestamp */
export const getUnixStartOfDay = date => getUnixTime(startOfDay(date));
/** Get end of day as a UNIX timestamp */
export const getUnixEndOfDay = date => getUnixTime(endOfDay(date));
export const generateRelativeTime = (value, unit, languageCode) => {
const code = languageCode?.replace(/_/g, '-'); // Hacky fix we need to handle it from source
const rtf = new Intl.RelativeTimeFormat(code, {
numeric: 'auto',
});
return rtf.format(value, unit);
};

View File

@@ -0,0 +1,89 @@
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
export const DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40;
export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / k ** i).toFixed(dm)) + ' ' + sizes[i];
};
export const fileSizeInMegaBytes = bytes => {
return bytes / (1024 * 1024);
};
export const checkFileSizeLimit = (file, maximumUploadLimit) => {
const fileSize = file?.file?.size || file?.size;
const fileSizeInMB = fileSizeInMegaBytes(fileSize);
return fileSizeInMB <= maximumUploadLimit;
};
export const resolveMaximumFileUploadSize = value => {
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
return DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE;
}
return parsedValue;
};
/**
* Validates if a file type is allowed for a specific channel
* @param {File} file - The file to validate
* @param {Object} options - Validation options
* @param {string} options.channelType - The channel type
* @param {string} options.medium - The channel medium
* @param {string} options.conversationType - The conversation type (for Instagram DM detection)
* @param {boolean} options.isInstagramChannel - Whether it's an Instagram channel
* @param {boolean} options.isOnPrivateNote - Whether composing a private note (uses broader file type list)
* @returns {boolean} - True if file type is allowed, false otherwise
*/
export const isFileTypeAllowedForChannel = (file, options = {}) => {
if (!file || file.size === 0) return false;
const {
channelType: originalChannelType,
medium,
conversationType,
isInstagramChannel,
isOnPrivateNote,
} = options;
// Use broader file types for private notes (matches file picker behavior)
const allowedFileTypes = isOnPrivateNote
? ALLOWED_FILE_TYPES
: getAllowedFileTypesByChannel({
channelType:
isInstagramChannel || conversationType === 'instagram_direct_message'
? INBOX_TYPES.INSTAGRAM
: originalChannelType,
medium,
});
// Convert to array and validate
const allowedTypesArray = allowedFileTypes.split(',').map(t => t.trim());
const fileExtension = `.${file.name.split('.').pop()}`;
return allowedTypesArray.some(allowedType => {
// Check for exact file extension match
if (allowedType === fileExtension) return true;
// Check for wildcard MIME type (e.g., image/*)
if (allowedType.endsWith('/*')) {
const prefix = allowedType.slice(0, -2); // Remove '/*'
return file.type.startsWith(prefix + '/');
}
// Check for exact MIME type match
return allowedType === file.type;
});
};

View File

@@ -0,0 +1,20 @@
export const escapeHtml = (unsafe = '') => {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
export const afterSanitizeAttributes = currentNode => {
if ('target' in currentNode) {
currentNode.setAttribute('target', '_blank');
}
};
export const domPurifyConfig = {
hooks: {
afterSanitizeAttributes,
},
};

View File

@@ -0,0 +1,5 @@
const DYTE_MEETING_LINK = 'https://app.dyte.io/v2/meeting';
export const buildDyteURL = dyteAuthToken => {
return `${DYTE_MEETING_LINK}?authToken=${dyteAuthToken}&showSetupScreen=true&disableVideoBackground=true`;
};

View File

@@ -0,0 +1,58 @@
export const isEnter = e => {
return e.key === 'Enter';
};
export const isEscape = e => {
return e.key === 'Escape';
};
export const hasPressedShift = e => {
return e.shiftKey;
};
export const hasPressedCommand = e => {
return e.metaKey;
};
export const hasPressedEnterAndNotCmdOrShift = e => {
return isEnter(e) && !hasPressedCommand(e) && !hasPressedShift(e);
};
export const hasPressedCommandAndEnter = e => {
return hasPressedCommand(e) && isEnter(e);
};
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
// https://github.com/chatwoot/chatwoot/issues/9492
export const keysToModifyInQWERTZ = new Set(['Alt+KeyP', 'Alt+KeyL']);
export const LAYOUT_QWERTY = 'QWERTY';
export const LAYOUT_QWERTZ = 'QWERTZ';
export const LAYOUT_AZERTY = 'AZERTY';
/**
* Determines whether the active element is typeable.
*
* @param {KeyboardEvent} e - The keyboard event object.
* @returns {boolean} `true` if the active element is typeable, `false` otherwise.
*
* @example
* document.addEventListener('keydown', e => {
* if (isActiveElementTypeable(e)) {
* handleTypeableElement(e);
* }
* });
*/
export const isActiveElementTypeable = e => {
/** @type {HTMLElement | null} */
// @ts-ignore
const activeElement = e.target || document.activeElement;
return !!(
activeElement?.tagName === 'INPUT' ||
activeElement?.tagName === 'NINJA-KEYS' ||
activeElement?.tagName === 'TEXTAREA' ||
activeElement?.contentEditable === 'true' ||
activeElement?.className?.includes('ProseMirror')
);
};

View File

@@ -0,0 +1,103 @@
import mila from 'markdown-it-link-attributes';
import mentionPlugin from './markdownIt/link';
import MarkdownIt from 'markdown-it';
const setImageHeight = inlineToken => {
const imgSrc = inlineToken.attrGet('src');
if (!imgSrc) return;
const url = new URL(imgSrc);
const height = url.searchParams.get('cw_image_height');
if (!height) return;
inlineToken.attrSet('style', `height: ${height};`);
};
const processInlineToken = blockToken => {
blockToken.children.forEach(inlineToken => {
if (inlineToken.type === 'image') {
setImageHeight(inlineToken);
}
});
};
const imgResizeManager = md => {
// Custom rule for image resize in markdown
// If the image url has a query param cw_image_height, then add a style attribute to the image
md.core.ruler.after('inline', 'add-image-height', state => {
state.tokens.forEach(blockToken => {
if (blockToken.type === 'inline') {
processInlineToken(blockToken);
}
});
});
};
const createMarkdownInstance = (linkify = true) => {
return MarkdownIt({
html: false,
xhtmlOut: true,
breaks: true,
langPrefix: 'language-',
linkify,
typographer: true,
quotes: '\u201c\u201d\u2018\u2019',
maxNesting: 20,
})
.use(mentionPlugin)
.use(imgResizeManager)
.use(mila, {
attrs: {
class: 'link',
rel: 'noreferrer noopener nofollow',
target: '_blank',
},
});
};
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
const TWITTER_HASH_REPLACEMENT = '$1[#$2](https://twitter.com/hashtag/$2)';
class MessageFormatter {
constructor(
message,
isATweet = false,
isAPrivateNote = false,
linkify = true
) {
this.message = message || '';
this.isAPrivateNote = isAPrivateNote;
this.isATweet = isATweet;
this.linkify = linkify;
this.md = createMarkdownInstance(linkify);
}
formatMessage() {
let updatedMessage = this.message;
if (this.isATweet && !this.isAPrivateNote) {
updatedMessage = updatedMessage.replace(
TWITTER_USERNAME_REGEX,
TWITTER_USERNAME_REPLACEMENT
);
updatedMessage = updatedMessage.replace(
TWITTER_HASH_REGEX,
TWITTER_HASH_REPLACEMENT
);
}
return this.md.render(updatedMessage);
}
get formattedMessage() {
return this.formatMessage();
}
get plainText() {
const strippedOutHtml = new DOMParser().parseFromString(
this.formattedMessage,
'text/html'
);
return strippedOutHtml.body.textContent || '';
}
}
export default MessageFormatter;

View File

@@ -0,0 +1,25 @@
export const isAFormMessage = message => message.content_type === 'form';
export const isASubmittedFormMessage = (message = {}) =>
isAFormMessage(message) && !!message.content_attributes?.submitted_values;
export const MESSAGE_MAX_LENGTH = {
GENERAL: 10000,
// https://developers.facebook.com/docs/messenger-platform/reference/send-api#request
FACEBOOK: 2000,
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#send-a-text-message
INSTAGRAM: 1000,
// https://business-api.tiktok.com/portal/docs?id=1832184403754242
TIKTOK: 6000,
// https://www.twilio.com/docs/glossary/what-sms-character-limit
TWILIO_SMS: 320,
// https://help.twilio.com/articles/360033806753-Maximum-Message-Length-with-Twilio-Programmable-Messaging
TWILIO_WHATSAPP: 1600,
// https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#text-object
WHATSAPP_CLOUD: 4096,
// https://support.bandwidth.com/hc/en-us/articles/360010235373-What-are-Bandwidth-s-SMS-character-limits-and-concatenation-practices
BANDWIDTH_SMS: 160,
// https://core.telegram.org/bots/api#sendmessage
TELEGRAM: 4096,
LINE: 2000,
EMAIL: 25000,
};

View File

@@ -0,0 +1,110 @@
import {
fromUnixTime,
startOfDay,
endOfDay,
getUnixTime,
subDays,
} from 'date-fns';
/**
* Returns a key-value pair of timestamp and value for heatmap data
*
* @param {Array} data - An array of objects containing timestamp and value
* @returns {Object} - An object with timestamp as keys and corresponding values as values
*/
export const flattenHeatmapData = data => {
return data.reduce((acc, curr) => {
acc[curr.timestamp] = curr.value;
return acc;
}, {});
};
/**
* Filter the given array to remove data outside the timeline
*
* @param {Array} data - An array of objects containing timestamp and value
* @param {number} from - Unix timestamp
* @param {number} to - Unix timestamp
* @returns {Array} - An array of objects containing timestamp and value
*/
export const clampDataBetweenTimeline = (data, from, to) => {
if (from === undefined && to === undefined) {
return data;
}
return data.filter(el => {
const { timestamp } = el;
const isWithinFrom = from === undefined || timestamp - from >= 0;
const isWithinTo = to === undefined || to - timestamp > 0;
return isWithinFrom && isWithinTo;
});
};
/**
* Generates an array of objects with timestamp and value as 0 for the last 7 days
*
* @returns {Array} - An array of objects containing timestamp and value
*/
export const generateEmptyHeatmapData = () => {
const data = [];
const today = new Date();
let timeMarker = getUnixTime(startOfDay(subDays(today, 6)));
let endOfToday = getUnixTime(endOfDay(today));
const oneHour = 3600;
while (timeMarker <= endOfToday) {
data.push({ value: 0, timestamp: timeMarker });
timeMarker += oneHour;
}
return data;
};
/**
* Reconciles new data with existing heatmap data based on timestamps
*
* @param {Array} data - An array of objects containing timestamp and value
* @param {Array} heatmapData - An array of objects containing timestamp, value and other properties
* @returns {Array} - An array of objects with updated values
*/
export const reconcileHeatmapData = (data, dataFromStore) => {
const parsedData = flattenHeatmapData(data);
// make a copy of the data from store
const heatmapData = dataFromStore.length
? dataFromStore
: generateEmptyHeatmapData();
return heatmapData.map(dataItem => {
if (parsedData[dataItem.timestamp]) {
dataItem.value = parsedData[dataItem.timestamp];
}
return dataItem;
});
};
/**
* Groups heatmap data by day
*
* @param {Array} heatmapData - An array of objects containing timestamp, value and other properties
* @returns {Map} - A Map object with dates as keys and corresponding data objects as values
*/
export const groupHeatmapByDay = heatmapData => {
return heatmapData.reduce((acc, data) => {
const date = fromUnixTime(data.timestamp);
const mapKey = startOfDay(date).toISOString();
const dataToAppend = {
...data,
date: fromUnixTime(data.timestamp),
hour: date.getHours(),
};
if (!acc.has(mapKey)) {
acc.set(mapKey, []);
}
acc.get(mapKey).push(dataToAppend);
return acc;
}, new Map());
};

View File

@@ -0,0 +1,109 @@
/**
* Checks if a string is a valid E.164 phone number format.
* @param {string} value - The phone number to validate.
* @returns {boolean} True if the number is in E.164 format, false otherwise.
*/
export const isPhoneE164 = value => !!value.match(/^\+[1-9]\d{1,14}$/);
/**
* Validates a phone number after removing the dial code.
* @param {string} value - The full phone number including dial code.
* @param {string} dialCode - The dial code to remove before validation.
* @returns {boolean} True if the number (without dial code) is valid, false otherwise.
*/
export const isPhoneNumberValid = (value, dialCode) => {
const number = value.replace(dialCode, '');
return !!number.match(/^[0-9]{1,14}$/);
};
/**
* Checks if a string is either a valid E.164 phone number or empty.
* @param {string} value - The phone number to validate.
* @returns {boolean} True if the number is in E.164 format or empty, false otherwise.
*/
export const isPhoneE164OrEmpty = value => isPhoneE164(value) || value === '';
/**
* Validates a phone number with dial code, requiring at least 5 digits.
* @param {string} value - The full phone number including dial code.
* @returns {boolean} True if the number is valid, false otherwise.
*/
export const isPhoneNumberValidWithDialCode = value => {
const number = value.replace(/^\+/, ''); // Remove the '+' sign
return !!number.match(/^[1-9]\d{4,}$/); // Validate the phone number with minimum 5 digits
};
/**
* Checks if a string starts with a plus sign.
* @param {string} value - The string to check.
* @returns {boolean} True if the string starts with '+', false otherwise.
*/
export const startsWithPlus = value => value.startsWith('+');
/**
* Checks if a string is a valid URL (starts with 'http') or is empty.
* @param {string} [value=''] - The string to check.
* @returns {boolean} True if the string is a valid URL or empty, false otherwise.
*/
export const shouldBeUrl = (value = '') =>
value ? value.startsWith('http') : true;
/**
* Validates a password for complexity requirements.
* @param {string} value - The password to validate.
* @returns {boolean} True if the password meets all requirements, false otherwise.
*/
export const isValidPassword = value => {
const containsUppercase = /[A-Z]/.test(value);
const containsLowercase = /[a-z]/.test(value);
const containsNumber = /[0-9]/.test(value);
const containsSpecialCharacter = /[!@#$%^&*()_+\-=[\]{}|'"/\\.,`<>:;?~]/.test(
value
);
return (
containsUppercase &&
containsLowercase &&
containsNumber &&
containsSpecialCharacter
);
};
/**
* Checks if a string consists only of digits.
* @param {string} value - The string to check.
* @returns {boolean} True if the string contains only digits, false otherwise.
*/
export const isNumber = value => /^\d+$/.test(value);
/**
* Validates a domain name.
* @param {string} value - The domain name to validate.
* @returns {boolean} True if the domain is valid or empty, false otherwise.
*/
export const isDomain = value => {
if (value !== '') {
const domainRegex = /^([\p{L}0-9]+(-[\p{L}0-9]+)*\.)+[a-z]{2,}$/gmu;
return domainRegex.test(value);
}
return true;
};
/**
* Creates a RegExp object from a string representation of a regular expression.
* @param {string} regexPatternValue - The string representation of the regex (e.g., '/pattern/flags').
* @returns {RegExp} A RegExp object created from the input string.
*/
export const getRegexp = regexPatternValue => {
let lastSlash = regexPatternValue.lastIndexOf('/');
return new RegExp(
regexPatternValue.slice(1, lastSlash),
regexPatternValue.slice(lastSlash + 1)
);
};
/**
* Checks if a string is a valid slug (letters, numbers, hyphens only, no spaces or other symbols).
* @param {string} value - The slug to validate.
* @returns {boolean} True if the slug is valid, false otherwise.
*/
export const isValidSlug = value => /^[a-zA-Z0-9-]+$/.test(value);

View File

@@ -0,0 +1,43 @@
import { LocalStorage } from './localStorage';
// Default cache expiry is 24 hours
const DEFAULT_EXPIRY = 24 * 60 * 60 * 1000;
export const getFromCache = (key, expiry = DEFAULT_EXPIRY) => {
try {
const cached = LocalStorage.get(key);
if (!cached) return null;
const { data, timestamp } = cached;
const isExpired = Date.now() - timestamp > expiry;
if (isExpired) {
LocalStorage.remove(key);
return null;
}
return data;
} catch (error) {
return null;
}
};
export const setCache = (key, data) => {
try {
const cacheData = {
data,
timestamp: Date.now(),
};
LocalStorage.set(key, cacheData);
} catch (error) {
// Ignore cache errors
}
};
export const clearCache = key => {
try {
LocalStorage.remove(key);
} catch (error) {
// Ignore cache errors
}
};

View File

@@ -0,0 +1,38 @@
/**
* Writes a text string to the system clipboard.
*
* @async
* @param {string} data text to be written to the clipboard
* @throws {Error} unable to copy text to clipboard
*/
export const copyTextToClipboard = async data => {
try {
const text =
typeof data === 'object' && data !== null
? JSON.stringify(data, null, 2)
: String(data ?? '');
await navigator.clipboard.writeText(text);
} catch (error) {
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
}
};
/**
* Handles OTP paste events by extracting numeric digits from clipboard data.
*
* @param {ClipboardEvent} event - The paste event from the clipboard
* @param {number} maxLength - Maximum number of digits to extract (default: 6)
* @returns {string|null} - Extracted numeric string or null if invalid
*/
export const handleOtpPaste = (event, maxLength = 6) => {
if (!event?.clipboardData) return null;
const pastedData = event.clipboardData
.getData('text')
.replace(/\D/g, '') // Remove all non-digit characters
.slice(0, maxLength); // Limit to maxLength digits
// Only return if we have the exact expected length
return pastedData.length === maxLength ? pastedData : null;
};

View File

@@ -0,0 +1,28 @@
import { toHex, mix, getLuminance, getContrast } from 'color2k';
export const isWidgetColorLighter = color => {
const colorToCheck = color.replace('#', '');
const c_r = parseInt(colorToCheck.substr(0, 2), 16);
const c_g = parseInt(colorToCheck.substr(2, 2), 16);
const c_b = parseInt(colorToCheck.substr(4, 2), 16);
const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000;
return brightness > 225;
};
export const adjustColorForContrast = (color, backgroundColor) => {
const targetRatio = 3.1;
const MAX_ITERATIONS = 20;
let adjustedColor = color;
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration += 1) {
const currentRatio = getContrast(adjustedColor, backgroundColor);
if (currentRatio >= targetRatio) {
break;
}
const adjustmentDirection =
getLuminance(adjustedColor) < 0.5 ? '#fff' : '#151718';
adjustedColor = mix(adjustedColor, adjustmentDirection, 0.05);
}
return toHex(adjustedColor);
};

View File

@@ -0,0 +1,38 @@
/**
* Document Helper - utilities for document display and formatting
*/
// Constants for document processing
const PDF_PREFIX = 'PDF:';
const TIMESTAMP_PATTERN = /_\d{14}(?=\.pdf$)/; // Format: _YYYYMMDDHHMMSS before .pdf extension
/**
* Checks if a document is a PDF based on its external link
* @param {string} externalLink - The external link string
* @returns {boolean} True if the document is a PDF
*/
export const isPdfDocument = externalLink => {
if (!externalLink) return false;
return externalLink.startsWith(PDF_PREFIX);
};
/**
* Formats the display link for documents
* For PDF documents: removes 'PDF:' prefix and timestamp suffix
* For regular URLs: returns as-is
*
* @param {string} externalLink - The external link string
* @returns {string} Formatted display link
*/
export const formatDocumentLink = externalLink => {
if (!externalLink) return '';
if (isPdfDocument(externalLink)) {
// Remove 'PDF:' prefix
const fullName = externalLink.substring(PDF_PREFIX.length);
// Remove timestamp suffix if present
return fullName.replace(TIMESTAMP_PATTERN, '');
}
return externalLink;
};

View File

@@ -0,0 +1,47 @@
/**
* Detects support for emoji character sets.
*
* Based on the Modernizr emoji detection.
* https://github.com/Modernizr/Modernizr/blob/347ddb078116cee91b25b6e897e211b023f9dcb4/feature-detects/emoji.js
*
* @return {Boolean} true or false
* @example
*
* hasEmojiSupport()
* // => true|false
*/
export const hasEmojiSupport = () => {
const pixelRatio = window.devicePixelRatio || 1;
const offset = 12 * pixelRatio;
const node = document.createElement('canvas');
// canvastext support
if (
!node.getContext ||
!node.getContext('2d') ||
typeof node.getContext('2d').fillText !== 'function'
) {
return false;
}
const ctx = node.getContext('2d');
ctx.fillStyle = '#f00';
ctx.textBaseline = 'top';
ctx.font = '32px Arial';
ctx.fillText('\ud83d\udc28', 0, 0); // U+1F428 KOALA
return ctx.getImageData(offset, offset, 1, 1).data[0] !== 0;
};
export const removeEmoji = text => {
if (text) {
return text
.replace(
/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
''
)
.replace(/\s+/g, ' ')
.trim();
}
return '';
};

View File

@@ -0,0 +1,84 @@
export const LocalStorage = {
clearAll() {
window.localStorage.clear();
},
get(key) {
let value = null;
try {
value = window.localStorage.getItem(key);
return typeof value === 'string' ? JSON.parse(value) : value;
} catch (error) {
return value;
}
},
set(key, value) {
if (typeof value === 'object') {
window.localStorage.setItem(key, JSON.stringify(value));
} else {
window.localStorage.setItem(key, value);
}
window.localStorage.setItem(key + ':ts', Date.now().toString());
},
setFlag(store, accountId, key, expiry = 24 * 60 * 60 * 1000) {
const storeName = accountId ? `${store}::${accountId}` : store;
const rawValue = window.localStorage.getItem(storeName);
const parsedValue = rawValue ? JSON.parse(rawValue) : {};
parsedValue[key] = Date.now() + expiry;
window.localStorage.setItem(storeName, JSON.stringify(parsedValue));
},
getFlag(store, accountId, key) {
const storeName = store ? `${store}::${accountId}` : store;
const rawValue = window.localStorage.getItem(storeName);
const parsedValue = rawValue ? JSON.parse(rawValue) : {};
return parsedValue[key] && parsedValue[key] > Date.now();
},
remove(key) {
window.localStorage.removeItem(key);
window.localStorage.removeItem(key + ':ts');
},
updateJsonStore(storeName, key, value) {
try {
const storedValue = this.get(storeName) || {};
storedValue[key] = value;
this.set(storeName, storedValue);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error updating JSON store in localStorage', e);
}
},
getFromJsonStore(storeName, key) {
try {
const storedValue = this.get(storeName) || {};
return storedValue[key] || null;
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error getting value from JSON store in localStorage', e);
return null;
}
},
deleteFromJsonStore(storeName, key) {
try {
const storedValue = this.get(storeName);
if (storedValue && key in storedValue) {
delete storedValue[key];
this.set(storeName, storedValue);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error deleting entry from JSON store in localStorage', e);
}
},
};

View File

@@ -0,0 +1,69 @@
// Process [@mention](mention://user/1/Pranav)
const USER_MENTIONS_REGEX = /mention:\/\/(user|team)\/(\d+)\/(.+)/gm;
const buildMentionTokens = () => (state, silent) => {
var label;
var labelEnd;
var labelStart;
var pos;
var res;
var token;
var href = '';
var max = state.posMax;
if (state.src.charCodeAt(state.pos) !== 0x5b /* [ */) {
return false;
}
labelStart = state.pos + 1;
labelEnd = state.md.helpers.parseLinkLabel(state, state.pos, true);
// parser failed to find ']', so it's not a valid link
if (labelEnd < 0) {
return false;
}
label = state.src.slice(labelStart, labelEnd);
pos = labelEnd + 1;
if (pos < max && state.src.charCodeAt(pos) === 0x28 /* ( */) {
pos += 1;
res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax);
if (res.ok) {
href = state.md.normalizeLink(res.str);
if (state.md.validateLink(href)) {
pos = res.pos;
} else {
href = '';
}
}
pos += 1;
}
if (!href.match(new RegExp(USER_MENTIONS_REGEX))) {
return false;
}
if (!silent) {
state.pos = labelStart;
state.posMax = labelEnd;
token = state.push('mention', '');
token.href = href;
token.content = label;
}
state.pos = pos;
state.posMax = max;
return true;
};
const renderMentions = () => (tokens, idx) => {
return `<span class="prosemirror-mention-node">${tokens[idx].content}</span>`;
};
export default function mentionPlugin(md) {
md.renderer.rules.mention = renderMentions(md);
md.inline.ruler.before('link', 'mention', buildMentionTokens(md));
}

View File

@@ -0,0 +1,3 @@
import mitt from 'mitt';
const emitter = mitt();
export { emitter };

View File

@@ -0,0 +1,40 @@
/**
* Determine the best-matching locale from the list of locales allowed by the portal.
*
* The matching happens in the following order:
* 1. Exact match the visitor-selected locale equals one in the `allowedLocales` list
* (e.g., `fr` ➜ `fr`).
* 2. Base language match the base part of a compound locale (before the underscore)
* matches (e.g., `fr_CA` ➜ `fr`).
* 3. Variant match when the base language is selected but a regional variant exists
* in the portal list (e.g., `fr` ➜ `fr_BE`).
*
* If none of these rules find a match, the function returns `null`,
* Don't show popular articles if locale doesn't match with allowed locales
*
* @export
* @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
* @param {string[]} allowedLocales Array of locales enabled for the portal.
* @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
*/
export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
// Ensure inputs are valid
if (
!selectedLocale ||
!Array.isArray(allowedLocales) ||
!allowedLocales.length
) {
return null;
}
const [lang] = selectedLocale.split('_');
const priorityMatches = [
selectedLocale, // exact match
lang, // base language match
allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
];
// Return the first match that exists in the allowed list, or null
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
};

View File

@@ -0,0 +1,22 @@
export const labelSanitizePattern = /[^a-zA-Z0-9_-]/g;
export const spacesPattern = /\s+/g;
/**
* Sanitizes a label by removing unwanted characters and replacing spaces with hyphens.
*
* @param {string | undefined | null} label - The label to sanitize.
* @returns {string} The sanitized label.
*
* @example
* const label = 'My Label 123';
* const sanitizedLabel = sanitizeLabel(label); // 'my-label-123'
*/
export const sanitizeLabel = (label = '') => {
if (!label) return '';
return label
.trim()
.toLowerCase()
.replace(spacesPattern, '-')
.replace(labelSanitizePattern, '');
};

View File

@@ -0,0 +1,26 @@
export default {
clearAll() {
window.sessionStorage.clear();
},
get(key) {
try {
const value = window.sessionStorage.getItem(key);
return value ? JSON.parse(value) : null;
} catch (error) {
return window.sessionStorage.getItem(key);
}
},
set(key, value) {
if (typeof value === 'object') {
window.sessionStorage.setItem(key, JSON.stringify(value));
} else {
window.sessionStorage.setItem(key, value);
}
},
remove(key) {
window.sessionStorage.removeItem(key);
},
};

View File

@@ -0,0 +1,13 @@
import { DuplicateContactException } from '../CustomErrors';
describe('DuplicateContactException', () => {
it('returns correct exception', () => {
const exception = new DuplicateContactException({
attributes: ['email'],
});
expect(exception.message).toEqual('DUPLICATE_CONTACT');
expect(exception.data).toEqual({
attributes: ['email'],
});
});
});

View File

@@ -0,0 +1,9 @@
import { dispatchWindowEvent } from '../CustomEventHelper';
describe('dispatchWindowEvent', () => {
it('dispatches correct event', () => {
window.dispatchEvent = vi.fn();
dispatchWindowEvent({ eventName: 'chatwoot:ready' });
expect(dispatchEvent).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,113 @@
import {
formatDate,
formatUnixDate,
isTimeAfter,
generateRelativeTime,
} from '../DateHelper';
describe('#DateHelper', () => {
it('should format unix date correctly without dateFormat', () => {
expect(formatUnixDate(1576340626)).toEqual('Dec 14, 2019');
});
it('should format unix date correctly without dateFormat', () => {
expect(formatUnixDate(1608214031, 'MM/dd/yyyy')).toEqual('12/17/2020');
});
it('should format date', () => {
expect(
formatDate({
date: 'Dec 14, 2019',
todayText: 'Today',
yesterdayText: 'Yesterday',
})
).toEqual('Dec 14, 2019');
});
it('should format date as today ', () => {
expect(
formatDate({
date: new Date(),
todayText: 'Today',
yesterdayText: 'Yesterday',
})
).toEqual('Today');
});
it('should format date as yesterday ', () => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
expect(
formatDate({
date: yesterday,
todayText: 'Today',
yesterdayText: 'Yesterday',
})
).toEqual('Yesterday');
});
});
describe('#isTimeAfter', () => {
it('return correct values', () => {
expect(isTimeAfter(5, 30, 9, 30)).toEqual(false);
expect(isTimeAfter(9, 30, 9, 30)).toEqual(true);
expect(isTimeAfter(9, 29, 9, 30)).toEqual(false);
expect(isTimeAfter(11, 59, 12, 0)).toEqual(false);
});
});
describe('generateRelativeTime', () => {
it('should return a string with the relative time', () => {
const value = 1;
const unit = 'second';
const languageCode = 'en-US';
const expectedResult = 'in 1 second';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should return a string with the relative time in a different language', () => {
const value = 10;
const unit = 'minute';
const languageCode = 'de-DE';
const expectedResult = 'in 10 Minuten';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should return a string with the relative time for a different unit', () => {
const value = 1;
const unit = 'hour';
const languageCode = 'en-US';
const expectedResult = 'in 1 hour';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should throw an error if the value is not a number', () => {
const value = 1;
const unit = 'day';
const languageCode = 'en_US';
const expectedResult = 'tomorrow';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should throw an error if the value is not a number', () => {
const value = 1;
const unit = 'day';
const languageCode = 'en-US';
const expectedResult = 'tomorrow';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
});

View File

@@ -0,0 +1,7 @@
import { removeEmoji } from '../emoji';
describe('#removeEmoji', () => {
it('returns values without emoji', () => {
expect(removeEmoji('😄Hi👋🏻 there❕')).toEqual('Hi there');
});
});

View File

@@ -0,0 +1,248 @@
import {
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE,
formatBytes,
fileSizeInMegaBytes,
checkFileSizeLimit,
resolveMaximumFileUploadSize,
isFileTypeAllowedForChannel,
} from '../FileHelper';
describe('#File Helpers', () => {
describe('formatBytes', () => {
it('should return zero bytes if 0 is passed', () => {
expect(formatBytes(0)).toBe('0 Bytes');
});
it('should return in bytes if 1000 is passed', () => {
expect(formatBytes(1000)).toBe('1000 Bytes');
});
it('should return in KB if 100000 is passed', () => {
expect(formatBytes(10000)).toBe('9.77 KB');
});
it('should return in MB if 10000000 is passed', () => {
expect(formatBytes(10000000)).toBe('9.54 MB');
});
});
describe('fileSizeInMegaBytes', () => {
it('should return zero if 0 is passed', () => {
expect(fileSizeInMegaBytes(0)).toBe(0);
});
it('should return 19.07 if 20000000 is passed', () => {
expect(fileSizeInMegaBytes(20000000)).toBeCloseTo(19.07, 2);
});
});
describe('checkFileSizeLimit', () => {
it('should return false if file with size 62208194 and file size limit 40 are passed', () => {
expect(checkFileSizeLimit({ file: { size: 62208194 } }, 40)).toBe(false);
});
it('should return true if file with size 62208194 and file size limit 40 are passed', () => {
expect(checkFileSizeLimit({ file: { size: 199154 } }, 40)).toBe(true);
});
});
describe('resolveMaximumFileUploadSize', () => {
it('should return default when value is undefined', () => {
expect(resolveMaximumFileUploadSize(undefined)).toBe(
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE
);
});
it('should return default when value is not a positive number', () => {
expect(resolveMaximumFileUploadSize('not-a-number')).toBe(
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE
);
expect(resolveMaximumFileUploadSize(-5)).toBe(
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE
);
});
it('should parse numeric strings and numbers', () => {
expect(resolveMaximumFileUploadSize('50')).toBe(50);
expect(resolveMaximumFileUploadSize(75)).toBe(75);
});
});
describe('isFileTypeAllowedForChannel', () => {
describe('edge cases', () => {
it('should return false for null file', () => {
expect(isFileTypeAllowedForChannel(null)).toBe(false);
});
it('should return false for undefined file', () => {
expect(isFileTypeAllowedForChannel(undefined)).toBe(false);
});
it('should return false for file with zero size', () => {
const file = { name: 'test.png', type: 'image/png', size: 0 };
expect(isFileTypeAllowedForChannel(file)).toBe(false);
});
});
describe('wildcard MIME types', () => {
it('should allow image/png when image/* is allowed', () => {
const file = { name: 'test.png', type: 'image/png', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
it('should allow image/jpeg when image/* is allowed', () => {
const file = { name: 'test.jpg', type: 'image/jpeg', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
it('should allow audio/mp3 when audio/* is allowed', () => {
const file = { name: 'test.mp3', type: 'audio/mp3', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
it('should allow video/mp4 when video/* is allowed', () => {
const file = { name: 'test.mp4', type: 'video/mp4', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
});
describe('exact MIME types', () => {
it('should allow application/pdf when explicitly allowed', () => {
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
it('should allow text/plain when explicitly allowed', () => {
const file = { name: 'test.txt', type: 'text/plain', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
});
describe('file extensions', () => {
it('should allow .3gpp extension when explicitly allowed', () => {
const file = { name: 'test.3gpp', type: '', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
});
describe('Instagram special handling', () => {
it('should use Instagram rules when isInstagramChannel is true', () => {
const file = { name: 'test.png', type: 'image/png', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
isInstagramChannel: true,
})
).toBe(true);
});
it('should use Instagram rules when conversationType is instagram_direct_message', () => {
const file = { name: 'test.png', type: 'image/png', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
conversationType: 'instagram_direct_message',
})
).toBe(true);
});
});
describe('disallowed file types', () => {
it('should reject executable files', () => {
const file = {
name: 'malware.exe',
type: 'application/x-msdownload',
size: 1000,
};
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(false);
});
it('should reject unsupported file types', () => {
const file = {
name: 'test.xyz',
type: 'application/x-unknown',
size: 1000,
};
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(false);
});
});
describe('channel-specific rules', () => {
it('should allow WhatsApp-specific file types', () => {
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::Whatsapp',
})
).toBe(true);
});
it('should allow Twilio WhatsApp-specific file types', () => {
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::TwilioSms',
medium: 'whatsapp',
})
).toBe(true);
});
});
describe('private note file types', () => {
it('should allow broader file types for private notes', () => {
const file = {
name: 'test.pdf',
type: 'application/pdf',
size: 1000,
};
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::Line',
isOnPrivateNote: true,
})
).toBe(true);
});
it('should allow CSV files in private notes', () => {
const file = { name: 'data.csv', type: 'text/csv', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::Line',
isOnPrivateNote: true,
})
).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,67 @@
import {
isEnter,
isEscape,
hasPressedShift,
hasPressedCommand,
isActiveElementTypeable,
} from '../KeyboardHelpers';
describe('#KeyboardHelpers', () => {
describe('#isEnter', () => {
it('return correct values', () => {
expect(isEnter({ key: 'Enter' })).toEqual(true);
});
});
describe('#isEscape', () => {
it('return correct values', () => {
expect(isEscape({ key: 'Escape' })).toEqual(true);
});
});
describe('#hasPressedShift', () => {
it('return correct values', () => {
expect(hasPressedShift({ shiftKey: true })).toEqual(true);
});
});
describe('#hasPressedCommand', () => {
it('return correct values', () => {
expect(hasPressedCommand({ metaKey: true })).toEqual(true);
});
});
});
describe('isActiveElementTypeable', () => {
it('should return true if the active element is an input element', () => {
const event = { target: document.createElement('input') };
const result = isActiveElementTypeable(event);
expect(result).toBe(true);
});
it('should return true if the active element is a textarea element', () => {
const event = { target: document.createElement('textarea') };
const result = isActiveElementTypeable(event);
expect(result).toBe(true);
});
it('should return true if the active element is a contentEditable element', () => {
const element = document.createElement('div');
element.contentEditable = 'true';
const event = { target: element };
const result = isActiveElementTypeable(event);
expect(result).toBe(true);
});
it('should return false if the active element is not typeable', () => {
const event = { target: document.createElement('div') };
const result = isActiveElementTypeable(event);
expect(result).toBe(false);
});
it('should return false if the active element is null', () => {
const event = { target: null };
const result = isActiveElementTypeable(event);
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,132 @@
import MessageFormatter from '../MessageFormatter';
describe('#MessageFormatter', () => {
describe('content with links', () => {
it('should format correctly', () => {
const message =
'Chatwoot is an opensource tool. [Chatwoot](https://www.chatwoot.com)';
expect(new MessageFormatter(message).formattedMessage).toMatch(
'<p>Chatwoot is an opensource tool. <a href="https://www.chatwoot.com" class="link" rel="noreferrer noopener nofollow" target="_blank">Chatwoot</a></p>'
);
});
it('should format correctly', () => {
const message =
'Chatwoot is an opensource tool. https://www.chatwoot.com';
expect(new MessageFormatter(message).formattedMessage).toMatch(
'<p>Chatwoot is an opensource tool. <a href="https://www.chatwoot.com" class="link" rel="noreferrer noopener nofollow" target="_blank">https://www.chatwoot.com</a></p>'
);
});
it('should not convert template variables to links when linkify is disabled', () => {
const message = 'Hey {{customer.name}}, check https://chatwoot.com';
const formatter = new MessageFormatter(message, false, false, false);
expect(formatter.formattedMessage).toMatch(
'<p>Hey {{customer.name}}, check https://chatwoot.com</p>'
);
});
});
describe('parses heading to strong', () => {
it('should format correctly', () => {
const message = '### opensource \n ## tool';
expect(new MessageFormatter(message).formattedMessage).toMatch(
`<h3>opensource</h3>
<h2>tool</h2>`
);
});
});
describe('content with image and has "cw_image_height" query at the end of URL', () => {
it('should set image height correctly', () => {
const message =
'Chatwoot is an opensource tool. ![](http://chatwoot.com/chatwoot.png?cw_image_height=24px)';
expect(new MessageFormatter(message).formattedMessage).toMatch(
'<p>Chatwoot is an opensource tool. <img src="http://chatwoot.com/chatwoot.png?cw_image_height=24px" alt="" style="height: 24px;" /></p>'
);
});
it('should set image height correctly if its original size', () => {
const message =
'Chatwoot is an opensource tool. ![](http://chatwoot.com/chatwoot.png?cw_image_height=auto)';
expect(new MessageFormatter(message).formattedMessage).toMatch(
'<p>Chatwoot is an opensource tool. <img src="http://chatwoot.com/chatwoot.png?cw_image_height=auto" alt="" style="height: auto;" /></p>'
);
});
it('should not set height', () => {
const message =
'Chatwoot is an opensource tool. ![](http://chatwoot.com/chatwoot.png)';
expect(new MessageFormatter(message).formattedMessage).toMatch(
'<p>Chatwoot is an opensource tool. <img src="http://chatwoot.com/chatwoot.png" alt="" /></p>'
);
});
});
describe('tweets', () => {
it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool';
expect(new MessageFormatter(message).formattedMessage).toMatch(message);
});
it('should add links to @mentions', () => {
const message =
'@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername';
expect(
new MessageFormatter(message, true, false).formattedMessage
).toMatch(
'<p><a href="http://twitter.com/chatwootapp" class="link" rel="noreferrer noopener nofollow" target="_blank">@chatwootapp</a> is an opensource tool thanks @longnonexistenttwitterusername</p>'
);
});
it('should add links to #tags', () => {
const message = '#chatwootapp is an opensource tool';
expect(
new MessageFormatter(message, true, false).formattedMessage
).toMatch(
'<p><a href="https://twitter.com/hashtag/chatwootapp" class="link" rel="noreferrer noopener nofollow" target="_blank">#chatwootapp</a> is an opensource tool</p>'
);
});
});
describe('private notes', () => {
it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool';
expect(new MessageFormatter(message).formattedMessage).toMatch(message);
});
it('should add links to @mentions', () => {
const message =
'@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername';
expect(
new MessageFormatter(message, false, true).formattedMessage
).toMatch(message);
});
it('should add links to #tags', () => {
const message = '#chatwootapp is an opensource tool';
expect(
new MessageFormatter(message, false, true).formattedMessage
).toMatch(message);
});
});
describe('plain text content', () => {
it('returns the plain text without HTML', () => {
const message =
'<b>Chatwoot is an opensource tool. https://www.chatwoot.com</b>';
expect(new MessageFormatter(message).plainText).toMatch(
'Chatwoot is an opensource tool. https://www.chatwoot.com'
);
});
});
describe('#sanitize', () => {
it('sanitizes markup and removes all unnecessary elements', () => {
const message =
'[xssLink](javascript:alert(document.cookie))\n[normalLink](https://google.com)**I am a bold text paragraph**';
expect(new MessageFormatter(message).formattedMessage).toMatch(
`<p>[xssLink](javascript:alert(document.cookie))<br />
<a href="https://google.com" class="link" rel="noreferrer noopener nofollow" target="_blank">normalLink</a><strong>I am a bold text paragraph</strong></p>`
);
});
});
});

View File

@@ -0,0 +1,24 @@
import { isASubmittedFormMessage, isAFormMessage } from '../MessageTypeHelper';
describe('#isASubmittedFormMessage', () => {
it('should return correct value', () => {
expect(
isASubmittedFormMessage({
content_type: 'form',
content_attributes: {
submitted_values: [{ name: 'text', value: 'Text ' }],
},
})
).toEqual(true);
});
});
describe('#isAFormMessage', () => {
it('should return correct value', () => {
expect(
isAFormMessage({
content_type: 'form',
})
).toEqual(true);
});
});

View File

@@ -0,0 +1,204 @@
import {
groupHeatmapByDay,
reconcileHeatmapData,
flattenHeatmapData,
clampDataBetweenTimeline,
} from '../ReportsDataHelper';
describe('flattenHeatmapData', () => {
it('should flatten heatmap data to key-value pairs', () => {
const data = [
{ timestamp: 1614265200, value: 10 },
{ timestamp: 1614308400, value: 20 },
];
const expected = {
1614265200: 10,
1614308400: 20,
};
expect(flattenHeatmapData(data)).toEqual(expected);
});
it('should handle empty data', () => {
const data = [];
const expected = {};
expect(flattenHeatmapData(data)).toEqual(expected);
});
it('should handle data with same timestamps', () => {
const data = [
{ timestamp: 1614265200, value: 10 },
{ timestamp: 1614265200, value: 20 },
];
const expected = {
1614265200: 20,
};
expect(flattenHeatmapData(data)).toEqual(expected);
});
});
describe('reconcileHeatmapData', () => {
it('should reconcile heatmap data with new data', () => {
const data = [
{ timestamp: 1614265200, value: 10 },
{ timestamp: 1614308400, value: 20 },
];
const heatmapData = [
{ timestamp: 1614265200, value: 5 },
{ timestamp: 1614308400, value: 15 },
{ timestamp: 1614387600, value: 25 },
];
const expected = [
{ timestamp: 1614265200, value: 10 },
{ timestamp: 1614308400, value: 20 },
{ timestamp: 1614387600, value: 25 },
];
expect(reconcileHeatmapData(data, heatmapData)).toEqual(expected);
});
it('should reconcile heatmap data with new data and handle missing data', () => {
const data = [{ timestamp: 1614308400, value: 20 }];
const heatmapData = [
{ timestamp: 1614265200, value: 5 },
{ timestamp: 1614308400, value: 15 },
{ timestamp: 1614387600, value: 25 },
];
const expected = [
{ timestamp: 1614265200, value: 5 },
{ timestamp: 1614308400, value: 20 },
{ timestamp: 1614387600, value: 25 },
];
expect(reconcileHeatmapData(data, heatmapData)).toEqual(expected);
});
it('should replace empty heatmap data with a new array', () => {
const data = [{ timestamp: 1614308400, value: 20 }];
const heatmapData = [];
expect(reconcileHeatmapData(data, heatmapData).length).toEqual(7 * 24);
});
});
describe('groupHeatmapByDay', () => {
it('should group heatmap data by day', () => {
const heatmapData = [
{ timestamp: 1614265200, value: 10 },
{ timestamp: 1614308400, value: 20 },
{ timestamp: 1614387600, value: 30 },
{ timestamp: 1614430800, value: 40 },
{ timestamp: 1614499200, value: 50 },
];
expect(groupHeatmapByDay(heatmapData)).toMatchInlineSnapshot(`
Map {
"2021-02-25T00:00:00.000Z" => [
{
"date": 2021-02-25T15:00:00.000Z,
"hour": 15,
"timestamp": 1614265200,
"value": 10,
},
],
"2021-02-26T00:00:00.000Z" => [
{
"date": 2021-02-26T03:00:00.000Z,
"hour": 3,
"timestamp": 1614308400,
"value": 20,
},
],
"2021-02-27T00:00:00.000Z" => [
{
"date": 2021-02-27T01:00:00.000Z,
"hour": 1,
"timestamp": 1614387600,
"value": 30,
},
{
"date": 2021-02-27T13:00:00.000Z,
"hour": 13,
"timestamp": 1614430800,
"value": 40,
},
],
"2021-02-28T00:00:00.000Z" => [
{
"date": 2021-02-28T08:00:00.000Z,
"hour": 8,
"timestamp": 1614499200,
"value": 50,
},
],
}
`);
});
it('should group empty heatmap data by day', () => {
const heatmapData = [];
const expected = new Map();
expect(groupHeatmapByDay(heatmapData)).toEqual(expected);
});
it('should group heatmap data with same timestamp in the same day', () => {
const heatmapData = [
{ timestamp: 1614265200, value: 10 },
{ timestamp: 1614265200, value: 20 },
];
expect(groupHeatmapByDay(heatmapData)).toMatchInlineSnapshot(`
Map {
"2021-02-25T00:00:00.000Z" => [
{
"date": 2021-02-25T15:00:00.000Z,
"hour": 15,
"timestamp": 1614265200,
"value": 10,
},
{
"date": 2021-02-25T15:00:00.000Z,
"hour": 15,
"timestamp": 1614265200,
"value": 20,
},
],
}
`);
});
});
describe('clampDataBetweenTimeline', () => {
const data = [
{ timestamp: 1646054400, value: 'A' },
{ timestamp: 1646054500, value: 'B' },
{ timestamp: 1646054600, value: 'C' },
{ timestamp: 1646054700, value: 'D' },
{ timestamp: 1646054800, value: 'E' },
];
it('should return empty array if data is empty', () => {
expect(clampDataBetweenTimeline([], 1646054500, 1646054700)).toEqual([]);
});
it('should return empty array if no data is within the timeline', () => {
expect(clampDataBetweenTimeline(data, 1646054900, 1646055000)).toEqual([]);
});
it('should return the data as is no time limits are provider', () => {
expect(clampDataBetweenTimeline(data)).toEqual(data);
});
it('should return all data if all data is within the timeline', () => {
expect(clampDataBetweenTimeline(data, 1646054300, 1646054900)).toEqual(
data
);
});
it('should return only data within the timeline', () => {
expect(clampDataBetweenTimeline(data, 1646054500, 1646054700)).toEqual([
{ timestamp: 1646054500, value: 'B' },
{ timestamp: 1646054600, value: 'C' },
]);
});
it('should return empty array if from and to are the same', () => {
expect(clampDataBetweenTimeline(data, 1646054500, 1646054500)).toEqual([]);
});
});

View File

@@ -0,0 +1,177 @@
import {
shouldBeUrl,
isPhoneNumberValidWithDialCode,
isPhoneE164OrEmpty,
isPhoneE164,
startsWithPlus,
isValidPassword,
isPhoneNumberValid,
isNumber,
isDomain,
getRegexp,
isValidSlug,
} from '../Validators';
describe('#shouldBeUrl', () => {
it('should return correct url', () => {
expect(shouldBeUrl('http')).toEqual(true);
});
it('should return wrong url', () => {
expect(shouldBeUrl('')).toEqual(true);
expect(shouldBeUrl('abc')).toEqual(false);
});
});
describe('#isPhoneE164', () => {
it('should return correct phone number', () => {
expect(isPhoneE164('+1234567890')).toEqual(true);
});
it('should return wrong phone number', () => {
expect(isPhoneE164('1234567890')).toEqual(false);
expect(isPhoneE164('12345678A9')).toEqual(false);
expect(isPhoneE164('+12345678901234567890')).toEqual(false);
});
});
describe('#isPhoneE164OrEmpty', () => {
it('should return correct phone number', () => {
expect(isPhoneE164OrEmpty('+1234567890')).toEqual(true);
expect(isPhoneE164OrEmpty('')).toEqual(true);
});
it('should return wrong phone number', () => {
expect(isPhoneE164OrEmpty('1234567890')).toEqual(false);
expect(isPhoneE164OrEmpty('12345678A9')).toEqual(false);
expect(isPhoneE164OrEmpty('+12345678901234567890')).toEqual(false);
});
});
describe('#isPhoneNumberValid', () => {
it('should return correct phone number', () => {
expect(isPhoneNumberValid('1234567890', '+91')).toEqual(true);
});
it('should return wrong phone number', () => {
expect(isPhoneNumberValid('12345A67890', '+1')).toEqual(false);
expect(isPhoneNumberValid('12345A6789120', '+1')).toEqual(false);
});
});
describe('#isValidPassword', () => {
it('should return correct password', () => {
expect(isValidPassword('testPass4!')).toEqual(true);
expect(isValidPassword('testPass4-')).toEqual(true);
expect(isValidPassword('testPass4\\')).toEqual(true);
expect(isValidPassword("testPass4'")).toEqual(true);
});
it('should return wrong password', () => {
expect(isValidPassword('testpass4')).toEqual(false);
expect(isValidPassword('testPass4')).toEqual(false);
expect(isValidPassword('testpass4!')).toEqual(false);
expect(isValidPassword('testPass!')).toEqual(false);
});
});
describe('#isNumber', () => {
it('should return correct number', () => {
expect(isNumber('123')).toEqual(true);
});
it('should return wrong number', () => {
expect(isNumber('123-')).toEqual(false);
expect(isNumber('123./')).toEqual(false);
expect(isNumber('string')).toEqual(false);
});
});
describe('#isDomain', () => {
it('should return correct domain', () => {
expect(isDomain('test.com')).toEqual(true);
expect(isDomain('www.test.com')).toEqual(true);
});
it('should return wrong domain', () => {
expect(isDomain('test')).toEqual(false);
expect(isDomain('test.')).toEqual(false);
expect(isDomain('test.123')).toEqual(false);
expect(isDomain('http://www.test.com')).toEqual(false);
expect(isDomain('https://test.in')).toEqual(false);
});
});
describe('#isPhoneNumberValidWithDialCode', () => {
it('should return correct phone number', () => {
expect(isPhoneNumberValidWithDialCode('+123456789')).toEqual(true);
expect(isPhoneNumberValidWithDialCode('+12345')).toEqual(true);
});
it('should return wrong phone number', () => {
expect(isPhoneNumberValidWithDialCode('+123')).toEqual(false);
expect(isPhoneNumberValidWithDialCode('+1234')).toEqual(false);
});
});
describe('#startsWithPlus', () => {
it('should return correct phone number', () => {
expect(startsWithPlus('+123456789')).toEqual(true);
});
it('should return wrong phone number', () => {
expect(startsWithPlus('123456789')).toEqual(false);
});
});
describe('#getRegexp', () => {
it('should create a correct RegExp object', () => {
const regexPattern = '/^[a-z]+$/i';
const regex = getRegexp(regexPattern);
expect(regex).toBeInstanceOf(RegExp);
expect(regex.toString()).toBe(regexPattern);
expect(regex.test('abc')).toBe(true);
expect(regex.test('ABC')).toBe(true);
expect(regex.test('123')).toBe(false);
});
it('should handle regex with flags', () => {
const regexPattern = '/hello/gi';
const regex = getRegexp(regexPattern);
expect(regex).toBeInstanceOf(RegExp);
expect(regex.toString()).toBe(regexPattern);
expect(regex.test('hello')).toBe(true);
expect(regex.test('HELLO')).toBe(false);
expect(regex.test('Hello World')).toBe(true);
});
it('should handle regex with special characters', () => {
const regexPattern = '/\\d{3}-\\d{2}-\\d{4}/';
const regex = getRegexp(regexPattern);
expect(regex).toBeInstanceOf(RegExp);
expect(regex.toString()).toBe(regexPattern);
expect(regex.test('123-45-6789')).toBe(true);
expect(regex.test('12-34-5678')).toBe(false);
});
});
describe('#isValidSlug', () => {
it('should return true for valid slugs', () => {
expect(isValidSlug('abc')).toEqual(true);
expect(isValidSlug('abc-123')).toEqual(true);
expect(isValidSlug('a-b-c')).toEqual(true);
expect(isValidSlug('123')).toEqual(true);
expect(isValidSlug('abc123-def')).toEqual(true);
});
it('should return false for invalid slugs', () => {
expect(isValidSlug('abc_def')).toEqual(false);
expect(isValidSlug('abc def')).toEqual(false);
expect(isValidSlug('abc@def')).toEqual(false);
expect(isValidSlug('abc.def')).toEqual(false);
expect(isValidSlug('abc/def')).toEqual(false);
expect(isValidSlug('abc!def')).toEqual(false);
expect(isValidSlug('abc--def!')).toEqual(false);
expect(isValidSlug('abc-def ')).toEqual(false);
expect(isValidSlug(' abc-def')).toEqual(false);
});
});

View File

@@ -0,0 +1,136 @@
import { getFromCache, setCache, clearCache } from '../cache';
import { LocalStorage } from '../localStorage';
vi.mock('../localStorage');
describe('Cache Helpers', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date(2023, 1, 1, 0, 0, 0));
});
afterEach(() => {
vi.useRealTimers();
});
describe('getFromCache', () => {
it('returns null when no data is cached', () => {
LocalStorage.get.mockReturnValue(null);
const result = getFromCache('test-key');
expect(result).toBeNull();
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
});
it('returns cached data when not expired', () => {
// Current time is 2023-02-01 00:00:00
// Cache timestamp is 1 hour ago
const oneHourAgo =
new Date(2023, 1, 1, 0, 0, 0).getTime() - 60 * 60 * 1000;
LocalStorage.get.mockReturnValue({
data: { foo: 'bar' },
timestamp: oneHourAgo,
});
// Default expiry is 24 hours
const result = getFromCache('test-key');
expect(result).toEqual({ foo: 'bar' });
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
expect(LocalStorage.remove).not.toHaveBeenCalled();
});
it('removes and returns null when data is expired', () => {
// Current time is 2023-02-01 00:00:00
// Cache timestamp is 25 hours ago (beyond the default 24-hour expiry)
const twentyFiveHoursAgo =
new Date(2023, 1, 1, 0, 0, 0).getTime() - 25 * 60 * 60 * 1000;
LocalStorage.get.mockReturnValue({
data: { foo: 'bar' },
timestamp: twentyFiveHoursAgo,
});
const result = getFromCache('test-key');
expect(result).toBeNull();
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
});
it('respects custom expiry time', () => {
// Current time is 2023-02-01 00:00:00
// Cache timestamp is 2 hours ago
const twoHoursAgo =
new Date(2023, 1, 1, 0, 0, 0).getTime() - 2 * 60 * 60 * 1000;
LocalStorage.get.mockReturnValue({
data: { foo: 'bar' },
timestamp: twoHoursAgo,
});
// Set expiry to 1 hour
const result = getFromCache('test-key', 60 * 60 * 1000);
expect(result).toBeNull();
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
});
it('handles errors gracefully', () => {
LocalStorage.get.mockImplementation(() => {
throw new Error('Storage error');
});
const result = getFromCache('test-key');
expect(result).toBeNull();
});
});
describe('setCache', () => {
it('stores data with timestamp', () => {
const data = { name: 'test' };
const expectedCacheData = {
data,
timestamp: new Date(2023, 1, 1, 0, 0, 0).getTime(),
};
setCache('test-key', data);
expect(LocalStorage.set).toHaveBeenCalledWith(
'test-key',
expectedCacheData
);
});
it('handles errors gracefully', () => {
LocalStorage.set.mockImplementation(() => {
throw new Error('Storage error');
});
// Should not throw
expect(() => setCache('test-key', { foo: 'bar' })).not.toThrow();
});
});
describe('clearCache', () => {
it('removes cached data', () => {
clearCache('test-key');
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
});
it('handles errors gracefully', () => {
LocalStorage.remove.mockImplementation(() => {
throw new Error('Storage error');
});
// Should not throw
expect(() => clearCache('test-key')).not.toThrow();
});
});
});

View File

@@ -0,0 +1,284 @@
import { copyTextToClipboard, handleOtpPaste } from '../clipboard';
const mockWriteText = vi.fn();
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
describe('copyTextToClipboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('with string input', () => {
it('copies plain text string to clipboard', async () => {
const text = 'Hello World';
await copyTextToClipboard(text);
expect(mockWriteText).toHaveBeenCalledWith('Hello World');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('copies empty string to clipboard', async () => {
const text = '';
await copyTextToClipboard(text);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with number input', () => {
it('converts number to string', async () => {
await copyTextToClipboard(42);
expect(mockWriteText).toHaveBeenCalledWith('42');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts zero to string', async () => {
await copyTextToClipboard(0);
expect(mockWriteText).toHaveBeenCalledWith('0');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with boolean input', () => {
it('converts true to string', async () => {
await copyTextToClipboard(true);
expect(mockWriteText).toHaveBeenCalledWith('true');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts false to string', async () => {
await copyTextToClipboard(false);
expect(mockWriteText).toHaveBeenCalledWith('false');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with null/undefined input', () => {
it('converts null to empty string', async () => {
await copyTextToClipboard(null);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts undefined to empty string', async () => {
await copyTextToClipboard(undefined);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with object input', () => {
it('stringifies simple object with proper formatting', async () => {
const obj = { name: 'John', age: 30 };
await copyTextToClipboard(obj);
const expectedJson = JSON.stringify(obj, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies nested object with proper formatting', async () => {
const nestedObj = {
severity: {
user_id: 1181505,
user_name: 'test',
server_name: '[1253]test1253',
},
};
await copyTextToClipboard(nestedObj);
const expectedJson = JSON.stringify(nestedObj, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies array with proper formatting', async () => {
const arr = [1, 2, { name: 'test' }];
await copyTextToClipboard(arr);
const expectedJson = JSON.stringify(arr, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies empty object', async () => {
const obj = {};
await copyTextToClipboard(obj);
expect(mockWriteText).toHaveBeenCalledWith('{}');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies empty array', async () => {
const arr = [];
await copyTextToClipboard(arr);
expect(mockWriteText).toHaveBeenCalledWith('[]');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('error handling', () => {
it('throws error when clipboard API fails', async () => {
const error = new Error('Clipboard access denied');
mockWriteText.mockRejectedValueOnce(error);
await expect(copyTextToClipboard('test')).rejects.toThrow(
'Unable to copy text to clipboard: Clipboard access denied'
);
});
it('handles clipboard API not available', async () => {
// Temporarily remove clipboard API
const originalClipboard = navigator.clipboard;
delete navigator.clipboard;
await expect(copyTextToClipboard('test')).rejects.toThrow(
'Unable to copy text to clipboard:'
);
// Restore clipboard API
navigator.clipboard = originalClipboard;
});
});
describe('edge cases', () => {
it('handles Date objects', async () => {
const date = new Date('2023-01-01T00:00:00.000Z');
await copyTextToClipboard(date);
const expectedJson = JSON.stringify(date, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('handles functions by converting to string', async () => {
const func = () => 'test';
await copyTextToClipboard(func);
expect(mockWriteText).toHaveBeenCalledWith(func.toString());
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
});
describe('handleOtpPaste', () => {
// Helper function to create mock clipboard event
const createMockPasteEvent = text => ({
clipboardData: {
getData: vi.fn().mockReturnValue(text),
},
});
describe('valid OTP paste scenarios', () => {
it('extracts 6-digit OTP from clean numeric string', () => {
const event = createMockPasteEvent('123456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
expect(event.clipboardData.getData).toHaveBeenCalledWith('text');
});
it('extracts 6-digit OTP from string with spaces', () => {
const event = createMockPasteEvent('1 2 3 4 5 6');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('extracts 6-digit OTP from string with dashes', () => {
const event = createMockPasteEvent('123-456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('handles negative numbers by extracting digits only', () => {
const event = createMockPasteEvent('-123456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('handles decimal numbers by extracting digits only', () => {
const event = createMockPasteEvent('123.456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('extracts 6-digit OTP from mixed alphanumeric string', () => {
const event = createMockPasteEvent('Your code is: 987654');
const result = handleOtpPaste(event);
expect(result).toBe('987654');
});
it('extracts first 6 digits when more than 6 digits present', () => {
const event = createMockPasteEvent('12345678901234');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('handles custom maxLength parameter', () => {
const event = createMockPasteEvent('12345678');
const result = handleOtpPaste(event, 8);
expect(result).toBe('12345678');
});
it('extracts 4-digit OTP with custom maxLength', () => {
const event = createMockPasteEvent('Your PIN: 9876');
const result = handleOtpPaste(event, 4);
expect(result).toBe('9876');
});
});
describe('invalid OTP paste scenarios', () => {
it('returns null for insufficient digits', () => {
const event = createMockPasteEvent('12345');
const result = handleOtpPaste(event);
expect(result).toBeNull();
});
it('returns null for text with no digits', () => {
const event = createMockPasteEvent('Hello World');
const result = handleOtpPaste(event);
expect(result).toBeNull();
});
it('returns null for empty string', () => {
const event = createMockPasteEvent('');
const result = handleOtpPaste(event);
expect(result).toBeNull();
});
it('returns null when event is null', () => {
const result = handleOtpPaste(null);
expect(result).toBeNull();
});
it('returns null when event is undefined', () => {
const result = handleOtpPaste(undefined);
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,67 @@
import { toHex, getContrast } from 'color2k';
import {
isWidgetColorLighter,
adjustColorForContrast,
} from 'shared/helpers/colorHelper';
describe('#isWidgetColorLighter', () => {
it('returns true if color is lighter', () => {
expect(isWidgetColorLighter('#ffffff')).toEqual(true);
});
it('returns false if color is darker', () => {
expect(isWidgetColorLighter('#000000')).toEqual(false);
});
});
describe('#adjustColorForContrast', () => {
const targetRatio = 3.1;
const getContrastRatio = (color1, color2) => {
// getContrast from 'color2k'
return getContrast(color1, color2);
};
it('adjusts a color to meet the contrast ratio against a light background', () => {
const color = '#ff0000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
});
it('adjusts a color to meet the contrast ratio against a dark background', () => {
const color = '#00ff00';
const backgroundColor = '#000000';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
});
it('returns a string representation of the color', () => {
const color = '#00ff00';
const backgroundColor = '#000000';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
expect(typeof adjustedColor).toEqual('string');
});
it('handles cases where the color already meets the contrast ratio', () => {
const color = '#000000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
expect(adjustedColor).toEqual(toHex(color));
});
it('does not modify a color that already exceeds the contrast ratio', () => {
const color = '#000000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
expect(adjustedColor).toEqual(toHex(color));
});
});

View File

@@ -0,0 +1,111 @@
import {
isPdfDocument,
formatDocumentLink,
} from 'shared/helpers/documentHelper';
describe('documentHelper', () => {
describe('#isPdfDocument', () => {
it('returns true for PDF documents', () => {
expect(isPdfDocument('PDF:document.pdf')).toBe(true);
expect(isPdfDocument('PDF:my-file_20241227123045.pdf')).toBe(true);
expect(isPdfDocument('PDF:report with spaces_20241227123045.pdf')).toBe(
true
);
});
it('returns false for regular URLs', () => {
expect(isPdfDocument('https://example.com')).toBe(false);
expect(isPdfDocument('http://docs.example.com/file.pdf')).toBe(false);
expect(isPdfDocument('ftp://files.example.com/document.pdf')).toBe(false);
});
it('returns false for empty or null values', () => {
expect(isPdfDocument('')).toBe(false);
expect(isPdfDocument(null)).toBe(false);
expect(isPdfDocument(undefined)).toBe(false);
});
it('returns false for strings that contain PDF but do not start with PDF:', () => {
expect(isPdfDocument('document PDF:file.pdf')).toBe(false);
expect(isPdfDocument('My PDF:file.pdf')).toBe(false);
});
});
describe('#formatDocumentLink', () => {
describe('PDF documents', () => {
it('removes PDF: prefix from PDF documents', () => {
expect(formatDocumentLink('PDF:document.pdf')).toBe('document.pdf');
expect(formatDocumentLink('PDF:my-file.pdf')).toBe('my-file.pdf');
});
it('removes timestamp suffix from PDF documents', () => {
expect(formatDocumentLink('PDF:document_20241227123045.pdf')).toBe(
'document.pdf'
);
expect(formatDocumentLink('PDF:report_20231215094530.pdf')).toBe(
'report.pdf'
);
});
it('handles PDF documents with spaces in filename', () => {
expect(formatDocumentLink('PDF:my document_20241227123045.pdf')).toBe(
'my document.pdf'
);
expect(
formatDocumentLink('PDF:Annual Report 2024_20241227123045.pdf')
).toBe('Annual Report 2024.pdf');
});
it('handles PDF documents without timestamp suffix', () => {
expect(formatDocumentLink('PDF:document.pdf')).toBe('document.pdf');
expect(formatDocumentLink('PDF:simple-file.pdf')).toBe(
'simple-file.pdf'
);
});
it('handles PDF documents with partial timestamp patterns', () => {
expect(formatDocumentLink('PDF:document_202412.pdf')).toBe(
'document_202412.pdf'
);
expect(formatDocumentLink('PDF:file_123.pdf')).toBe('file_123.pdf');
});
it('handles edge cases with timestamp pattern', () => {
expect(
formatDocumentLink('PDF:doc_20241227123045_final_20241227123045.pdf')
).toBe('doc_20241227123045_final.pdf');
});
});
describe('Regular URLs', () => {
it('returns regular URLs unchanged', () => {
expect(formatDocumentLink('https://example.com')).toBe(
'https://example.com'
);
expect(formatDocumentLink('http://docs.example.com/api')).toBe(
'http://docs.example.com/api'
);
expect(formatDocumentLink('https://github.com/user/repo')).toBe(
'https://github.com/user/repo'
);
});
it('handles URLs with query parameters', () => {
expect(formatDocumentLink('https://example.com?param=value')).toBe(
'https://example.com?param=value'
);
expect(
formatDocumentLink(
'https://api.example.com/docs?version=v1&format=json'
)
).toBe('https://api.example.com/docs?version=v1&format=json');
});
it('handles URLs with fragments', () => {
expect(formatDocumentLink('https://example.com/docs#section1')).toBe(
'https://example.com/docs#section1'
);
});
});
});
});

View File

@@ -0,0 +1,107 @@
import { LocalStorage } from '../localStorage';
// Mocking localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: key => store[key] || null,
setItem: (key, value) => {
store[key] = String(value);
},
removeItem: key => delete store[key],
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
describe('LocalStorage utility', () => {
beforeEach(() => {
localStorage.clear();
});
it('set and get methods', () => {
LocalStorage.set('testKey', { a: 1 });
expect(LocalStorage.get('testKey')).toEqual({ a: 1 });
});
it('remove method', () => {
LocalStorage.set('testKey', 'testValue');
LocalStorage.remove('testKey');
expect(LocalStorage.get('testKey')).toBeNull();
});
it('updateJsonStore method', () => {
LocalStorage.updateJsonStore('testStore', 'testKey', 'testValue');
expect(LocalStorage.get('testStore')).toEqual({ testKey: 'testValue' });
});
it('getFromJsonStore method', () => {
LocalStorage.set('testStore', { testKey: 'testValue' });
expect(LocalStorage.getFromJsonStore('testStore', 'testKey')).toBe(
'testValue'
);
});
it('deleteFromJsonStore method', () => {
LocalStorage.set('testStore', { testKey: 'testValue' });
LocalStorage.deleteFromJsonStore('testStore', 'testKey');
expect(LocalStorage.getFromJsonStore('testStore', 'testKey')).toBeNull();
});
it('setFlag and getFlag methods', () => {
const store = 'testStore';
const accountId = '123';
const key = 'flagKey';
const expiry = 1000; // 1 second
// Set flag and verify it's set
LocalStorage.setFlag(store, accountId, key, expiry);
expect(LocalStorage.getFlag(store, accountId, key)).toBe(true);
// Wait for expiry and verify flag is not set
return new Promise(resolve => {
setTimeout(() => {
expect(LocalStorage.getFlag(store, accountId, key)).toBe(false);
resolve();
}, expiry + 100); // wait a bit more than expiry time to ensure the flag has expired
});
});
it('clearAll method', () => {
LocalStorage.set('testKey1', 'testValue1');
LocalStorage.set('testKey2', 'testValue2');
LocalStorage.clearAll();
expect(LocalStorage.get('testKey1')).toBeNull();
expect(LocalStorage.get('testKey2')).toBeNull();
});
it('set method with non-object value', () => {
LocalStorage.set('testKey', 'testValue');
expect(LocalStorage.get('testKey')).toBe('testValue');
});
it('set and get methods with null value', () => {
LocalStorage.set('testKey', null);
expect(LocalStorage.get('testKey')).toBeNull();
});
it('set and get methods with undefined value', () => {
LocalStorage.set('testKey', undefined);
expect(LocalStorage.get('testKey')).toBe('undefined');
});
it('set and get methods with boolean value', () => {
LocalStorage.set('testKey', true);
expect(LocalStorage.get('testKey')).toBe(true);
});
it('set and get methods with number value', () => {
LocalStorage.set('testKey', 42);
expect(LocalStorage.get('testKey')).toBe(42);
});
});

View File

@@ -0,0 +1,25 @@
import { emitter } from '../mitt';
describe('emitter', () => {
it('should emit and listen to events correctly', () => {
const mockCallback = vi.fn();
// Subscribe to an event
emitter.on('event', mockCallback);
// Emit the event
emitter.emit('event', 'data');
// Expect the callback to be called with the correct data
expect(mockCallback).toHaveBeenCalledWith('data');
// Unsubscribe from the event
emitter.off('event', mockCallback);
// Emit the event again
emitter.emit('event', 'data');
// Expect the callback not to be called again
expect(mockCallback).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,28 @@
import { getMatchingLocale } from 'shared/helpers/portalHelper';
describe('portalHelper - getMatchingLocale', () => {
it('returns exact match when present', () => {
const result = getMatchingLocale('fr', ['en', 'fr']);
expect(result).toBe('fr');
});
it('returns base language match when exact variant not present', () => {
const result = getMatchingLocale('fr_CA', ['en', 'fr']);
expect(result).toBe('fr');
});
it('returns variant match when base language not present', () => {
const result = getMatchingLocale('fr', ['en', 'fr_BE']);
expect(result).toBe('fr_BE');
});
it('returns null when no match found', () => {
const result = getMatchingLocale('de', ['en', 'fr']);
expect(result).toBeNull();
});
it('returns null for invalid inputs', () => {
expect(getMatchingLocale('', [])).toBeNull();
expect(getMatchingLocale(null, null)).toBeNull();
});
});

View File

@@ -0,0 +1,44 @@
import { sanitizeLabel } from '../sanitizeData';
describe('sanitizeLabel', () => {
it('should return an empty string when given an empty string', () => {
const label = '';
const sanitizedLabel = sanitizeLabel(label);
expect(sanitizedLabel).toEqual('');
});
it('should remove leading and trailing whitespace', () => {
const label = ' My Label ';
const sanitizedLabel = sanitizeLabel(label);
expect(sanitizedLabel).toEqual('my-label');
});
it('should convert all characters to lowercase', () => {
const label = 'My Label';
const sanitizedLabel = sanitizeLabel(label);
expect(sanitizedLabel).toEqual('my-label');
});
it('should replace spaces with hyphens', () => {
const label = 'My Label 123';
const sanitizedLabel = sanitizeLabel(label);
expect(sanitizedLabel).toEqual('my-label-123');
});
it('should remove any characters that are not alphanumeric, underscore, or hyphen', () => {
const label = 'My_Label!123';
const sanitizedLabel = sanitizeLabel(label);
expect(sanitizedLabel).toEqual('my_label123');
});
it('should handle null and undefined input', () => {
const nullLabel = null;
const undefinedLabel = undefined;
// @ts-ignore - intentionally passing null and undefined to test
const sanitizedNullLabel = sanitizeLabel(nullLabel);
const sanitizedUndefinedLabel = sanitizeLabel(undefinedLabel);
expect(sanitizedNullLabel).toEqual('');
expect(sanitizedUndefinedLabel).toEqual('');
});
});

View File

@@ -0,0 +1,137 @@
import SessionStorage from '../sessionStorage';
// Mocking sessionStorage
const sessionStorageMock = (() => {
let store = {};
return {
getItem: key => store[key] || null,
setItem: (key, value) => {
store[key] = String(value);
},
removeItem: key => delete store[key],
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'sessionStorage', {
value: sessionStorageMock,
});
describe('SessionStorage utility', () => {
beforeEach(() => {
sessionStorage.clear();
});
describe('clearAll method', () => {
it('should clear all items from sessionStorage', () => {
sessionStorage.setItem('testKey1', 'testValue1');
sessionStorage.setItem('testKey2', 'testValue2');
SessionStorage.clearAll();
expect(sessionStorage.getItem('testKey1')).toBeNull();
expect(sessionStorage.getItem('testKey2')).toBeNull();
});
});
describe('get method', () => {
it('should retrieve and parse JSON values correctly', () => {
const testObject = { a: 1, b: 'test' };
sessionStorage.setItem('testKey', JSON.stringify(testObject));
expect(SessionStorage.get('testKey')).toEqual(testObject);
});
it('should return null for non-existent keys', () => {
expect(SessionStorage.get('nonExistentKey')).toBeNull();
});
it('should handle non-JSON values by returning the raw value', () => {
sessionStorage.setItem('testKey', 'plain string value');
expect(SessionStorage.get('testKey')).toBe('plain string value');
});
it('should handle malformed JSON gracefully', () => {
sessionStorage.setItem('testKey', '{malformed:json}');
expect(SessionStorage.get('testKey')).toBe('{malformed:json}');
});
});
describe('set method', () => {
it('should store object values as JSON strings', () => {
const testObject = { a: 1, b: 'test' };
SessionStorage.set('testKey', testObject);
expect(sessionStorage.getItem('testKey')).toBe(
JSON.stringify(testObject)
);
});
it('should store primitive values directly', () => {
SessionStorage.set('stringKey', 'test string');
expect(sessionStorage.getItem('stringKey')).toBe('test string');
SessionStorage.set('numberKey', 42);
expect(sessionStorage.getItem('numberKey')).toBe('42');
SessionStorage.set('booleanKey', true);
expect(sessionStorage.getItem('booleanKey')).toBe('true');
});
it('should handle null values', () => {
SessionStorage.set('nullKey', null);
expect(sessionStorage.getItem('nullKey')).toBe('null');
expect(SessionStorage.get('nullKey')).toBeNull();
});
it('should handle undefined values', () => {
SessionStorage.set('undefinedKey', undefined);
expect(sessionStorage.getItem('undefinedKey')).toBe('undefined');
});
});
describe('remove method', () => {
it('should remove an item from sessionStorage', () => {
SessionStorage.set('testKey', 'testValue');
expect(SessionStorage.get('testKey')).toBe('testValue');
SessionStorage.remove('testKey');
expect(SessionStorage.get('testKey')).toBeNull();
});
it('should do nothing when removing a non-existent key', () => {
expect(() => {
SessionStorage.remove('nonExistentKey');
}).not.toThrow();
});
});
describe('Integration of methods', () => {
it('should set, get, and remove values correctly', () => {
SessionStorage.set('testKey', { value: 'test' });
expect(SessionStorage.get('testKey')).toEqual({ value: 'test' });
SessionStorage.remove('testKey');
expect(SessionStorage.get('testKey')).toBeNull();
});
it('should correctly handle impersonation flag (common use case)', () => {
SessionStorage.set('impersonationUser', true);
expect(SessionStorage.get('impersonationUser')).toBe(true);
expect(sessionStorage.getItem('impersonationUser')).toBe('true');
SessionStorage.remove('impersonationUser');
expect(SessionStorage.get('impersonationUser')).toBeNull();
});
});
});

View File

@@ -0,0 +1,239 @@
import {
messageStamp,
messageTimestamp,
dynamicTime,
dateFormat,
shortTimestamp,
getDayDifferenceFromNow,
hasOneDayPassed,
} from 'shared/helpers/timeHelper';
beforeEach(() => {
process.env.TZ = 'UTC';
vi.useFakeTimers('modern');
const mockDate = new Date(Date.UTC(2023, 4, 5));
vi.setSystemTime(mockDate);
});
afterEach(() => {
vi.useRealTimers();
});
describe('#messageStamp', () => {
it('returns correct value', () => {
expect(messageStamp(1612971343)).toEqual('3:35 PM');
expect(messageStamp(1612971343, 'LLL d, h:mm a')).toEqual(
'Feb 10, 3:35 PM'
);
});
});
describe('#messageTimestamp', () => {
it('should return the message date in the specified format if the message was sent in the current year', () => {
expect(messageTimestamp(1680777464)).toEqual('Apr 6, 2023');
});
it('should return the message date and time in a different format if the message was sent in a different year', () => {
expect(messageTimestamp(1612971343)).toEqual('Feb 10 2021, 3:35 PM');
});
});
describe('#dynamicTime', () => {
it('returns correct value', () => {
Date.now = vi.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf());
expect(dynamicTime(1612971343)).toEqual('about 2 years ago');
});
});
describe('#dateFormat', () => {
it('returns correct value', () => {
expect(dateFormat(1612971343)).toEqual('Feb 10, 2021');
expect(dateFormat(1612971343, 'LLL d, yyyy')).toEqual('Feb 10, 2021');
});
});
describe('#shortTimestamp', () => {
// Test cases when withAgo is false or not provided
it('returns correct value without ago', () => {
expect(shortTimestamp('less than a minute ago')).toEqual('now');
expect(shortTimestamp('1 minute ago')).toEqual('1m');
expect(shortTimestamp('12 minutes ago')).toEqual('12m');
expect(shortTimestamp('a minute ago')).toEqual('1m');
expect(shortTimestamp('an hour ago')).toEqual('1h');
expect(shortTimestamp('1 hour ago')).toEqual('1h');
expect(shortTimestamp('2 hours ago')).toEqual('2h');
expect(shortTimestamp('1 day ago')).toEqual('1d');
expect(shortTimestamp('a day ago')).toEqual('1d');
expect(shortTimestamp('3 days ago')).toEqual('3d');
expect(shortTimestamp('a month ago')).toEqual('1mo');
expect(shortTimestamp('1 month ago')).toEqual('1mo');
expect(shortTimestamp('2 months ago')).toEqual('2mo');
expect(shortTimestamp('a year ago')).toEqual('1y');
expect(shortTimestamp('1 year ago')).toEqual('1y');
expect(shortTimestamp('4 years ago')).toEqual('4y');
});
// Test cases when withAgo is true
it('returns correct value with ago', () => {
expect(shortTimestamp('less than a minute ago', true)).toEqual('now');
expect(shortTimestamp('1 minute ago', true)).toEqual('1m ago');
expect(shortTimestamp('12 minutes ago', true)).toEqual('12m ago');
expect(shortTimestamp('a minute ago', true)).toEqual('1m ago');
expect(shortTimestamp('an hour ago', true)).toEqual('1h ago');
expect(shortTimestamp('1 hour ago', true)).toEqual('1h ago');
expect(shortTimestamp('2 hours ago', true)).toEqual('2h ago');
expect(shortTimestamp('1 day ago', true)).toEqual('1d ago');
expect(shortTimestamp('a day ago', true)).toEqual('1d ago');
expect(shortTimestamp('3 days ago', true)).toEqual('3d ago');
expect(shortTimestamp('a month ago', true)).toEqual('1mo ago');
expect(shortTimestamp('1 month ago', true)).toEqual('1mo ago');
expect(shortTimestamp('2 months ago', true)).toEqual('2mo ago');
expect(shortTimestamp('a year ago', true)).toEqual('1y ago');
expect(shortTimestamp('1 year ago', true)).toEqual('1y ago');
expect(shortTimestamp('4 years ago', true)).toEqual('4y ago');
});
});
describe('#getDayDifferenceFromNow', () => {
it('returns 0 for timestamps from today', () => {
// Mock current date: May 5, 2023
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // 12:00 PM
const todayTimestamp = Math.floor(now.getTime() / 1000); // Same day
expect(getDayDifferenceFromNow(now, todayTimestamp)).toEqual(0);
});
it('returns 2 for timestamps from 2 days ago', () => {
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
const twoDaysAgoTimestamp = Math.floor(
new Date(Date.UTC(2023, 4, 3, 10, 0, 0)).getTime() / 1000
); // May 3, 2023
expect(getDayDifferenceFromNow(now, twoDaysAgoTimestamp)).toEqual(2);
});
it('returns 7 for timestamps from a week ago', () => {
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
const weekAgoTimestamp = Math.floor(
new Date(Date.UTC(2023, 3, 28, 8, 0, 0)).getTime() / 1000
); // April 28, 2023
expect(getDayDifferenceFromNow(now, weekAgoTimestamp)).toEqual(7);
});
it('returns 30 for timestamps from a month ago', () => {
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
const monthAgoTimestamp = Math.floor(
new Date(Date.UTC(2023, 3, 5, 12, 0, 0)).getTime() / 1000
); // April 5, 2023
expect(getDayDifferenceFromNow(now, monthAgoTimestamp)).toEqual(30);
});
it('handles edge case with different times on same day', () => {
const now = new Date(Date.UTC(2023, 4, 5, 23, 59, 59)); // May 5, 2023 11:59:59 PM
const morningTimestamp = Math.floor(
new Date(Date.UTC(2023, 4, 5, 0, 0, 1)).getTime() / 1000
); // May 5, 2023 12:00:01 AM
expect(getDayDifferenceFromNow(now, morningTimestamp)).toEqual(0);
});
it('handles cross-month boundaries correctly', () => {
const now = new Date(Date.UTC(2023, 4, 1, 12, 0, 0)); // May 1, 2023
const lastMonthTimestamp = Math.floor(
new Date(Date.UTC(2023, 3, 30, 12, 0, 0)).getTime() / 1000
); // April 30, 2023
expect(getDayDifferenceFromNow(now, lastMonthTimestamp)).toEqual(1);
});
it('handles cross-year boundaries correctly', () => {
const now = new Date(Date.UTC(2023, 0, 2, 12, 0, 0)); // January 2, 2023
const lastYearTimestamp = Math.floor(
new Date(Date.UTC(2022, 11, 31, 12, 0, 0)).getTime() / 1000
); // December 31, 2022
expect(getDayDifferenceFromNow(now, lastYearTimestamp)).toEqual(2);
});
});
describe('#hasOneDayPassed', () => {
beforeEach(() => {
// Mock current date: May 5, 2023, 12:00 PM UTC (1683288000)
const mockDate = new Date(1683288000 * 1000);
vi.setSystemTime(mockDate);
});
it('returns false for timestamps from today', () => {
// Same day, different time - May 5, 2023 8:00 AM UTC
const todayTimestamp = 1683273600;
expect(hasOneDayPassed(todayTimestamp)).toBe(false);
});
it('returns false for timestamps from yesterday (less than 24 hours)', () => {
// Yesterday but less than 24 hours ago - May 4, 2023 6:00 PM UTC (18 hours ago)
const yesterdayTimestamp = 1683230400;
expect(hasOneDayPassed(yesterdayTimestamp)).toBe(false);
});
it('returns true for timestamps from exactly 1 day ago', () => {
// Exactly 24 hours ago - May 4, 2023 12:00 PM UTC
const oneDayAgoTimestamp = 1683201600;
expect(hasOneDayPassed(oneDayAgoTimestamp)).toBe(true);
});
it('returns true for timestamps from more than 1 day ago', () => {
// 2 days ago - May 3, 2023 10:00 AM UTC
const twoDaysAgoTimestamp = 1683108000;
expect(hasOneDayPassed(twoDaysAgoTimestamp)).toBe(true);
});
it('returns true for timestamps from a week ago', () => {
// 7 days ago - April 28, 2023 8:00 AM UTC
const weekAgoTimestamp = 1682668800;
expect(hasOneDayPassed(weekAgoTimestamp)).toBe(true);
});
it('returns true for null timestamp (defensive check)', () => {
expect(hasOneDayPassed(null)).toBe(true);
});
it('returns true for undefined timestamp (defensive check)', () => {
expect(hasOneDayPassed(undefined)).toBe(true);
});
it('returns true for zero timestamp (defensive check)', () => {
expect(hasOneDayPassed(0)).toBe(true);
});
it('returns true for empty string timestamp (defensive check)', () => {
expect(hasOneDayPassed('')).toBe(true);
});
it('handles cross-month boundaries correctly', () => {
// Set current time to May 1, 2023 12:00 PM UTC (1682942400)
const mayFirst = new Date(1682942400 * 1000);
vi.setSystemTime(mayFirst);
// April 29, 2023 12:00 PM UTC (1682769600) - 2 days ago, crossing month boundary
const crossMonthTimestamp = 1682769600;
expect(hasOneDayPassed(crossMonthTimestamp)).toBe(true);
});
it('handles cross-year boundaries correctly', () => {
// Set current time to January 2, 2023 12:00 PM UTC (1672660800)
const newYear = new Date(1672660800 * 1000);
vi.setSystemTime(newYear);
// December 30, 2022 12:00 PM UTC (1672401600) - 3 days ago, crossing year boundary
const crossYearTimestamp = 1672401600;
expect(hasOneDayPassed(crossYearTimestamp)).toBe(true);
});
});

View File

@@ -0,0 +1,116 @@
import {
format,
isSameYear,
fromUnixTime,
formatDistanceToNow,
differenceInDays,
} from 'date-fns';
/**
* Formats a Unix timestamp into a human-readable time format.
* @param {number} time - Unix timestamp.
* @param {string} [dateFormat='h:mm a'] - Desired format of the time.
* @returns {string} Formatted time string.
*/
export const messageStamp = (time, dateFormat = 'h:mm a') => {
const unixTime = fromUnixTime(time);
return format(unixTime, dateFormat);
};
/**
* Provides a formatted timestamp, adjusting the format based on the current year.
* @param {number} time - Unix timestamp.
* @param {string} [dateFormat='MMM d, yyyy'] - Desired date format.
* @returns {string} Formatted date string.
*/
export const messageTimestamp = (time, dateFormat = 'MMM d, yyyy') => {
const messageTime = fromUnixTime(time);
const now = new Date();
const messageDate = format(messageTime, dateFormat);
if (!isSameYear(messageTime, now)) {
return format(messageTime, 'LLL d y, h:mm a');
}
return messageDate;
};
/**
* Converts a Unix timestamp to a relative time string (e.g., 3 hours ago).
* @param {number} time - Unix timestamp.
* @returns {string} Relative time string.
*/
export const dynamicTime = time => {
const unixTime = fromUnixTime(time);
return formatDistanceToNow(unixTime, { addSuffix: true });
};
/**
* Formats a Unix timestamp into a specified date format.
* @param {number} time - Unix timestamp.
* @param {string} [dateFormat='MMM d, yyyy'] - Desired date format.
* @returns {string} Formatted date string.
*/
export const dateFormat = (time, df = 'MMM d, yyyy') => {
const unixTime = fromUnixTime(time);
return format(unixTime, df);
};
/**
* Converts a detailed time description into a shorter format, optionally appending 'ago'.
* @param {string} time - Detailed time description (e.g., 'a minute ago').
* @param {boolean} [withAgo=false] - Whether to append 'ago' to the result.
* @returns {string} Shortened time description.
*/
export const shortTimestamp = (time, withAgo = false) => {
// This function takes a time string and converts it to a short time string
// with the following format: 1m, 1h, 1d, 1mo, 1y
// The function also takes an optional boolean parameter withAgo
// which will add the word "ago" to the end of the time string
const suffix = withAgo ? ' ago' : '';
const timeMappings = {
'less than a minute ago': 'now',
'a minute ago': `1m${suffix}`,
'an hour ago': `1h${suffix}`,
'a day ago': `1d${suffix}`,
'a month ago': `1mo${suffix}`,
'a year ago': `1y${suffix}`,
};
// Check if the time string is one of the specific cases
if (timeMappings[time]) {
return timeMappings[time];
}
const convertToShortTime = time
.replace(/about|over|almost|/g, '')
.replace(' minute ago', `m${suffix}`)
.replace(' minutes ago', `m${suffix}`)
.replace(' hour ago', `h${suffix}`)
.replace(' hours ago', `h${suffix}`)
.replace(' day ago', `d${suffix}`)
.replace(' days ago', `d${suffix}`)
.replace(' month ago', `mo${suffix}`)
.replace(' months ago', `mo${suffix}`)
.replace(' year ago', `y${suffix}`)
.replace(' years ago', `y${suffix}`);
return convertToShortTime;
};
/**
* Calculates the difference in days between now and a given timestamp.
* @param {Date} now - Current date/time.
* @param {number} timestampInSeconds - Unix timestamp in seconds.
* @returns {number} Number of days difference.
*/
export const getDayDifferenceFromNow = (now, timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000);
return differenceInDays(now, date);
};
/**
* Checks if more than 24 hours have passed since a given timestamp.
* Useful for determining if retry/refresh actions should be disabled.
* @param {number} timestamp - Unix timestamp.
* @returns {boolean} True if more than 24 hours have passed.
*/
export const hasOneDayPassed = timestamp => {
if (!timestamp) return true; // Defensive check
return getDayDifferenceFromNow(new Date(), timestamp) >= 1;
};

View File

@@ -0,0 +1,54 @@
export const set = (state, data) => {
state.records = data;
};
export const create = (state, data) => {
state.records.push(data);
};
export const setSingleRecord = (state, data) => {
const recordIndex = state.records.findIndex(record => record.id === data.id);
if (recordIndex > -1) {
state.records[recordIndex] = data;
} else {
create(state, data);
}
};
export const update = (state, data) => {
state.records.forEach((element, index) => {
if (element.id === data.id) {
state.records[index] = data;
}
});
};
/* when you don't want to overwrite the whole object */
export const updateAttributes = (state, data) => {
state.records.forEach((element, index) => {
if (element.id === data.id) {
state.records[index] = { ...state.records[index], ...data };
}
});
};
export const updatePresence = (state, data) => {
state.records.forEach((element, index) => {
const availabilityStatus = data[element.id];
state.records[index].availability_status = availabilityStatus || 'offline';
});
};
export const updateSingleRecordPresence = (
records,
{ id, availabilityStatus }
) => {
const [selectedRecord] = records.filter(record => record.id === Number(id));
if (selectedRecord) {
selectedRecord.availability_status = availabilityStatus;
}
};
export const destroy = (state, id) => {
state.records = state.records.filter(record => record.id !== id);
};