SHA256
channels ux cleanup and create-flow recovery
This commit is contained in:
@@ -40,6 +40,7 @@ const MSG_SUBTYPE_REACTION_LIKE = 1;
|
||||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
||||
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
|
||||
const CREATE_CHANNEL_BODY_VERSION = 2;
|
||||
|
||||
function normalizeServerUrl(url) {
|
||||
const value = (url || '').trim();
|
||||
@@ -77,6 +78,17 @@ function opError(op, response) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function isLegacyCreateChannelFormatError(error) {
|
||||
const code = String(error?.code || '').trim().toUpperCase();
|
||||
const text = String(error?.message || '').toLowerCase();
|
||||
if (code === 'BAD_BLOCK_FORMAT') return true;
|
||||
return (
|
||||
text.includes('unknown body type/version') ||
|
||||
text.includes('unknown tech body type/version/subtype') ||
|
||||
text.includes('bad_block_format')
|
||||
);
|
||||
}
|
||||
|
||||
function makeClientInfo() {
|
||||
const ua = navigator.userAgent || 'unknown';
|
||||
return ua.slice(0, 50);
|
||||
@@ -267,6 +279,50 @@ function makeCreateChannelBodyBytes({ lineCode, prevLineNumber, prevLineHashHex,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeChannelDescription(value) {
|
||||
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
|
||||
const bytes = utf8Bytes(text);
|
||||
if (bytes.length > 200) {
|
||||
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function makeCreateChannelBodyV2Bytes({
|
||||
lineCode,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName,
|
||||
channelDescription = '',
|
||||
}) {
|
||||
const check = validateChannelDisplayName(channelName);
|
||||
if (!check.ok) throw new Error(channelNameErrorText(check.code));
|
||||
const cleanName = check.normalized;
|
||||
const cleanDescription = normalizeChannelDescription(channelDescription);
|
||||
|
||||
const nameBytes = utf8Bytes(cleanName);
|
||||
if (nameBytes.length < 1 || nameBytes.length > 255) {
|
||||
throw new Error('Channel name must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const descriptionBytes = utf8Bytes(cleanDescription);
|
||||
if (descriptionBytes.length > 200) {
|
||||
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(nameBytes.length),
|
||||
nameBytes,
|
||||
int16Bytes(descriptionBytes.length),
|
||||
descriptionBytes,
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Message text is required');
|
||||
@@ -828,7 +884,7 @@ export class AuthService {
|
||||
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
|
||||
}
|
||||
|
||||
async addBlockCreateChannel({ login, channelName, storagePwd }) {
|
||||
async addBlockCreateChannel({ login, channelName, channelDescription = '', storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
|
||||
@@ -866,25 +922,47 @@ export class AuthService {
|
||||
thisLineNumber = createdChannels.length + 1;
|
||||
}
|
||||
|
||||
const bodyBytes = makeCreateChannelBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName: cleanChannelName,
|
||||
});
|
||||
const submitCreate = async (useV2) => {
|
||||
const bodyBytes = useV2
|
||||
? makeCreateChannelBodyV2Bytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName: cleanChannelName,
|
||||
channelDescription,
|
||||
})
|
||||
: makeCreateChannelBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName: cleanChannelName,
|
||||
});
|
||||
|
||||
const payload = await this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TECH,
|
||||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TECH,
|
||||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||
msgVersion: useV2 ? CREATE_CHANNEL_BODY_VERSION : 1,
|
||||
bodyBytes,
|
||||
});
|
||||
};
|
||||
|
||||
let payload;
|
||||
let usedLegacyDescriptionFallback = false;
|
||||
try {
|
||||
payload = await submitCreate(true);
|
||||
} catch (error) {
|
||||
if (!isLegacyCreateChannelFormatError(error)) throw error;
|
||||
payload = await submitCreate(false);
|
||||
usedLegacyDescriptionFallback = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
usedLegacyDescriptionFallback,
|
||||
channel: {
|
||||
ownerBlockchainName: blockchainName,
|
||||
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
|
||||
|
||||
@@ -7,6 +7,11 @@ export function normalizeChannelDisplayName(value) {
|
||||
return String(value).trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function normalizeChannelDescription(value) {
|
||||
if (value == null) return '';
|
||||
return String(value).trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function toCanonicalChannelSlug(value) {
|
||||
const normalized = normalizeChannelDisplayName(value);
|
||||
if (!normalized) return '';
|
||||
@@ -76,4 +81,10 @@ export function channelNameErrorText(code) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function channelDescriptionErrorText(value) {
|
||||
const normalized = normalizeChannelDescription(value);
|
||||
if (new TextEncoder().encode(normalized).length > 200) {
|
||||
return 'Описание слишком длинное: максимум 200 байт UTF-8.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
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('');
|
||||
}
|
||||
Reference in New Issue
Block a user