SHiNE-server/shine-UI/js/services/channels-ux.js

223 lines
6.0 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.

const TOAST_HOST_ID = 'shine-toast-host';
const rtf = (() => {
try {
return new Intl.RelativeTimeFormat('ru', { numeric: 'auto' });
} catch {
return null;
}
})();
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function pickUnit(seconds) {
const abs = Math.abs(seconds);
if (abs < 60) return ['second', Math.round(seconds)];
const minutes = seconds / 60;
if (Math.abs(minutes) < 60) return ['minute', Math.round(minutes)];
const hours = minutes / 60;
if (Math.abs(hours) < 24) return ['hour', Math.round(hours)];
const days = hours / 24;
if (Math.abs(days) < 30) return ['day', Math.round(days)];
const months = days / 30;
if (Math.abs(months) < 12) return ['month', Math.round(months)];
const years = months / 12;
return ['year', Math.round(years)];
}
export function formatRelativeTime(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '—';
const now = Date.now();
const diffSeconds = (ts - now) / 1000;
const ageSeconds = now >= ts ? (now - ts) / 1000 : 0;
const ageHours = ageSeconds / 3600;
if (ageHours <= 10) {
const [unit, value] = pickUnit(diffSeconds);
if (rtf) return rtf.format(value, unit);
const absValue = Math.abs(value);
const suffix = value <= 0 ? 'назад' : 'через';
const labels = {
second: 'сек',
minute: 'мин',
hour: 'ч',
day: 'д',
month: 'мес',
year: 'г',
};
return `${suffix} ${absValue} ${labels[unit] || ''}`.trim();
}
try {
const dt = new Date(ts);
const nowDt = new Date(now);
const formatter = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
...(dt.getFullYear() !== nowDt.getFullYear() ? { year: 'numeric' } : {}),
hour: '2-digit',
minute: '2-digit',
});
return formatter.format(dt);
} catch {
return new Date(ts).toLocaleString();
}
}
function ensureToastHost() {
let host = document.getElementById(TOAST_HOST_ID);
if (host) return host;
host = document.createElement('div');
host.id = TOAST_HOST_ID;
host.className = 'toast-host';
document.body.append(host);
return host;
}
export function showToast(message, { kind = 'success', timeoutMs = 2500 } = {}) {
const text = String(message || '').trim();
if (!text) return;
const host = ensureToastHost();
const toast = document.createElement('div');
toast.className = `toast toast--${kind}`;
toast.textContent = text;
host.append(toast);
requestAnimationFrame(() => {
toast.classList.add('is-visible');
});
const hide = () => {
toast.classList.remove('is-visible');
toast.classList.add('is-hiding');
setTimeout(() => toast.remove(), 220);
};
setTimeout(hide, Math.max(1200, Number(timeoutMs) || 2500));
}
export function softHaptic(duration = 15) {
try {
if (navigator?.vibrate) navigator.vibrate(Math.max(5, Math.min(30, Number(duration) || 15)));
} catch {
// ignore
}
}
export function animatePress(el) {
if (!el) return;
el.classList.remove('is-springing');
// force reflow
// eslint-disable-next-line no-unused-expressions
el.offsetWidth;
el.classList.add('is-springing');
}
const CHANNEL_NOTIF_KEY = 'shine-channels-notify-v1';
export function readChannelNotificationsState() {
try {
const raw = localStorage.getItem(CHANNEL_NOTIF_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
} catch {
// ignore
}
return {};
}
export function writeChannelNotificationsState(nextState) {
try {
localStorage.setItem(CHANNEL_NOTIF_KEY, JSON.stringify(nextState || {}));
} catch {
// ignore
}
}
export function makeAuthorLabel(login, localNumber) {
const cleanLogin = String(login || 'автор');
const n = Number(localNumber);
if (!Number.isFinite(n) || n < 1) return cleanLogin;
return `${cleanLogin} · #${n}`;
}
export function createSkeletonCard(className = '') {
const card = document.createElement('div');
card.className = `card skeleton-card ${className}`.trim();
card.innerHTML = `
<div class="skeleton-line w-40"></div>
<div class="skeleton-line w-90"></div>
<div class="skeleton-line w-70"></div>
`;
return card;
}
export function normalizeChannelDescription(value) {
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
if (!text) return '';
const chars = Array.from(text);
if (chars.length <= 200) return text;
return chars.slice(0, 200).join('');
}
function fallbackCopyText(text) {
const ta = document.createElement('textarea');
ta.value = String(text || '');
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.pointerEvents = 'none';
document.body.append(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
}
export async function shareOrCopyLink({ title = '', text = '', url = '' }) {
const link = String(url || '').trim();
if (!link) {
throw new Error('Ссылка для передачи не подготовлена.');
}
const payload = { title: String(title || '').trim(), text: String(text || '').trim(), url: link };
if (navigator?.share) {
try {
await navigator.share(payload);
return 'shared';
} catch (error) {
if (error?.name === 'AbortError') return 'cancelled';
// fallback to copy path
}
}
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(link);
return 'copied';
}
if (fallbackCopyText(link)) {
return 'copied';
}
throw new Error('Не удалось передать ссылку.');
}
export async function longPressFeel(el, delayMs = 130) {
const node = el instanceof Element ? el : null;
if (node) node.classList.add('is-long-press');
await new Promise((resolve) => setTimeout(resolve, Math.max(60, Number(delayMs) || 130)));
if (node) node.classList.remove('is-long-press');
}