Corp/backend/static/js/components/chatMessageTemplates.js

237 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @fileoverview Chat Message Templates - HTML шаблоны для рендеринга сообщений чата
* @module components/chatMessageTemplates
*/
import { esc } from '../utils/stringUtils.js';
/**
* Дополняет число нулём слева
* @param {number} n - Число
* @returns {string} Строка с ведущим нулём
*/
const z2 = n => (n < 10 ? '0' : '') + n;
/**
* Извлекает timestamp из сообщения
* @param {Object} msg - Объект сообщения
* @returns {number} Unix timestamp в миллисекундах
*/
export function toTimestamp(msg) {
if (msg && msg.created_ts != null && !isNaN(Number(msg.created_ts))) {
return Number(msg.created_ts);
}
if (msg && msg.created) {
const t = Date.parse(msg.created);
if (!isNaN(t)) return t;
}
return Date.now();
}
/**
* Форматирует timestamp в HH:MM
* @param {number} ts - Unix timestamp
* @returns {string} Время в формате HH:MM
*/
export function formatTime(ts) {
const d = new Date(ts);
return `${z2(d.getHours())}:${z2(d.getMinutes())}`;
}
/**
* Форматирует timestamp в DD.MM.YYYY
* @param {number} ts - Unix timestamp
* @returns {string} Дата в формате DD.MM.YYYY
*/
export function formatDay(ts) {
const d = new Date(ts);
return `${z2(d.getDate())}.${z2(d.getMonth() + 1)}.${d.getFullYear()}`;
}
/**
* Создаёт HTML для аватара
* @param {string} url - URL аватара
* @returns {string} HTML аватара
*/
export function avatarHTML(url) {
return url
? `<span class="mini-ava border"><img src="${url}" alt="" loading="lazy"></span>`
: `<span class="mini-ava border d-inline-grid"><i class="bi-person"></i></span>`;
}
/**
* Создаёт HTML для имени пользователя с ссылкой
* @param {number|string} userId - ID пользователя
* @param {string} name - Имя пользователя
* @param {string} url - URL профиля
* @param {number|string} meId - ID текущего пользователя
* @param {string} profileUrl - URL профиля текущего пользователя
* @param {string} detailUrlTemplate - Шаблон URL деталей сотрудника
* @returns {string} HTML имени с ссылкой
*/
export function nameHTML(userId, name, url, meId, profileUrl, detailUrlTemplate) {
if (Number(userId) === Number(meId)) {
return `<a href="${profileUrl}" class="text-decoration-none">Вы</a>`;
}
if (url) {
return `<a href="${url}" class="text-decoration-none fw-semibold">${esc(name || 'Сотрудник')}</a>`;
}
const detailUrl = detailUrlTemplate.replace('/0/', `/${userId}/`);
return `<a href="${detailUrl}" class="text-decoration-none fw-semibold">${esc(name || 'Сотрудник')}</a>`;
}
/**
* Форматирует размер файла
* @param {number} bytes - Размер в байтах
* @returns {string} Размер в человекочитаемом формате
*/
export function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
/**
* Создаёт разделитель дня
* @param {string} dateStr - Дата в текстовом формате
* @returns {HTMLElement} DOM элемент разделителя
*/
export function createDayDivider(dateStr) {
const div = document.createElement('div');
div.className = 'day-divider';
div.innerHTML = `<span>${esc(dateStr)}</span>`;
return div;
}
/**
* Создаёт элемент сообщения
* @param {Object} msg - Объект сообщения
* @param {number|string} msg.id - ID сообщения
* @param {number|string} msg.author_id - ID автора
* @param {string} msg.author_name - Имя автора
* @param {string} msg.author - Альтернативное имя автора
* @param {string} msg.author_url - URL профиля автора
* @param {string} msg.avatar - URL аватара автора
* @param {string} msg.content - Текст сообщения
* @param {Object} options - Опции рендеринга
* @param {number|string} options.meId - ID текущего пользователя
* @param {string} options.profileUrl - URL профиля текущего пользователя
* @param {string} options.detailUrlTemplate - Шаблон URL деталей сотрудника
* @param {Object.<string, string>} options.avatarMap - Карта аватаров
* @returns {HTMLElement} DOM элемент сообщения
*/
export function createMessageElement(msg, options = {}) {
const {
meId,
profileUrl = '/employees/profile/',
detailUrlTemplate = '/employees/0/',
avatarMap = {}
} = options;
const mine = Number(msg.author_id) === Number(meId);
const isPending = Boolean(msg.is_pending);
const ts = toTimestamp(msg);
// Создание обёртки
const wrap = document.createElement('div');
wrap.className = `d-flex mb-3 msg ${mine ? 'justify-content-end' : 'justify-content-start'}`;
if (isPending) {
wrap.classList.add('message-pending');
}
if (msg.id != null) {
wrap.setAttribute('data-id', String(msg.id));
}
wrap.setAttribute('data-ts', String(ts));
wrap.setAttribute('data-author-id', String(msg.author_id || ''));
// Формирование контента
const who = nameHTML(
msg.author_id,
msg.author_name || msg.author,
msg.author_url,
meId,
profileUrl,
detailUrlTemplate
);
const time = formatTime(ts);
const text = esc(msg.content || '').replace(/\n/g, '<br>');
// Формирование вложений
let attachmentsHTML = '';
if (msg.has_attachments && msg.attachments && msg.attachments.length > 0) {
attachmentsHTML = '<div class="message-attachments mt-2">';
msg.attachments.forEach((att, index) => {
attachmentsHTML += '<div class="attachment-item mb-2">';
if (att.file_type === 'image') {
attachmentsHTML += `
<a href="${att.file_url}" target="_blank">
<img src="${att.file_url}"
alt="${esc(att.file_name)}"
loading="lazy"
class="chat-media chat-media--image rounded">
</a>`;
} else if (att.file_type === 'audio') {
attachmentsHTML += `
<audio controls class="w-100">
<source src="${att.file_url}" type="${att.mime_type}">
</audio>
<div class="small text-secondary">${esc(att.file_name)}</div>`;
} else if (att.file_type === 'video') {
attachmentsHTML += `
<video controls preload="metadata" playsinline class="chat-media chat-media--video">
<source src="${att.file_url}" type="${att.mime_type || 'video/mp4'}">
Ваш браузер не поддерживает видео.
</video>
<div class="small text-secondary">${esc(att.file_name)}</div>`;
} else {
const fileSize = att.file_size ? formatFileSize(att.file_size) : '';
attachmentsHTML += `
<a href="${att.file_url}"
class="d-flex align-items-center text-decoration-none p-2 rounded"
download="${esc(att.file_name)}">
<i class="bi-file-earmark fs-3 me-2"></i>
<div>
<div class="fw-semibold">${esc(att.file_name)}</div>
<div class="small text-secondary">${fileSize}</div>
</div>
</a>`;
}
attachmentsHTML += '</div>';
});
attachmentsHTML += '</div>';
}
const pendingStatus = isPending
? '<div class="message-status small text-secondary mt-2"><span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Отправляем…</div>'
: '';
const bubble = `
<div class="d-flex flex-column" style="max-width:80%;">
<div class="small text-secondary ${mine ? 'text-end' : ''}">
${who} · ${time}
</div>
<div class="mt-1 bubble ${mine ? 'bubble-me' : 'bubble-other'}">
${text ? text : ''}
${attachmentsHTML}
${pendingStatus}
</div>
</div>`;
// Аватар
const avaUrl = msg.avatar || avatarMap[msg.author_id] || "";
const ava = avatarHTML(avaUrl);
// Финальная сборка
if (!mine) {
const link = msg.author_url || detailUrlTemplate.replace('/0/', `/${msg.author_id}/`);
wrap.innerHTML = `<a class="me-2 text-decoration-none" href="${link}">${ava}</a>${bubble}`;
} else {
wrap.innerHTML = `${bubble}<a class="ms-2 text-decoration-none" href="${profileUrl}">${ava}</a>`;
}
return wrap;
}