Channels UI + read/unread + unique views + style polish
This commit is contained in:
parent
78d6124f2a
commit
c0dfa6c7ab
@ -1,4 +1,4 @@
|
|||||||
import { resolveToolbarActive } from '../router.js';
|
import { resolveToolbarActive } from '../router.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
@ -31,7 +31,8 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
const isProfile = item.pageId === 'profile-view';
|
const isProfile = item.pageId === 'profile-view';
|
||||||
const isMessages = item.pageId === 'messages-list';
|
const isMessages = item.pageId === 'messages-list';
|
||||||
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}`;
|
const isNetwork = item.pageId === 'network-view';
|
||||||
|
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}`;
|
||||||
if (isProfile) {
|
if (isProfile) {
|
||||||
btn.innerHTML = `
|
btn.innerHTML = `
|
||||||
<span>${item.icon}</span>
|
<span>${item.icon}</span>
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
|||||||
import {
|
import {
|
||||||
animatePress,
|
animatePress,
|
||||||
createSkeletonCard,
|
createSkeletonCard,
|
||||||
|
longPressFeel,
|
||||||
|
shareOrCopyLink,
|
||||||
showToast,
|
showToast,
|
||||||
softHaptic,
|
softHaptic,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
@ -13,6 +15,7 @@ export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
|||||||
|
|
||||||
const pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
const pendingThreadScroll = new Map();
|
const pendingThreadScroll = new Map();
|
||||||
|
const revealedCountersByRoute = new Map();
|
||||||
|
|
||||||
function logThreadRuntimeError(stage, error, context = {}) {
|
function logThreadRuntimeError(stage, error, context = {}) {
|
||||||
const message = String(error?.message || error || 'thread runtime error');
|
const message = String(error?.message || error || 'thread runtime error');
|
||||||
@ -63,6 +66,36 @@ function messageRefKey(messageRef) {
|
|||||||
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRevealedCounterSet(routeKey) {
|
||||||
|
const key = String(routeKey || '').trim();
|
||||||
|
if (!key) return new Set();
|
||||||
|
let bucket = revealedCountersByRoute.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = new Set();
|
||||||
|
revealedCountersByRoute.set(key, bucket);
|
||||||
|
}
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCounterVisible(routeKey, counterKey) {
|
||||||
|
const key = String(counterKey || '').trim();
|
||||||
|
if (!key) return false;
|
||||||
|
return getRevealedCounterSet(routeKey).has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealCounter(routeKey, counterKey) {
|
||||||
|
const key = String(counterKey || '').trim();
|
||||||
|
if (!key) return;
|
||||||
|
getRevealedCounterSet(routeKey).add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAbsoluteRouteUrl(routePath = '') {
|
||||||
|
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.hash = `#/${cleanRoute}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
function parseThreadSelector(route) {
|
function parseThreadSelector(route) {
|
||||||
const params = route?.params || {};
|
const params = route?.params || {};
|
||||||
const blockNumber = toSafeInt(params.messageBlockNumber);
|
const blockNumber = toSafeInt(params.messageBlockNumber);
|
||||||
@ -119,6 +152,19 @@ function buildBackRoute(selector) {
|
|||||||
return 'channels-list';
|
return 'channels-list';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildThreadRouteFromTarget(target, selector) {
|
||||||
|
if (!target || !selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
|
||||||
|
return [
|
||||||
|
'channel-thread-view',
|
||||||
|
encodeRoutePart(target.blockchainName),
|
||||||
|
target.blockNumber,
|
||||||
|
normalizeRouteHash(target.blockHash),
|
||||||
|
encodeRoutePart(selector.channel.ownerBlockchainName),
|
||||||
|
selector.channel.rootBlockNumber,
|
||||||
|
normalizeRouteHash(selector.channel.rootBlockHash),
|
||||||
|
].join('/');
|
||||||
|
}
|
||||||
|
|
||||||
function buildTargetFromNode(node) {
|
function buildTargetFromNode(node) {
|
||||||
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
||||||
const blockNumber = Number(node?.messageRef?.blockNumber);
|
const blockNumber = Number(node?.messageRef?.blockNumber);
|
||||||
@ -212,7 +258,7 @@ function openReplyModal({ onSubmit }) {
|
|||||||
if (textEl) textEl.focus();
|
if (textEl) textEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNodeCard(node, heading, handlers, localNumber) {
|
function renderNodeCard(node, heading, handlers, localNumber, routeKey, options = {}) {
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack thread-node-card';
|
card.className = 'card stack thread-node-card';
|
||||||
|
|
||||||
@ -222,9 +268,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
const replies = Number(node?.repliesCount || 0);
|
const replies = Number(node?.repliesCount || 0);
|
||||||
const versions = Number(node?.versionsTotal || 1);
|
const versions = Number(node?.versionsTotal || 1);
|
||||||
|
|
||||||
|
const headingText = String(heading || '').trim();
|
||||||
|
if (headingText) {
|
||||||
const headingEl = document.createElement('strong');
|
const headingEl = document.createElement('strong');
|
||||||
headingEl.className = 'thread-node-heading';
|
headingEl.className = 'thread-node-heading';
|
||||||
headingEl.textContent = heading;
|
headingEl.textContent = headingText;
|
||||||
|
card.append(headingEl);
|
||||||
|
}
|
||||||
|
|
||||||
const meta = document.createElement('p');
|
const meta = document.createElement('p');
|
||||||
meta.className = 'thread-node-meta';
|
meta.className = 'thread-node-meta';
|
||||||
@ -241,12 +291,36 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
stats.className = 'thread-node-stats';
|
stats.className = 'thread-node-stats';
|
||||||
stats.textContent = `Лайки: ${likes}, ответы: ${replies}, версий: ${versions}`;
|
stats.textContent = `Лайки: ${likes}, ответы: ${replies}, версий: ${versions}`;
|
||||||
|
|
||||||
card.append(headingEl, meta, body, stats);
|
card.append(meta, body, stats);
|
||||||
|
|
||||||
|
if (options.showViews === true) {
|
||||||
|
const views = document.createElement('p');
|
||||||
|
views.className = 'thread-node-views';
|
||||||
|
views.textContent = `Просмотры: ${Number(node?.viewCount || 0)}`;
|
||||||
|
card.append(views);
|
||||||
|
}
|
||||||
|
|
||||||
const target = buildTargetFromNode(node);
|
const target = buildTargetFromNode(node);
|
||||||
|
const refKey = messageRefKey(target);
|
||||||
|
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true;
|
||||||
|
if (!countersVisible) {
|
||||||
|
card.classList.remove('is-counters-visible');
|
||||||
|
stats.classList.add('is-hidden');
|
||||||
|
} else {
|
||||||
|
card.classList.add('is-counters-visible');
|
||||||
|
stats.classList.remove('is-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const revealCounters = () => {
|
||||||
|
if (!refKey) return;
|
||||||
|
revealCounter(routeKey, refKey);
|
||||||
|
card.classList.add('is-counters-visible');
|
||||||
|
stats.classList.remove('is-hidden');
|
||||||
|
};
|
||||||
|
card.addEventListener('click', revealCounters);
|
||||||
|
|
||||||
if (!target || !handlers) return card;
|
if (!target || !handlers) return card;
|
||||||
|
|
||||||
const refKey = messageRefKey(target);
|
|
||||||
if (refKey) card.dataset.messageKey = refKey;
|
if (refKey) card.dataset.messageKey = refKey;
|
||||||
|
|
||||||
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
|
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
|
||||||
@ -263,13 +337,15 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
likeButton.type = 'button';
|
likeButton.type = 'button';
|
||||||
likeButton.className = 'secondary-btn thread-like-btn';
|
likeButton.className = 'secondary-btn thread-like-btn';
|
||||||
if (isLiked) likeButton.classList.add('is-liked');
|
if (isLiked) likeButton.classList.add('is-liked');
|
||||||
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
|
likeButton.textContent = isPending ? '✦ Сияние...' : '✦ Сияние';
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
likeButton.addEventListener('click', async (event) => {
|
likeButton.addEventListener('click', async (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
|
revealCounters();
|
||||||
|
await longPressFeel(event.currentTarget, 130);
|
||||||
likeButton.disabled = true;
|
likeButton.disabled = true;
|
||||||
likeButton.textContent = 'Выполняется...';
|
likeButton.textContent = 'Сияние...';
|
||||||
try {
|
try {
|
||||||
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -285,20 +361,32 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
const replyButton = document.createElement('button');
|
const replyButton = document.createElement('button');
|
||||||
replyButton.type = 'button';
|
replyButton.type = 'button';
|
||||||
replyButton.className = 'secondary-btn thread-reply-btn';
|
replyButton.className = 'secondary-btn thread-reply-btn';
|
||||||
replyButton.textContent = 'Ответить';
|
replyButton.textContent = '⟳ Отразить';
|
||||||
replyButton.addEventListener('click', (event) => {
|
replyButton.addEventListener('click', (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
|
revealCounters();
|
||||||
openReplyModal({
|
openReplyModal({
|
||||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(likeButton, replyButton);
|
const shareButton = document.createElement('button');
|
||||||
|
shareButton.type = 'button';
|
||||||
|
shareButton.className = 'secondary-btn thread-share-btn';
|
||||||
|
shareButton.textContent = '↗ Транслировать';
|
||||||
|
shareButton.addEventListener('click', async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
revealCounters();
|
||||||
|
await handlers.onShare(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(likeButton, replyButton, shareButton);
|
||||||
card.append(actions);
|
card.append(actions);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
function renderDescendants(items, handlers, nextNumber, routeKey, depth = 0) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'stack';
|
wrap.className = 'stack';
|
||||||
|
|
||||||
@ -306,13 +394,13 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
|||||||
normalized.forEach((branch, index) => {
|
normalized.forEach((branch, index) => {
|
||||||
try {
|
try {
|
||||||
const nodeNumber = nextNumber();
|
const nodeNumber = nextNumber();
|
||||||
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber);
|
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber, routeKey);
|
||||||
row.classList.add('thread-node-level');
|
row.classList.add('thread-node-level');
|
||||||
row.style.setProperty('--depth', String(Math.min(depth, 4)));
|
row.style.setProperty('--depth', String(Math.min(depth, 4)));
|
||||||
wrap.append(row);
|
wrap.append(row);
|
||||||
|
|
||||||
if (Array.isArray(branch?.children) && branch.children.length) {
|
if (Array.isArray(branch?.children) && branch.children.length) {
|
||||||
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1));
|
wrap.append(renderDescendants(branch.children, handlers, nextNumber, routeKey, depth + 1));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
|
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
|
||||||
@ -444,6 +532,22 @@ export function render({ navigate, route }) {
|
|||||||
showStatus('');
|
showStatus('');
|
||||||
rerender();
|
rerender();
|
||||||
},
|
},
|
||||||
|
onShare: async (target) => {
|
||||||
|
try {
|
||||||
|
const routePath = buildThreadRouteFromTarget(target, selector);
|
||||||
|
if (!routePath) throw new Error('Не удалось подготовить ссылку на тред.');
|
||||||
|
const result = await shareOrCopyLink({
|
||||||
|
title: 'SHiNE · Тред',
|
||||||
|
text: 'Сообщение из треда SHiNE',
|
||||||
|
url: buildAbsoluteRouteUrl(routePath),
|
||||||
|
});
|
||||||
|
if (result === 'copied') showToast('Ссылка скопирована');
|
||||||
|
if (result === 'shared') showToast('Ссылка передана');
|
||||||
|
if (result === 'copied' || result === 'shared') softHaptic(10);
|
||||||
|
} catch (error) {
|
||||||
|
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
onActionError: (error, action) => {
|
onActionError: (error, action) => {
|
||||||
const fallback = action === 'unlike'
|
const fallback = action === 'unlike'
|
||||||
? 'Не удалось убрать лайк.'
|
? 'Не удалось убрать лайк.'
|
||||||
@ -479,11 +583,6 @@ export function render({ navigate, route }) {
|
|||||||
const focus = payload?.focus || null;
|
const focus = payload?.focus || null;
|
||||||
const descendants = Array.isArray(payload?.descendants) ? payload.descendants : [];
|
const descendants = Array.isArray(payload?.descendants) ? payload.descendants : [];
|
||||||
|
|
||||||
const summary = document.createElement('div');
|
|
||||||
summary.className = 'card thread-summary';
|
|
||||||
summary.textContent = `Предки: ${ancestors.length}, ответы: ${descendants.length}`;
|
|
||||||
screen.append(summary);
|
|
||||||
|
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
const nextNumber = () => {
|
const nextNumber = () => {
|
||||||
seq += 1;
|
seq += 1;
|
||||||
@ -498,7 +597,7 @@ export function render({ navigate, route }) {
|
|||||||
title.textContent = 'Предыдущие сообщения';
|
title.textContent = 'Предыдущие сообщения';
|
||||||
ancestorsWrap.append(title);
|
ancestorsWrap.append(title);
|
||||||
ancestors.forEach((node, index) => {
|
ancestors.forEach((node, index) => {
|
||||||
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
|
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber(), routeKey));
|
||||||
});
|
});
|
||||||
screen.append(ancestorsWrap);
|
screen.append(ancestorsWrap);
|
||||||
}
|
}
|
||||||
@ -506,10 +605,7 @@ export function render({ navigate, route }) {
|
|||||||
if (focus) {
|
if (focus) {
|
||||||
const focusWrap = document.createElement('div');
|
const focusWrap = document.createElement('div');
|
||||||
focusWrap.className = 'stack thread-block thread-block--focus';
|
focusWrap.className = 'stack thread-block thread-block--focus';
|
||||||
const title = document.createElement('h3');
|
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber(), routeKey, { showViews: true }));
|
||||||
title.className = 'section-title';
|
|
||||||
title.textContent = 'Текущее сообщение';
|
|
||||||
focusWrap.append(title, renderNodeCard(focus, 'Выбранное сообщение', handlers, nextNumber()));
|
|
||||||
screen.append(focusWrap);
|
screen.append(focusWrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -521,7 +617,7 @@ export function render({ navigate, route }) {
|
|||||||
descendantsWrap.append(descendantsTitle);
|
descendantsWrap.append(descendantsTitle);
|
||||||
|
|
||||||
if (descendants.length) {
|
if (descendants.length) {
|
||||||
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
|
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber, routeKey));
|
||||||
} else {
|
} else {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'card meta-muted';
|
empty.className = 'card meta-muted';
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
|||||||
import {
|
import {
|
||||||
animatePress,
|
animatePress,
|
||||||
createSkeletonCard,
|
createSkeletonCard,
|
||||||
|
formatRelativeTime,
|
||||||
|
longPressFeel,
|
||||||
|
shareOrCopyLink,
|
||||||
showToast,
|
showToast,
|
||||||
softHaptic,
|
softHaptic,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
@ -17,6 +20,10 @@ export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
|||||||
|
|
||||||
const pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
const pendingScrollByRoute = new Map();
|
const pendingScrollByRoute = new Map();
|
||||||
|
const revealedCountersByRoute = new Map();
|
||||||
|
const seenFlushTimersByRoute = new Map();
|
||||||
|
const seenPendingByRoute = new Map();
|
||||||
|
const firstUnreadJumpByRoute = new Map();
|
||||||
|
|
||||||
function isChannelsDemoMode() {
|
function isChannelsDemoMode() {
|
||||||
try {
|
try {
|
||||||
@ -66,6 +73,59 @@ function messageRefKey(messageRef) {
|
|||||||
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMessageRefKey(key) {
|
||||||
|
const raw = String(key || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const parts = raw.split(':');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const blockNumber = Number(parts[1]);
|
||||||
|
const blockHash = normalizeMessageHash(parts[2]);
|
||||||
|
if (!parts[0] || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
||||||
|
return {
|
||||||
|
blockchainName: parts[0],
|
||||||
|
blockNumber,
|
||||||
|
blockHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockRefToMessageKey(blockRef, fallbackBch = '') {
|
||||||
|
const blockNumber = toSafeInt(blockRef?.blockNumber);
|
||||||
|
const blockHash = normalizeMessageHash(blockRef?.blockHash);
|
||||||
|
const blockchainName = String(fallbackBch || '').trim();
|
||||||
|
if (!blockchainName || blockNumber == null || !blockHash) return '';
|
||||||
|
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRevealedCounterSet(routeKey) {
|
||||||
|
const key = String(routeKey || '').trim();
|
||||||
|
if (!key) return new Set();
|
||||||
|
let bucket = revealedCountersByRoute.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = new Set();
|
||||||
|
revealedCountersByRoute.set(key, bucket);
|
||||||
|
}
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCounterVisible(routeKey, counterKey) {
|
||||||
|
const key = String(counterKey || '').trim();
|
||||||
|
if (!key) return false;
|
||||||
|
return getRevealedCounterSet(routeKey).has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealCounter(routeKey, counterKey) {
|
||||||
|
const key = String(counterKey || '').trim();
|
||||||
|
if (!key) return;
|
||||||
|
getRevealedCounterSet(routeKey).add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAbsoluteRouteUrl(routePath = '') {
|
||||||
|
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.hash = `#/${cleanRoute}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
function channelDescriptionParamKey(selector) {
|
function channelDescriptionParamKey(selector) {
|
||||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||||||
const rootNo = Number(selector?.channelRootBlockNumber);
|
const rootNo = Number(selector?.channelRootBlockNumber);
|
||||||
@ -165,6 +225,43 @@ function resolveMessageText(message) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toTimestampMs(...candidates) {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate == null) continue;
|
||||||
|
if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) {
|
||||||
|
return candidate > 1e12 ? Math.round(candidate) : Math.round(candidate * 1000);
|
||||||
|
}
|
||||||
|
if (typeof candidate === 'string') {
|
||||||
|
const trimmed = candidate.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const asNum = Number(trimmed);
|
||||||
|
if (Number.isFinite(asNum) && asNum > 0) {
|
||||||
|
return asNum > 1e12 ? Math.round(asNum) : Math.round(asNum * 1000);
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(trimmed);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMessageTimestampMs(message) {
|
||||||
|
return toTimestampMs(
|
||||||
|
message?.messageTimeMs,
|
||||||
|
message?.message_time_ms,
|
||||||
|
message?.timeMs,
|
||||||
|
message?.time_ms,
|
||||||
|
message?.timestampMs,
|
||||||
|
message?.timestamp_ms,
|
||||||
|
message?.createdAtMs,
|
||||||
|
message?.created_at_ms,
|
||||||
|
message?.messageTime,
|
||||||
|
message?.createdAt,
|
||||||
|
message?.created_at,
|
||||||
|
message?.timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function openAboutChannelModal(channel) {
|
function openAboutChannelModal(channel) {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@ -405,6 +502,9 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
body: resolvedText || '(пусто)',
|
body: resolvedText || '(пусто)',
|
||||||
likesCount: Number(message?.likesCount || 0),
|
likesCount: Number(message?.likesCount || 0),
|
||||||
repliesCount: Number(message?.repliesCount || 0),
|
repliesCount: Number(message?.repliesCount || 0),
|
||||||
|
viewCount: Number(message?.viewCount || 0),
|
||||||
|
seenByMe: message?.seenByMe === true,
|
||||||
|
timestampMs: resolveMessageTimestampMs(message),
|
||||||
messageRef,
|
messageRef,
|
||||||
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
||||||
};
|
};
|
||||||
@ -420,6 +520,8 @@ async function loadFromApi(route, channelId) {
|
|||||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||||
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
|
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
|
||||||
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
||||||
|
const firstUnreadKey = blockRefToMessageKey(payload.firstUnreadMessageRef, selector.ownerBlockchainName);
|
||||||
|
const unreadFromPayload = Number(payload.unreadCount || 0);
|
||||||
|
|
||||||
const readDescription = async () => {
|
const readDescription = async () => {
|
||||||
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
|
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
|
||||||
@ -445,6 +547,10 @@ async function loadFromApi(route, channelId) {
|
|||||||
ownerName: ownerLogin || 'неизвестно',
|
ownerName: ownerLogin || 'неизвестно',
|
||||||
},
|
},
|
||||||
posts,
|
posts,
|
||||||
|
unreadCount: Number.isFinite(unreadFromPayload)
|
||||||
|
? Math.max(0, unreadFromPayload)
|
||||||
|
: posts.filter((post) => post.seenByMe !== true).length,
|
||||||
|
firstUnreadKey,
|
||||||
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
|
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
|
||||||
selector,
|
selector,
|
||||||
};
|
};
|
||||||
@ -516,13 +622,29 @@ function applyPendingScroll(screen, routeKey) {
|
|||||||
setTimeout(doScroll, 20);
|
setTimeout(doScroll, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
|
function renderPostCard(post, {
|
||||||
|
navigate,
|
||||||
|
routeKey,
|
||||||
|
selector,
|
||||||
|
onToggleLike,
|
||||||
|
onReply,
|
||||||
|
onShare,
|
||||||
|
}) {
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack channel-message-card';
|
card.className = 'card stack channel-message-card';
|
||||||
|
|
||||||
|
const topRow = document.createElement('div');
|
||||||
|
topRow.className = 'channel-message-top';
|
||||||
|
|
||||||
|
const avatar = document.createElement('div');
|
||||||
|
avatar.className = 'channel-message-avatar';
|
||||||
|
avatar.textContent = String(post.authorLogin || 'A').trim().charAt(0).toUpperCase() || 'A';
|
||||||
|
|
||||||
|
const authorBlock = document.createElement('div');
|
||||||
|
authorBlock.className = 'channel-message-author';
|
||||||
|
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.className = 'channel-message-title author-line';
|
title.className = 'channel-message-title author-line';
|
||||||
|
|
||||||
const loginEl = document.createElement('span');
|
const loginEl = document.createElement('span');
|
||||||
loginEl.className = 'author-line-login';
|
loginEl.className = 'author-line-login';
|
||||||
loginEl.textContent = post.authorLogin;
|
loginEl.textContent = post.authorLogin;
|
||||||
@ -531,22 +653,41 @@ function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
|
|||||||
numberEl.className = 'author-line-num';
|
numberEl.className = 'author-line-num';
|
||||||
numberEl.textContent = `· #${post.localNumber}`;
|
numberEl.textContent = `· #${post.localNumber}`;
|
||||||
|
|
||||||
|
const timestamp = document.createElement('div');
|
||||||
|
timestamp.className = 'channel-message-time';
|
||||||
|
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||||
|
|
||||||
title.append(loginEl, numberEl);
|
title.append(loginEl, numberEl);
|
||||||
|
authorBlock.append(title, timestamp);
|
||||||
|
topRow.append(avatar, authorBlock);
|
||||||
|
|
||||||
const body = document.createElement('p');
|
const body = document.createElement('p');
|
||||||
body.className = 'channel-message-body';
|
body.className = 'channel-message-body';
|
||||||
body.textContent = post.body;
|
body.textContent = post.body;
|
||||||
|
|
||||||
const stats = document.createElement('p');
|
const views = document.createElement('p');
|
||||||
stats.className = 'channel-message-stats';
|
views.className = 'channel-message-views';
|
||||||
stats.textContent = `Лайки: ${post.likesCount || 0}, ответы: ${post.repliesCount || 0}`;
|
views.textContent = `Просмотры: ${Number(post.viewCount || 0)}`;
|
||||||
|
|
||||||
card.append(title, body, stats);
|
card.append(topRow, body, views);
|
||||||
|
|
||||||
const refKey = messageRefKey(post.messageRef);
|
const refKey = messageRefKey(post.messageRef);
|
||||||
if (refKey) {
|
if (refKey) {
|
||||||
card.dataset.messageKey = refKey;
|
card.dataset.messageKey = refKey;
|
||||||
}
|
}
|
||||||
|
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true;
|
||||||
|
if (!countersVisible) {
|
||||||
|
card.classList.remove('is-counters-visible');
|
||||||
|
} else {
|
||||||
|
card.classList.add('is-counters-visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const revealCounters = () => {
|
||||||
|
if (!refKey) return;
|
||||||
|
revealCounter(routeKey, refKey);
|
||||||
|
card.classList.add('is-counters-visible');
|
||||||
|
};
|
||||||
|
card.addEventListener('click', revealCounters);
|
||||||
|
|
||||||
if (!post.messageRef || !selector) return card;
|
if (!post.messageRef || !selector) return card;
|
||||||
|
|
||||||
@ -558,25 +699,36 @@ function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
|
|||||||
|
|
||||||
const likeButton = document.createElement('button');
|
const likeButton = document.createElement('button');
|
||||||
likeButton.type = 'button';
|
likeButton.type = 'button';
|
||||||
likeButton.className = 'secondary-btn channel-action-like';
|
likeButton.className = 'channel-action-item channel-action-like';
|
||||||
const isLiked = post.reactionState === 'liked';
|
const isLiked = post.reactionState === 'liked';
|
||||||
if (isLiked) likeButton.classList.add('is-liked');
|
if (isLiked) likeButton.classList.add('is-liked');
|
||||||
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
|
likeButton.innerHTML = `
|
||||||
|
<span class="channel-action-icon" aria-hidden="true">✦</span>
|
||||||
|
<span class="channel-action-label">${isPending ? 'Сияние...' : 'Сияние'}</span>
|
||||||
|
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
||||||
|
`;
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
likeButton.addEventListener('click', async (event) => {
|
likeButton.addEventListener('click', async (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
|
revealCounters();
|
||||||
|
await longPressFeel(event.currentTarget, 130);
|
||||||
likeButton.disabled = true;
|
likeButton.disabled = true;
|
||||||
likeButton.textContent = 'Выполняется...';
|
const labelEl = likeButton.querySelector('.channel-action-label');
|
||||||
|
if (labelEl) labelEl.textContent = 'Сияние...';
|
||||||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
||||||
});
|
});
|
||||||
|
|
||||||
const replyButton = document.createElement('button');
|
const replyButton = document.createElement('button');
|
||||||
replyButton.type = 'button';
|
replyButton.type = 'button';
|
||||||
replyButton.className = 'secondary-btn channel-action-reply';
|
replyButton.className = 'channel-action-item channel-action-reply';
|
||||||
replyButton.textContent = 'Ответить';
|
replyButton.innerHTML = `
|
||||||
|
<span class="channel-action-icon" aria-hidden="true">⟳</span>
|
||||||
|
<span class="channel-action-label">Отразить</span>
|
||||||
|
`;
|
||||||
replyButton.addEventListener('click', (event) => {
|
replyButton.addEventListener('click', (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
|
revealCounters();
|
||||||
openReplyModal({
|
openReplyModal({
|
||||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||||
});
|
});
|
||||||
@ -584,15 +736,35 @@ function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
|
|||||||
|
|
||||||
const openThreadButton = document.createElement('button');
|
const openThreadButton = document.createElement('button');
|
||||||
openThreadButton.type = 'button';
|
openThreadButton.type = 'button';
|
||||||
openThreadButton.className = 'secondary-btn channel-action-thread';
|
openThreadButton.className = 'channel-action-item channel-action-thread';
|
||||||
openThreadButton.textContent = 'Открыть тред';
|
openThreadButton.innerHTML = `
|
||||||
|
<span class="channel-action-icon" aria-hidden="true">#</span>
|
||||||
|
<span class="channel-action-label">Тред</span>
|
||||||
|
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||||
|
`;
|
||||||
openThreadButton.addEventListener('click', (event) => {
|
openThreadButton.addEventListener('click', (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
|
revealCounters();
|
||||||
const route = buildThreadRoute(post.messageRef, selector);
|
const route = buildThreadRoute(post.messageRef, selector);
|
||||||
if (route) navigate(route);
|
if (route) navigate(route);
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(likeButton, replyButton, openThreadButton);
|
const shareButton = document.createElement('button');
|
||||||
|
shareButton.type = 'button';
|
||||||
|
shareButton.className = 'channel-action-item channel-action-share';
|
||||||
|
shareButton.innerHTML = `
|
||||||
|
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||||
|
<span class="channel-action-label">Транслировать</span>
|
||||||
|
`;
|
||||||
|
shareButton.addEventListener('click', async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
revealCounters();
|
||||||
|
const route = buildThreadRoute(post.messageRef, selector);
|
||||||
|
await onShare(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(likeButton, replyButton, openThreadButton, shareButton);
|
||||||
card.append(actions);
|
card.append(actions);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
@ -648,15 +820,38 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
|
|
||||||
const feed = document.createElement('div');
|
const feed = document.createElement('div');
|
||||||
feed.className = 'stack channel-feed';
|
feed.className = 'stack channel-feed';
|
||||||
|
const unreadDivider = document.createElement('div');
|
||||||
|
unreadDivider.className = 'channels-unread-divider';
|
||||||
|
unreadDivider.textContent = 'Непрочитанные сообщения';
|
||||||
|
const unreadJump = document.createElement('button');
|
||||||
|
unreadJump.type = 'button';
|
||||||
|
unreadJump.className = 'channels-unread-jump';
|
||||||
|
unreadJump.innerHTML = `
|
||||||
|
<span class="channels-unread-jump-icon" aria-hidden="true">↓</span>
|
||||||
|
<span class="channels-unread-jump-badge"></span>
|
||||||
|
`;
|
||||||
|
const unreadBadge = unreadJump.querySelector('.channels-unread-jump-badge');
|
||||||
|
const postsByKey = new Map();
|
||||||
|
const unreadKeys = new Set();
|
||||||
|
let seenFlushInFlight = false;
|
||||||
|
let seenObserver = null;
|
||||||
|
|
||||||
if (channelData.posts.length) {
|
if (channelData.posts.length) {
|
||||||
channelData.posts.forEach((post) => {
|
channelData.posts.forEach((post) => {
|
||||||
feed.append(renderPostCard(post, {
|
const row = renderPostCard(post, {
|
||||||
navigate,
|
navigate,
|
||||||
|
routeKey,
|
||||||
selector: channelData.selector,
|
selector: channelData.selector,
|
||||||
onToggleLike: handlers.onToggleLike,
|
onToggleLike: handlers.onToggleLike,
|
||||||
onReply: handlers.onReply,
|
onReply: handlers.onReply,
|
||||||
}));
|
onShare: handlers.onShare,
|
||||||
|
});
|
||||||
|
const key = messageRefKey(post.messageRef);
|
||||||
|
if (key) {
|
||||||
|
postsByKey.set(key, post);
|
||||||
|
if (post.seenByMe !== true) unreadKeys.add(key);
|
||||||
|
}
|
||||||
|
feed.append(row);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
@ -665,6 +860,102 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
feed.append(empty);
|
feed.append(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncUnreadState = () => {
|
||||||
|
unreadKeys.clear();
|
||||||
|
postsByKey.forEach((post, key) => {
|
||||||
|
if (post.seenByMe !== true) unreadKeys.add(key);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUnreadJump = () => {
|
||||||
|
const unreadCount = unreadKeys.size;
|
||||||
|
unreadJump.classList.toggle('is-visible', unreadCount > 0);
|
||||||
|
unreadJump.hidden = unreadCount <= 0;
|
||||||
|
if (unreadBadge) unreadBadge.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountUnreadDivider = () => {
|
||||||
|
unreadDivider.remove();
|
||||||
|
if (!unreadKeys.size) return;
|
||||||
|
const firstUnread = channelData.posts.find((post) => {
|
||||||
|
const key = messageRefKey(post.messageRef);
|
||||||
|
return key && unreadKeys.has(key);
|
||||||
|
});
|
||||||
|
const firstUnreadKey = messageRefKey(firstUnread?.messageRef);
|
||||||
|
if (!firstUnreadKey) return;
|
||||||
|
const target = feed.querySelector(`[data-message-key="${firstUnreadKey}"]`);
|
||||||
|
if (target) {
|
||||||
|
feed.insertBefore(unreadDivider, target);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const routePending = (() => {
|
||||||
|
let bucket = seenPendingByRoute.get(routeKey);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = new Set();
|
||||||
|
seenPendingByRoute.set(routeKey, bucket);
|
||||||
|
}
|
||||||
|
return bucket;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const scheduleSeenFlush = () => {
|
||||||
|
const oldTimer = seenFlushTimersByRoute.get(routeKey);
|
||||||
|
if (oldTimer) clearTimeout(oldTimer);
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
seenFlushTimersByRoute.delete(routeKey);
|
||||||
|
if (seenFlushInFlight) return;
|
||||||
|
|
||||||
|
const pendingKeys = [...routePending].filter((key) => {
|
||||||
|
const post = postsByKey.get(key);
|
||||||
|
return !!post && post.seenByMe !== true;
|
||||||
|
});
|
||||||
|
if (!pendingKeys.length) return;
|
||||||
|
|
||||||
|
const refs = pendingKeys
|
||||||
|
.map((key) => parseMessageRefKey(key))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!refs.length) return;
|
||||||
|
|
||||||
|
pendingKeys.forEach((key) => routePending.delete(key));
|
||||||
|
seenFlushInFlight = true;
|
||||||
|
try {
|
||||||
|
await handlers.onMarkSeenBatch(refs);
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
const key = messageRefKey(ref);
|
||||||
|
const post = key ? postsByKey.get(key) : null;
|
||||||
|
if (post) post.seenByMe = true;
|
||||||
|
});
|
||||||
|
syncUnreadState();
|
||||||
|
mountUnreadDivider();
|
||||||
|
updateUnreadJump();
|
||||||
|
} catch (error) {
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
const key = messageRefKey(ref);
|
||||||
|
if (!key) return;
|
||||||
|
const node = feed.querySelector(`[data-message-key="${key}"]`);
|
||||||
|
if (node) seenObserver?.observe(node);
|
||||||
|
});
|
||||||
|
handlers.onSeenError?.(error);
|
||||||
|
} finally {
|
||||||
|
seenFlushInFlight = false;
|
||||||
|
if (routePending.size) scheduleSeenFlush();
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
|
seenFlushTimersByRoute.set(routeKey, timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
unreadJump.addEventListener('click', () => {
|
||||||
|
const unreadPosts = channelData.posts.filter((post) => {
|
||||||
|
const key = messageRefKey(post.messageRef);
|
||||||
|
return key && unreadKeys.has(key);
|
||||||
|
});
|
||||||
|
const targetPost = unreadPosts.length ? unreadPosts[unreadPosts.length - 1] : channelData.posts[channelData.posts.length - 1];
|
||||||
|
const key = messageRefKey(targetPost?.messageRef);
|
||||||
|
if (!key) return;
|
||||||
|
const target = feed.querySelector(`[data-message-key="${key}"]`);
|
||||||
|
target?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||||
|
});
|
||||||
|
|
||||||
if (channelData.isOwnChannel) {
|
if (channelData.isOwnChannel) {
|
||||||
actionButton.addEventListener('click', (event) => {
|
actionButton.addEventListener('click', (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
@ -682,8 +973,58 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
backButton.textContent = 'Назад к каналам';
|
backButton.textContent = 'Назад к каналам';
|
||||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
|
||||||
screen.append(head, actionButton, feed, backButton);
|
screen.append(head, actionButton, feed, backButton, unreadJump);
|
||||||
|
|
||||||
|
if (state.session.login && channelData.selector && channelData.posts.length) {
|
||||||
|
seenObserver = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting || entry.intersectionRatio < 0.6) return;
|
||||||
|
const key = String(entry.target?.dataset?.messageKey || '').trim();
|
||||||
|
if (!key) return;
|
||||||
|
const post = postsByKey.get(key);
|
||||||
|
if (!post || post.seenByMe === true) return;
|
||||||
|
routePending.add(key);
|
||||||
|
seenObserver?.unobserve(entry.target);
|
||||||
|
});
|
||||||
|
if (routePending.size) scheduleSeenFlush();
|
||||||
|
}, {
|
||||||
|
root: document.getElementById('app-screen') || null,
|
||||||
|
threshold: [0.6],
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.querySelectorAll('[data-message-key]').forEach((node) => {
|
||||||
|
const key = String(node.dataset.messageKey || '').trim();
|
||||||
|
if (key && unreadKeys.has(key)) seenObserver?.observe(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
syncUnreadState();
|
||||||
|
mountUnreadDivider();
|
||||||
|
updateUnreadJump();
|
||||||
|
|
||||||
|
const firstUnreadCandidate = channelData.firstUnreadKey
|
||||||
|
|| (() => {
|
||||||
|
const first = channelData.posts.find((post) => post.seenByMe !== true);
|
||||||
|
return messageRefKey(first?.messageRef);
|
||||||
|
})();
|
||||||
|
if (firstUnreadCandidate) {
|
||||||
|
const previous = firstUnreadJumpByRoute.get(routeKey);
|
||||||
|
if (previous !== firstUnreadCandidate) {
|
||||||
|
pendingScrollByRoute.set(routeKey, firstUnreadCandidate);
|
||||||
|
firstUnreadJumpByRoute.set(routeKey, firstUnreadCandidate);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
firstUnreadJumpByRoute.delete(routeKey);
|
||||||
|
}
|
||||||
|
|
||||||
applyPendingScroll(screen, routeKey);
|
applyPendingScroll(screen, routeKey);
|
||||||
|
return () => {
|
||||||
|
seenObserver?.disconnect();
|
||||||
|
const timer = seenFlushTimersByRoute.get(routeKey);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
seenFlushTimersByRoute.delete(routeKey);
|
||||||
|
seenPendingByRoute.delete(routeKey);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSkeleton(screen) {
|
function renderSkeleton(screen) {
|
||||||
@ -776,6 +1117,38 @@ export function render({ navigate, route }) {
|
|||||||
rerender();
|
rerender();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onMarkSeenBatch = async (refs) => {
|
||||||
|
if (!Array.isArray(refs) || !refs.length) return;
|
||||||
|
const login = String(state.session.login || '').trim();
|
||||||
|
if (!login || !routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) return;
|
||||||
|
await authService.markChannelMessagesSeen({
|
||||||
|
login,
|
||||||
|
channel: {
|
||||||
|
ownerBlockchainName: routeSelector.ownerBlockchainName,
|
||||||
|
channelRootBlockNumber: routeSelector.channelRootBlockNumber,
|
||||||
|
channelRootBlockHash: routeSelector.channelRootBlockHash,
|
||||||
|
},
|
||||||
|
messages: refs,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onShare = async (routePath) => {
|
||||||
|
try {
|
||||||
|
const routeToShare = String(routePath || '').trim();
|
||||||
|
if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.');
|
||||||
|
const result = await shareOrCopyLink({
|
||||||
|
title: 'SHiNE · Каналы',
|
||||||
|
text: 'Тред из канала SHiNE',
|
||||||
|
url: buildAbsoluteRouteUrl(routeToShare),
|
||||||
|
});
|
||||||
|
if (result === 'copied') showToast('Ссылка скопирована');
|
||||||
|
if (result === 'shared') showToast('Ссылка передана');
|
||||||
|
if (result === 'shared' || result === 'copied') softHaptic(10);
|
||||||
|
} catch (error) {
|
||||||
|
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onAddPost = async (bodyText) => {
|
const onAddPost = async (bodyText) => {
|
||||||
const { login, storagePwd } = requireSigningSession();
|
const { login, storagePwd } = requireSigningSession();
|
||||||
if (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) {
|
if (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) {
|
||||||
@ -824,11 +1197,13 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
const skeleton = renderSkeleton(screen);
|
const skeleton = renderSkeleton(screen);
|
||||||
|
|
||||||
|
let cleanupSeenTracking = null;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const apiData = await loadFromApi(route, channelId);
|
const apiData = await loadFromApi(route, channelId);
|
||||||
skeleton.remove();
|
skeleton.remove();
|
||||||
renderBody(screen, navigate, routeKey, apiData, {
|
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
||||||
onToggleLike: async (messageRef, action) => {
|
onToggleLike: async (messageRef, action) => {
|
||||||
try {
|
try {
|
||||||
await onToggleLike(messageRef, action);
|
await onToggleLike(messageRef, action);
|
||||||
@ -853,6 +1228,7 @@ export function render({ navigate, route }) {
|
|||||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onShare: onShare,
|
||||||
onEditDescription: async (descriptionText) => {
|
onEditDescription: async (descriptionText) => {
|
||||||
try {
|
try {
|
||||||
await onEditDescription(descriptionText);
|
await onEditDescription(descriptionText);
|
||||||
@ -883,6 +1259,16 @@ export function render({ navigate, route }) {
|
|||||||
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
|
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onMarkSeenBatch: async (refs) => {
|
||||||
|
try {
|
||||||
|
await onMarkSeenBatch(refs);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(toUserMessage(error, 'Не удалось отметить сообщения как прочитанные.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSeenError: (error) => {
|
||||||
|
showStatus(toUserMessage(error, 'Не удалось обновить статус прочтения.'));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
skeleton.remove();
|
skeleton.remove();
|
||||||
@ -896,6 +1282,7 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
appScreen?.classList.remove('channels-scroll-clean');
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
|
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
||||||
};
|
};
|
||||||
|
|
||||||
return screen;
|
return screen;
|
||||||
|
|||||||
@ -416,6 +416,7 @@ function mapMockGroups() {
|
|||||||
isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal',
|
isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal',
|
||||||
notificationsEnabled: false,
|
notificationsEnabled: false,
|
||||||
messagesCount: Number(channel.messagesCount || 0),
|
messagesCount: Number(channel.messagesCount || 0),
|
||||||
|
unreadCount: 0,
|
||||||
lastMessageAt: 0,
|
lastMessageAt: 0,
|
||||||
ownerName: String(channel.ownerName || 'неизвестно'),
|
ownerName: String(channel.ownerName || 'неизвестно'),
|
||||||
channelName: String(channel.channelName || channel.title || ''),
|
channelName: String(channel.channelName || channel.title || ''),
|
||||||
@ -470,6 +471,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
channelDescription,
|
channelDescription,
|
||||||
messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний',
|
messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний',
|
||||||
messagesCount: Number(summary?.messagesCount || 0),
|
messagesCount: Number(summary?.messagesCount || 0),
|
||||||
|
unreadCount: Number(summary?.unreadCount || 0),
|
||||||
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
|
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
|
||||||
tabCategory,
|
tabCategory,
|
||||||
isOwnChannel: isOwn,
|
isOwnChannel: isOwn,
|
||||||
@ -479,6 +481,20 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSyntheticDefaultChannel(row) {
|
||||||
|
if (!row || !row.isOwnChannel) return false;
|
||||||
|
const name = String(row.channelName || '').trim();
|
||||||
|
if (name !== '0') return false;
|
||||||
|
|
||||||
|
const hasDescription = Boolean(String(row.channelDescription || '').trim());
|
||||||
|
const hasMessages = Number(row.messagesCount || 0) > 0;
|
||||||
|
const hasTimestamp = Number(row.lastMessageAt || 0) > 0;
|
||||||
|
const preview = String(row.messagePreview || '').trim();
|
||||||
|
const hasCustomPreview = preview && preview !== 'Ждем ваших начинаний';
|
||||||
|
|
||||||
|
return !hasDescription && !hasMessages && !hasTimestamp && !hasCustomPreview;
|
||||||
|
}
|
||||||
|
|
||||||
function pullCreateSuccessFlash() {
|
function pullCreateSuccessFlash() {
|
||||||
try {
|
try {
|
||||||
const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim();
|
const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim();
|
||||||
@ -491,7 +507,9 @@ function pullCreateSuccessFlash() {
|
|||||||
|
|
||||||
function mapApiFeed(feed, notificationsState) {
|
function mapApiFeed(feed, notificationsState) {
|
||||||
const index = {};
|
const index = {};
|
||||||
const ownChannels = (feed?.ownedChannels || []).map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState));
|
const ownChannels = (feed?.ownedChannels || [])
|
||||||
|
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState))
|
||||||
|
.filter((row) => !isSyntheticDefaultChannel(row));
|
||||||
const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
|
const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
|
||||||
const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index, notificationsState));
|
const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index, notificationsState));
|
||||||
|
|
||||||
@ -508,22 +526,7 @@ function toListModel(groups) {
|
|||||||
|
|
||||||
function renderEmptyState(activeTab, navigate) {
|
function renderEmptyState(activeTab, navigate) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'channels-empty-state channels-empty-state--compact';
|
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
||||||
|
|
||||||
const text = document.createElement('div');
|
|
||||||
text.className = 'meta-muted';
|
|
||||||
text.textContent = 'В этом разделе нет сообщений';
|
|
||||||
|
|
||||||
wrap.append(text);
|
|
||||||
|
|
||||||
if (activeTab === 'my') {
|
|
||||||
const cta = document.createElement('button');
|
|
||||||
cta.type = 'button';
|
|
||||||
cta.className = 'secondary-btn';
|
|
||||||
cta.textContent = 'Создать первый канал';
|
|
||||||
cta.addEventListener('click', () => navigate('add-channel-view'));
|
|
||||||
wrap.append(cta);
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
@ -767,7 +770,7 @@ function renderChannelMain(channel, activeTab) {
|
|||||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
||||||
|
|
||||||
const meta = document.createElement('p');
|
const meta = document.createElement('p');
|
||||||
meta.className = 'channel-row-owner';
|
meta.className = 'channel-row-owner channel-counter-meta';
|
||||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||||
|
|
||||||
main.append(author, title, preview, meta);
|
main.append(author, title, preview, meta);
|
||||||
@ -790,7 +793,7 @@ function renderChannelMain(channel, activeTab) {
|
|||||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
||||||
|
|
||||||
const meta = document.createElement('p');
|
const meta = document.createElement('p');
|
||||||
meta.className = 'channel-row-owner';
|
meta.className = 'channel-row-owner channel-counter-meta';
|
||||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||||
|
|
||||||
main.prepend(title);
|
main.prepend(title);
|
||||||
@ -818,6 +821,8 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
filtered.forEach((channel) => {
|
filtered.forEach((channel) => {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'channel-row';
|
row.className = 'channel-row';
|
||||||
|
const countersVisible = listState.revealedCounters.has(channel.id);
|
||||||
|
row.classList.toggle('is-counters-visible', countersVisible);
|
||||||
|
|
||||||
const avatar = document.createElement('div');
|
const avatar = document.createElement('div');
|
||||||
avatar.className = 'avatar';
|
avatar.className = 'avatar';
|
||||||
@ -835,6 +840,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
menuButton.addEventListener('click', (event) => {
|
menuButton.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
animatePress(menuButton);
|
animatePress(menuButton);
|
||||||
|
listState.revealedCounters.add(channel.id);
|
||||||
|
|
||||||
if (listState.openMenuId === channel.id) {
|
if (listState.openMenuId === channel.id) {
|
||||||
closeChannelMenu(listState);
|
closeChannelMenu(listState);
|
||||||
@ -859,7 +865,9 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
|
|
||||||
const count = document.createElement('span');
|
const count = document.createElement('span');
|
||||||
count.className = 'unread channel-row-count';
|
count.className = 'unread channel-row-count';
|
||||||
count.textContent = String(channel.messagesCount || 0);
|
const unreadCount = Number(channel.unreadCount || 0);
|
||||||
|
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||||
|
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||||
|
|
||||||
controls.append(menuButton, time, count);
|
controls.append(menuButton, time, count);
|
||||||
|
|
||||||
@ -871,12 +879,13 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
container.append(list);
|
container.append(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBottomCta({ button, listState, navigate, onReload }) {
|
function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = false }) {
|
||||||
const tab = listState.activeTab;
|
const tab = listState.activeTab;
|
||||||
|
const baseClass = `primary-btn channels-bottom-action${isTabEmpty && tab !== 'my' ? ' is-empty-lift' : ''}`;
|
||||||
|
|
||||||
if (tab === 'subscriptions') {
|
if (tab === 'subscriptions') {
|
||||||
button.textContent = 'Подписаться на канал';
|
button.textContent = 'Подписаться на канал';
|
||||||
button.className = 'primary-btn channels-bottom-action';
|
button.className = baseClass;
|
||||||
button.onclick = () => openSimpleSubscribeModal({
|
button.onclick = () => openSimpleSubscribeModal({
|
||||||
kind: 'channel',
|
kind: 'channel',
|
||||||
kindLabel: 'Подписка на канал',
|
kindLabel: 'Подписка на канал',
|
||||||
@ -888,7 +897,7 @@ function updateBottomCta({ button, listState, navigate, onReload }) {
|
|||||||
|
|
||||||
if (tab === 'authors') {
|
if (tab === 'authors') {
|
||||||
button.textContent = 'Подписаться на автора';
|
button.textContent = 'Подписаться на автора';
|
||||||
button.className = 'primary-btn channels-bottom-action';
|
button.className = baseClass;
|
||||||
button.onclick = () => openSimpleSubscribeModal({
|
button.onclick = () => openSimpleSubscribeModal({
|
||||||
kind: 'user',
|
kind: 'user',
|
||||||
kindLabel: 'Подписка на автора',
|
kindLabel: 'Подписка на автора',
|
||||||
@ -899,7 +908,7 @@ function updateBottomCta({ button, listState, navigate, onReload }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button.textContent = 'Создать канал';
|
button.textContent = 'Создать канал';
|
||||||
button.className = 'primary-btn channels-bottom-action';
|
button.className = baseClass;
|
||||||
button.onclick = () => navigate('add-channel-view');
|
button.onclick = () => navigate('add-channel-view');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -948,6 +957,7 @@ export function render({ navigate }) {
|
|||||||
activeTab: 'my',
|
activeTab: 'my',
|
||||||
openMenuId: null,
|
openMenuId: null,
|
||||||
notificationsState,
|
notificationsState,
|
||||||
|
revealedCounters: new Set(),
|
||||||
channels: [],
|
channels: [],
|
||||||
menuCleanup: null,
|
menuCleanup: null,
|
||||||
};
|
};
|
||||||
@ -964,6 +974,8 @@ export function render({ navigate }) {
|
|||||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||||
|
|
||||||
const rerenderList = () => {
|
const rerenderList = () => {
|
||||||
|
const isTabEmpty = !(listState.channels || []).some((channel) => channel.tabCategory === listState.activeTab);
|
||||||
|
|
||||||
tabItems.forEach((tab) => {
|
tabItems.forEach((tab) => {
|
||||||
const btn = tabs.querySelector(`[data-tab="${tab.key}"]`);
|
const btn = tabs.querySelector(`[data-tab="${tab.key}"]`);
|
||||||
if (btn) btn.classList.toggle('is-active', tab.key === listState.activeTab);
|
if (btn) btn.classList.toggle('is-active', tab.key === listState.activeTab);
|
||||||
@ -984,6 +996,7 @@ export function render({ navigate }) {
|
|||||||
listState,
|
listState,
|
||||||
navigate,
|
navigate,
|
||||||
onReload: reloadFeed,
|
onReload: reloadFeed,
|
||||||
|
isTabEmpty,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1019,6 +1032,7 @@ export function render({ navigate }) {
|
|||||||
listState,
|
listState,
|
||||||
navigate,
|
navigate,
|
||||||
onReload: reloadFeed,
|
onReload: reloadFeed,
|
||||||
|
isTabEmpty: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export function render({ navigate, route }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack dm-screen dm-chat-screen';
|
||||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
@ -124,16 +124,16 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'chat-wrap';
|
wrap.className = 'chat-wrap dm-chat-wrap';
|
||||||
|
|
||||||
const log = document.createElement('div');
|
const log = document.createElement('div');
|
||||||
log.className = 'messages-log';
|
log.className = 'messages-log dm-messages-log';
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.className = 'chat-input';
|
form.className = 'chat-input dm-chat-input';
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<input class="input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
|
<input class="input dm-input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
|
||||||
<button class="primary-btn" type="submit">Отправить</button>
|
<button class="primary-btn dm-send-btn" type="submit">Отправить</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
form.addEventListener('submit', async (event) => {
|
||||||
|
|||||||
@ -5,10 +5,10 @@ export const pageMeta = { id: 'contact-search-view', title: 'Поиск конт
|
|||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack dm-screen dm-search-screen';
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.className = 'input';
|
input.className = 'input dm-input';
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.name = 'contact';
|
input.name = 'contact';
|
||||||
input.placeholder = 'Введите начало логина';
|
input.placeholder = 'Введите начало логина';
|
||||||
@ -16,14 +16,14 @@ export function render({ navigate }) {
|
|||||||
input.maxLength = 80;
|
input.maxLength = 80;
|
||||||
|
|
||||||
const resultsCard = document.createElement('section');
|
const resultsCard = document.createElement('section');
|
||||||
resultsCard.className = 'card stack';
|
resultsCard.className = 'card stack dm-dialog-card';
|
||||||
resultsCard.hidden = true;
|
resultsCard.hidden = true;
|
||||||
|
|
||||||
const status = document.createElement('p');
|
const status = document.createElement('p');
|
||||||
status.className = 'meta-muted';
|
status.className = 'meta-muted';
|
||||||
|
|
||||||
const resultsList = document.createElement('div');
|
const resultsList = document.createElement('div');
|
||||||
resultsList.className = 'stack';
|
resultsList.className = 'stack dm-list';
|
||||||
|
|
||||||
const renderResults = (matches, query) => {
|
const renderResults = (matches, query) => {
|
||||||
resultsList.innerHTML = '';
|
resultsList.innerHTML = '';
|
||||||
@ -43,7 +43,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
matches.forEach((login) => {
|
matches.forEach((login) => {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'list-item';
|
row.className = 'list-item dm-dialog-card';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
|
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
|
||||||
<div>
|
<div>
|
||||||
@ -60,7 +60,7 @@ export function render({ navigate }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const searchButton = document.createElement('button');
|
const searchButton = document.createElement('button');
|
||||||
searchButton.className = 'primary-btn';
|
searchButton.className = 'primary-btn dm-send-btn';
|
||||||
searchButton.type = 'button';
|
searchButton.type = 'button';
|
||||||
searchButton.textContent = 'Поиск';
|
searchButton.textContent = 'Поиск';
|
||||||
searchButton.addEventListener('click', async () => {
|
searchButton.addEventListener('click', async () => {
|
||||||
@ -84,7 +84,7 @@ export function render({ navigate }) {
|
|||||||
controls.append(searchButton);
|
controls.append(searchButton);
|
||||||
|
|
||||||
const formCard = document.createElement('section');
|
const formCard = document.createElement('section');
|
||||||
formCard.className = 'card stack';
|
formCard.className = 'card stack dm-dialog-card';
|
||||||
formCard.append(input, controls);
|
formCard.append(input, controls);
|
||||||
|
|
||||||
resultsCard.append(status, resultsList);
|
resultsCard.append(status, resultsList);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { directMessages } from '../mock-data.js';
|
import { directMessages } from '../mock-data.js';
|
||||||
import {
|
import {
|
||||||
getChatMessages,
|
getChatMessages,
|
||||||
@ -13,7 +13,7 @@ export const pageMeta = { id: 'messages-list', title: 'Личные сообще
|
|||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack dm-screen dm-list-screen';
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
@ -24,14 +24,11 @@ export function render({ navigate }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'stack';
|
list.className = 'stack dm-list';
|
||||||
const status = document.createElement('div');
|
|
||||||
status.className = 'status-line';
|
|
||||||
status.textContent = 'Загрузка списка сообщений...';
|
|
||||||
|
|
||||||
function renderRow(item) {
|
function renderRow(item) {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'list-item';
|
row.className = 'list-item dm-dialog-card';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="avatar">${item.initials}</div>
|
<div class="avatar">${item.initials}</div>
|
||||||
<div>
|
<div>
|
||||||
@ -56,6 +53,7 @@ export function render({ navigate }) {
|
|||||||
const contacts = relations.outContacts || [];
|
const contacts = relations.outContacts || [];
|
||||||
setContacts(contacts);
|
setContacts(contacts);
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
||||||
const contactRows = contacts.map((login) => {
|
const contactRows = contacts.map((login) => {
|
||||||
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
|
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
|
||||||
const chat = getChatMessages(login);
|
const chat = getChatMessages(login);
|
||||||
@ -100,19 +98,13 @@ export function render({ navigate }) {
|
|||||||
empty.className = 'card meta-muted';
|
empty.className = 'card meta-muted';
|
||||||
empty.textContent = 'Пока нет ни контактов, ни сообщений';
|
empty.textContent = 'Пока нет ни контактов, ни сообщений';
|
||||||
list.append(empty);
|
list.append(empty);
|
||||||
status.className = 'status-line is-available';
|
|
||||||
status.textContent = 'Нет диалогов.';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.forEach((item) => list.append(renderRow(item)));
|
rows.forEach((item) => list.append(renderRow(item)));
|
||||||
status.className = 'status-line is-available';
|
|
||||||
status.textContent = `Загружено диалогов: ${rows.length}`;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isSessionInvalidError(error)) {
|
if (isSessionInvalidError(error)) {
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
status.className = 'status-line is-unavailable';
|
|
||||||
status.textContent = 'Сессия устарела.';
|
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack';
|
card.className = 'card stack';
|
||||||
@ -145,12 +137,10 @@ export function render({ navigate }) {
|
|||||||
fail.className = 'card meta-muted';
|
fail.className = 'card meta-muted';
|
||||||
fail.textContent = `Не удалось загрузить сообщения: ${error.message || 'unknown'}`;
|
fail.textContent = `Не удалось загрузить сообщения: ${error.message || 'unknown'}`;
|
||||||
list.append(fail);
|
list.append(fail);
|
||||||
status.className = 'status-line is-unavailable';
|
|
||||||
status.textContent = 'Список недоступен.';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
screen.append(status, list);
|
screen.append(list);
|
||||||
loadList();
|
loadList();
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ function renderList(container) {
|
|||||||
|
|
||||||
export function render() {
|
export function render() {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack notifications-screen';
|
||||||
|
|
||||||
screen.append(renderHeader({ title: 'Уведомления' }));
|
screen.append(renderHeader({ title: 'Уведомления' }));
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ export function render() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'stack';
|
list.className = 'stack notifications-list';
|
||||||
renderList(list);
|
renderList(list);
|
||||||
|
|
||||||
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
|
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { profile } from '../mock-data.js';
|
import { profile } from '../mock-data.js';
|
||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
@ -83,7 +83,7 @@ export function render({ navigate }) {
|
|||||||
const login = state.session.login || profile.login;
|
const login = state.session.login || profile.login;
|
||||||
|
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack profile-screen';
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
@ -561,8 +561,8 @@ export function render({ navigate }) {
|
|||||||
updateTogglesUi();
|
updateTogglesUi();
|
||||||
updateGenderUi();
|
updateGenderUi();
|
||||||
|
|
||||||
status.className = 'status-line is-available';
|
status.className = 'status-line';
|
||||||
status.textContent = 'Актуальные параметры загружены.';
|
status.textContent = '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.className = 'status-line is-unavailable';
|
status.className = 'status-line is-unavailable';
|
||||||
status.textContent = `Не удалось загрузить параметры: ${error.message || 'ошибка сети'}`;
|
status.textContent = `Не удалось загрузить параметры: ${error.message || 'ошибка сети'}`;
|
||||||
|
|||||||
@ -756,6 +756,17 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async markChannelMessagesSeen({ login, channel, messages }) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
const refs = Array.isArray(messages) ? messages : [];
|
||||||
|
const payload = { channel, messages: refs };
|
||||||
|
if (cleanLogin) payload.login = cleanLogin;
|
||||||
|
|
||||||
|
const response = await this.ws.request('MarkChannelMessagesSeen', payload);
|
||||||
|
if (response.status !== 200) throw opError('MarkChannelMessagesSeen', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||||
const cleanLogin = (login || '').trim();
|
const cleanLogin = (login || '').trim();
|
||||||
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
||||||
|
|||||||
@ -168,3 +168,55 @@ export function normalizeChannelDescription(value) {
|
|||||||
if (chars.length <= 200) return text;
|
if (chars.length <= 200) return text;
|
||||||
return chars.slice(0, 200).join('');
|
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');
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,21 +1,42 @@
|
|||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
background: #05070A;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: radial-gradient(circle at center, rgba(88, 50, 168, 0.15) 0%, rgba(212, 175, 55, 0.1) 40%, transparent 70%);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: -1;
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
width: min(100vw, 430px);
|
width: min(100vw, 430px);
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
position: relative;
|
position: relative;
|
||||||
background:
|
background: transparent;
|
||||||
radial-gradient(circle at 16% -8%, rgba(211, 168, 76, 0.16), transparent 38%),
|
|
||||||
linear-gradient(165deg, rgba(10, 21, 44, 0.98), rgba(5, 11, 24, 0.99));
|
|
||||||
border-left: 1px solid rgba(211, 170, 86, 0.2);
|
border-left: 1px solid rgba(211, 170, 86, 0.2);
|
||||||
border-right: 1px solid rgba(211, 170, 86, 0.2);
|
border-right: 1px solid rgba(211, 170, 86, 0.2);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.screen-content,
|
||||||
|
.toolbar-slot,
|
||||||
|
.connection-retry-banner {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.screen-content {
|
.screen-content {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@ -346,6 +346,34 @@ public final class DatabaseInitializer {
|
|||||||
ON message_stats (to_login);
|
ON message_stats (to_login);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
// 8.0) message_views_state (уникальный просмотр/прочтение сообщения пользователем)
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_views_state (
|
||||||
|
viewer_login TEXT NOT NULL,
|
||||||
|
to_bch_name TEXT NOT NULL,
|
||||||
|
to_block_number INTEGER NOT NULL,
|
||||||
|
to_block_hash BLOB NOT NULL,
|
||||||
|
first_seen_at_ms INTEGER NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE (
|
||||||
|
viewer_login,
|
||||||
|
to_bch_name,
|
||||||
|
to_block_number,
|
||||||
|
to_block_hash
|
||||||
|
)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_views_state_target
|
||||||
|
ON message_views_state (to_bch_name, to_block_number, to_block_hash);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel
|
||||||
|
ON message_views_state (viewer_login, to_bch_name);
|
||||||
|
""");
|
||||||
|
|
||||||
// 8.1) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
|
// 8.1) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS reactions_state (
|
CREATE TABLE IF NOT EXISTS reactions_state (
|
||||||
|
|||||||
@ -79,6 +79,7 @@ public final class SqliteDbController {
|
|||||||
st.execute("PRAGMA foreign_keys = OFF");
|
st.execute("PRAGMA foreign_keys = OFF");
|
||||||
|
|
||||||
ensureReactionsStateTable(st);
|
ensureReactionsStateTable(st);
|
||||||
|
ensureMessageViewsStateTable(st);
|
||||||
|
|
||||||
if (!tableExists(c, "connections_state")) {
|
if (!tableExists(c, "connections_state")) {
|
||||||
createConnectionsStateTable(st);
|
createConnectionsStateTable(st);
|
||||||
@ -89,6 +90,7 @@ public final class SqliteDbController {
|
|||||||
ensureChannelNamesDescriptionColumn(c, st);
|
ensureChannelNamesDescriptionColumn(c, st);
|
||||||
ensureConnectionsIndexes(st);
|
ensureConnectionsIndexes(st);
|
||||||
ensureReactionsIndexes(st);
|
ensureReactionsIndexes(st);
|
||||||
|
ensureMessageViewsIndexes(st);
|
||||||
ensureChannelNamesIndexes(st);
|
ensureChannelNamesIndexes(st);
|
||||||
ensureSignedMessageReceiptUniq(c, st);
|
ensureSignedMessageReceiptUniq(c, st);
|
||||||
|
|
||||||
@ -131,6 +133,24 @@ public final class SqliteDbController {
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureMessageViewsStateTable(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_views_state (
|
||||||
|
viewer_login TEXT NOT NULL,
|
||||||
|
to_bch_name TEXT NOT NULL,
|
||||||
|
to_block_number INTEGER NOT NULL,
|
||||||
|
to_block_hash BLOB NOT NULL,
|
||||||
|
first_seen_at_ms INTEGER NOT NULL,
|
||||||
|
UNIQUE (
|
||||||
|
viewer_login,
|
||||||
|
to_bch_name,
|
||||||
|
to_block_number,
|
||||||
|
to_block_hash
|
||||||
|
)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS connections_state (
|
CREATE TABLE IF NOT EXISTS connections_state (
|
||||||
@ -176,6 +196,17 @@ public final class SqliteDbController {
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureMessageViewsIndexes(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_views_state_target
|
||||||
|
ON message_views_state (to_bch_name, to_block_number, to_block_hash);
|
||||||
|
""");
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel
|
||||||
|
ON message_views_state (viewer_login, to_bch_name);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
|
private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||||
|
|||||||
@ -49,9 +49,11 @@ import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstra
|
|||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_MarkChannelMessagesSeen_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelMessagesSeen_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||||
@ -129,6 +131,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||||
|
Map.entry("MarkChannelMessagesSeen", new Net_MarkChannelMessagesSeen_Handler()),
|
||||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||||
@ -183,6 +186,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||||
|
Map.entry("MarkChannelMessagesSeen", Net_MarkChannelMessagesSeen_Request.class),
|
||||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||||
|
|||||||
@ -212,6 +212,111 @@ final class ChannelsReadSupport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int countViews(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT COUNT(*) AS cnt
|
||||||
|
FROM message_views_state
|
||||||
|
WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, bch);
|
||||||
|
ps.setInt(2, blockNumber);
|
||||||
|
ps.setBytes(3, blockHash);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getInt("cnt") : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isSeenByLogin(Connection c, String login, String toBch, int toBlockNumber, byte[] toBlockHash) throws SQLException {
|
||||||
|
if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String sql = """
|
||||||
|
SELECT 1
|
||||||
|
FROM message_views_state
|
||||||
|
WHERE viewer_login = ? COLLATE NOCASE
|
||||||
|
AND to_bch_name = ?
|
||||||
|
AND to_block_number = ?
|
||||||
|
AND to_block_hash = ?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
ps.setString(2, toBch);
|
||||||
|
ps.setInt(3, toBlockNumber);
|
||||||
|
ps.setBytes(4, toBlockHash);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int countUnreadPosts(Connection c, String viewerLogin, String ownerBch, int lineCode) throws SQLException {
|
||||||
|
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() || lineCode < 0) return 0;
|
||||||
|
String sql = """
|
||||||
|
SELECT COUNT(*) AS cnt
|
||||||
|
FROM blocks b
|
||||||
|
LEFT JOIN message_views_state v
|
||||||
|
ON v.viewer_login = ?
|
||||||
|
AND v.to_bch_name = b.bch_name
|
||||||
|
AND v.to_block_number = b.block_number
|
||||||
|
AND v.to_block_hash = b.block_hash
|
||||||
|
WHERE b.bch_name = ?
|
||||||
|
AND b.msg_type = ?
|
||||||
|
AND b.msg_sub_type = ?
|
||||||
|
AND b.line_code = ?
|
||||||
|
AND v.viewer_login IS NULL
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, viewerLogin);
|
||||||
|
ps.setString(2, ownerBch);
|
||||||
|
ps.setInt(3, MSG_TYPE_TEXT);
|
||||||
|
ps.setInt(4, MsgSubType.TEXT_POST);
|
||||||
|
ps.setInt(5, lineCode);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getInt("cnt") : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static PostBlock firstUnreadPost(Connection c, String viewerLogin, String ownerBch, int lineCode) throws SQLException {
|
||||||
|
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() || lineCode < 0) return null;
|
||||||
|
String sql = """
|
||||||
|
SELECT b.login,b.bch_name,b.block_number,b.block_hash,b.block_bytes
|
||||||
|
FROM blocks b
|
||||||
|
LEFT JOIN message_views_state v
|
||||||
|
ON v.viewer_login = ?
|
||||||
|
AND v.to_bch_name = b.bch_name
|
||||||
|
AND v.to_block_number = b.block_number
|
||||||
|
AND v.to_block_hash = b.block_hash
|
||||||
|
WHERE b.bch_name = ?
|
||||||
|
AND b.msg_type = ?
|
||||||
|
AND b.msg_sub_type = ?
|
||||||
|
AND b.line_code = ?
|
||||||
|
AND v.viewer_login IS NULL
|
||||||
|
ORDER BY b.block_number ASC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, viewerLogin);
|
||||||
|
ps.setString(2, ownerBch);
|
||||||
|
ps.setInt(3, MSG_TYPE_TEXT);
|
||||||
|
ps.setInt(4, MsgSubType.TEXT_POST);
|
||||||
|
ps.setInt(5, lineCode);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
PostBlock pb = new PostBlock();
|
||||||
|
pb.login = rs.getString("login");
|
||||||
|
pb.bchName = rs.getString("bch_name");
|
||||||
|
pb.blockNumber = rs.getInt("block_number");
|
||||||
|
pb.blockHash = rs.getBytes("block_hash");
|
||||||
|
pb.blockBytes = rs.getBytes("block_bytes");
|
||||||
|
return pb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||||
if (rootNumber == 0) return "";
|
if (rootNumber == 0) return "";
|
||||||
|
|
||||||
|
|||||||
@ -108,11 +108,22 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
item.setLikesCount(stats[0]);
|
item.setLikesCount(stats[0]);
|
||||||
item.setRepliesCount(stats[1]);
|
item.setRepliesCount(stats[1]);
|
||||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||||
|
item.setViewCount(ChannelsReadSupport.countViews(c, post.bchName, post.blockNumber, post.blockHash));
|
||||||
|
item.setSeenByMe(ChannelsReadSupport.isSeenByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||||
|
|
||||||
items.add(item);
|
items.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.setMessages(items);
|
resp.setMessages(items);
|
||||||
|
int unreadCount = ChannelsReadSupport.countUnreadPosts(c, viewerLogin, ownerBch, lineCode);
|
||||||
|
resp.setUnreadCount(unreadCount);
|
||||||
|
ChannelsReadSupport.PostBlock firstUnread = ChannelsReadSupport.firstUnreadPost(c, viewerLogin, ownerBch, lineCode);
|
||||||
|
if (firstUnread != null) {
|
||||||
|
Net_GetChannelMessages_Response.BlockRef firstUnreadRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||||
|
firstUnreadRef.setBlockNumber(firstUnread.blockNumber);
|
||||||
|
firstUnreadRef.setBlockHash(ChannelsReadSupport.toHex(firstUnread.blockHash));
|
||||||
|
resp.setFirstUnreadMessageRef(firstUnreadRef);
|
||||||
|
}
|
||||||
return resp;
|
return resp;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("GetChannelMessages failed", e);
|
log.error("GetChannelMessages failed", e);
|
||||||
|
|||||||
@ -178,6 +178,8 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
node.setLikesCount(stats[0]);
|
node.setLikesCount(stats[0]);
|
||||||
node.setRepliesCount(stats[1]);
|
node.setRepliesCount(stats[1]);
|
||||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||||
|
node.setViewCount(ChannelsReadSupport.countViews(c, row.bchName, row.blockNumber, row.blockHash));
|
||||||
|
node.setSeenByMe(ChannelsReadSupport.isSeenByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||||
|
|
||||||
if (row.lineCode != null && row.lineCode >= 0) {
|
if (row.lineCode != null && row.lineCode >= 0) {
|
||||||
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
||||||
|
|||||||
@ -45,9 +45,9 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
List<ChannelKey> followedUsersChannels = loadFollowedChannels(c, canonicalLogin, true);
|
List<ChannelKey> followedUsersChannels = loadFollowedChannels(c, canonicalLogin, true);
|
||||||
List<ChannelKey> followedChannels = loadFollowedChannels(c, canonicalLogin, false);
|
List<ChannelKey> followedChannels = loadFollowedChannels(c, canonicalLogin, false);
|
||||||
|
|
||||||
resp.setOwnedChannels(buildSummaries(c, own));
|
resp.setOwnedChannels(buildSummaries(c, canonicalLogin, own));
|
||||||
resp.setFollowedUsersChannels(buildSummaries(c, followedUsersChannels));
|
resp.setFollowedUsersChannels(buildSummaries(c, canonicalLogin, followedUsersChannels));
|
||||||
resp.setFollowedChannels(buildSummaries(c, followedChannels));
|
resp.setFollowedChannels(buildSummaries(c, canonicalLogin, followedChannels));
|
||||||
|
|
||||||
return resp;
|
return resp;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -56,7 +56,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Net_ListSubscriptionsFeed_Response.ChannelSummary> buildSummaries(Connection c, List<ChannelKey> keys) throws Exception {
|
private List<Net_ListSubscriptionsFeed_Response.ChannelSummary> buildSummaries(Connection c, String viewerLogin, List<ChannelKey> keys) throws Exception {
|
||||||
List<Net_ListSubscriptionsFeed_Response.ChannelSummary> out = new ArrayList<>();
|
List<Net_ListSubscriptionsFeed_Response.ChannelSummary> out = new ArrayList<>();
|
||||||
for (ChannelKey key : keys) {
|
for (ChannelKey key : keys) {
|
||||||
Net_ListSubscriptionsFeed_Response.ChannelSummary row = new Net_ListSubscriptionsFeed_Response.ChannelSummary();
|
Net_ListSubscriptionsFeed_Response.ChannelSummary row = new Net_ListSubscriptionsFeed_Response.ChannelSummary();
|
||||||
@ -74,6 +74,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
row.setChannel(channelRef);
|
row.setChannel(channelRef);
|
||||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||||
|
row.setUnreadCount(ChannelsReadSupport.countUnreadPosts(c, viewerLogin, key.ownerBch, key.rootNumber));
|
||||||
|
|
||||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||||
if (lastPost != null) {
|
if (lastPost != null) {
|
||||||
|
|||||||
@ -0,0 +1,135 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelMessagesSeen_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelMessagesSeen_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.MsgSubType;
|
||||||
|
import shine.db.SqliteDbController;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_MarkChannelMessagesSeen_Handler.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_MarkChannelMessagesSeen_Request req = (Net_MarkChannelMessagesSeen_Request) baseRequest;
|
||||||
|
List<Net_MarkChannelMessagesSeen_Request.MessageRef> refs = req.getMessages();
|
||||||
|
if (refs == null || refs.isEmpty()) {
|
||||||
|
Net_MarkChannelMessagesSeen_Response ok = new Net_MarkChannelMessagesSeen_Response();
|
||||||
|
ok.setOp(req.getOp());
|
||||||
|
ok.setRequestId(req.getRequestId());
|
||||||
|
ok.setStatus(WireCodes.Status.OK);
|
||||||
|
ok.setSeenAccepted(0);
|
||||||
|
ok.setInserted(0);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
||||||
|
String viewerLogin = ctx != null ? ctx.getLogin() : null;
|
||||||
|
if (viewerLogin == null || viewerLogin.isBlank()) {
|
||||||
|
viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin());
|
||||||
|
}
|
||||||
|
if (viewerLogin == null || viewerLogin.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Invalid login");
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer expectedRoot = req.getChannel() != null ? req.getChannel().getChannelRootBlockNumber() : null;
|
||||||
|
String expectedOwnerBch = req.getChannel() != null ? req.getChannel().getOwnerBlockchainName() : null;
|
||||||
|
boolean strictChannelMatch = expectedRoot != null && expectedRoot >= 0
|
||||||
|
&& expectedOwnerBch != null && !expectedOwnerBch.isBlank();
|
||||||
|
|
||||||
|
String existsSql = """
|
||||||
|
SELECT 1
|
||||||
|
FROM blocks
|
||||||
|
WHERE bch_name = ?
|
||||||
|
AND block_number = ?
|
||||||
|
AND block_hash = ?
|
||||||
|
AND msg_type = ?
|
||||||
|
AND msg_sub_type = ?
|
||||||
|
%s
|
||||||
|
LIMIT 1
|
||||||
|
""".formatted(strictChannelMatch ? "AND line_code = ?" : "");
|
||||||
|
|
||||||
|
String insertSql = """
|
||||||
|
INSERT OR IGNORE INTO message_views_state (
|
||||||
|
viewer_login, to_bch_name, to_block_number, to_block_hash, first_seen_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
|
||||||
|
int seenAccepted = 0;
|
||||||
|
int inserted = 0;
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
Set<String> dedup = new HashSet<>();
|
||||||
|
|
||||||
|
try (PreparedStatement existsPs = c.prepareStatement(existsSql);
|
||||||
|
PreparedStatement insertPs = c.prepareStatement(insertSql)) {
|
||||||
|
|
||||||
|
for (Net_MarkChannelMessagesSeen_Request.MessageRef ref : refs) {
|
||||||
|
if (ref == null) continue;
|
||||||
|
String bch = String.valueOf(ref.getBlockchainName() == null ? "" : ref.getBlockchainName()).trim();
|
||||||
|
Integer no = ref.getBlockNumber();
|
||||||
|
String hashHex = String.valueOf(ref.getBlockHash() == null ? "" : ref.getBlockHash()).trim().toLowerCase();
|
||||||
|
|
||||||
|
if (bch.isBlank() || no == null || no < 0) continue;
|
||||||
|
if (!hashHex.matches("^[0-9a-f]{64}$")) continue;
|
||||||
|
|
||||||
|
if (strictChannelMatch && !bch.equals(expectedOwnerBch)) continue;
|
||||||
|
|
||||||
|
String key = bch + "|" + no + "|" + hashHex;
|
||||||
|
if (!dedup.add(key)) continue;
|
||||||
|
|
||||||
|
existsPs.clearParameters();
|
||||||
|
existsPs.setString(1, bch);
|
||||||
|
existsPs.setInt(2, no);
|
||||||
|
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
|
||||||
|
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
|
||||||
|
existsPs.setInt(5, MsgSubType.TEXT_POST);
|
||||||
|
if (strictChannelMatch) {
|
||||||
|
existsPs.setInt(6, expectedRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean exists;
|
||||||
|
try (ResultSet rs = existsPs.executeQuery()) {
|
||||||
|
exists = rs.next();
|
||||||
|
}
|
||||||
|
if (!exists) continue;
|
||||||
|
|
||||||
|
seenAccepted += 1;
|
||||||
|
|
||||||
|
insertPs.clearParameters();
|
||||||
|
insertPs.setString(1, viewerLogin);
|
||||||
|
insertPs.setString(2, bch);
|
||||||
|
insertPs.setInt(3, no);
|
||||||
|
insertPs.setBytes(4, ChannelsReadSupport.hexToBytes(hashHex));
|
||||||
|
insertPs.setLong(5, nowMs);
|
||||||
|
inserted += insertPs.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_MarkChannelMessagesSeen_Response ok = new Net_MarkChannelMessagesSeen_Response();
|
||||||
|
ok.setOp(req.getOp());
|
||||||
|
ok.setRequestId(req.getRequestId());
|
||||||
|
ok.setStatus(WireCodes.Status.OK);
|
||||||
|
ok.setSeenAccepted(seenAccepted);
|
||||||
|
ok.setInserted(inserted);
|
||||||
|
return ok;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("MarkChannelMessagesSeen failed", e);
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Internal server error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -8,6 +8,8 @@ import java.util.List;
|
|||||||
public class Net_GetChannelMessages_Response extends Net_Response {
|
public class Net_GetChannelMessages_Response extends Net_Response {
|
||||||
private Channel channel;
|
private Channel channel;
|
||||||
private List<MessageItem> messages = new ArrayList<>();
|
private List<MessageItem> messages = new ArrayList<>();
|
||||||
|
private int unreadCount;
|
||||||
|
private BlockRef firstUnreadMessageRef;
|
||||||
|
|
||||||
public Channel getChannel() { return channel; }
|
public Channel getChannel() { return channel; }
|
||||||
public void setChannel(Channel channel) { this.channel = channel; }
|
public void setChannel(Channel channel) { this.channel = channel; }
|
||||||
@ -15,6 +17,12 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public List<MessageItem> getMessages() { return messages; }
|
public List<MessageItem> getMessages() { return messages; }
|
||||||
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||||
|
|
||||||
|
public int getUnreadCount() { return unreadCount; }
|
||||||
|
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
||||||
|
|
||||||
|
public BlockRef getFirstUnreadMessageRef() { return firstUnreadMessageRef; }
|
||||||
|
public void setFirstUnreadMessageRef(BlockRef firstUnreadMessageRef) { this.firstUnreadMessageRef = firstUnreadMessageRef; }
|
||||||
|
|
||||||
public static class Channel {
|
public static class Channel {
|
||||||
private String ownerLogin;
|
private String ownerLogin;
|
||||||
private String ownerBlockchainName;
|
private String ownerBlockchainName;
|
||||||
@ -47,6 +55,8 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
private int likesCount;
|
private int likesCount;
|
||||||
private boolean likedByMe;
|
private boolean likedByMe;
|
||||||
private int repliesCount;
|
private int repliesCount;
|
||||||
|
private int viewCount;
|
||||||
|
private boolean seenByMe;
|
||||||
private int versionsTotal;
|
private int versionsTotal;
|
||||||
private List<VersionItem> versions = new ArrayList<>();
|
private List<VersionItem> versions = new ArrayList<>();
|
||||||
|
|
||||||
@ -74,6 +84,12 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public int getRepliesCount() { return repliesCount; }
|
public int getRepliesCount() { return repliesCount; }
|
||||||
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
||||||
|
|
||||||
|
public int getViewCount() { return viewCount; }
|
||||||
|
public void setViewCount(int viewCount) { this.viewCount = viewCount; }
|
||||||
|
|
||||||
|
public boolean isSeenByMe() { return seenByMe; }
|
||||||
|
public void setSeenByMe(boolean seenByMe) { this.seenByMe = seenByMe; }
|
||||||
|
|
||||||
public int getVersionsTotal() { return versionsTotal; }
|
public int getVersionsTotal() { return versionsTotal; }
|
||||||
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
|
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
public static class ChannelSummary {
|
public static class ChannelSummary {
|
||||||
private ChannelRef channel;
|
private ChannelRef channel;
|
||||||
private int messagesCount;
|
private int messagesCount;
|
||||||
|
private int unreadCount;
|
||||||
private LastMessage lastMessage;
|
private LastMessage lastMessage;
|
||||||
|
|
||||||
public ChannelRef getChannel() { return channel; }
|
public ChannelRef getChannel() { return channel; }
|
||||||
@ -34,6 +35,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
public int getMessagesCount() { return messagesCount; }
|
public int getMessagesCount() { return messagesCount; }
|
||||||
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
||||||
|
|
||||||
|
public int getUnreadCount() { return unreadCount; }
|
||||||
|
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
||||||
|
|
||||||
public LastMessage getLastMessage() { return lastMessage; }
|
public LastMessage getLastMessage() { return lastMessage; }
|
||||||
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
|
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_MarkChannelMessagesSeen_Request extends Net_Request {
|
||||||
|
private String login;
|
||||||
|
private ChannelSelector channel;
|
||||||
|
private List<MessageRef> messages = new ArrayList<>();
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
|
||||||
|
public ChannelSelector getChannel() { return channel; }
|
||||||
|
public void setChannel(ChannelSelector channel) { this.channel = channel; }
|
||||||
|
|
||||||
|
public List<MessageRef> getMessages() { return messages; }
|
||||||
|
public void setMessages(List<MessageRef> messages) { this.messages = messages; }
|
||||||
|
|
||||||
|
public static class ChannelSelector {
|
||||||
|
private String ownerBlockchainName;
|
||||||
|
private Integer channelRootBlockNumber;
|
||||||
|
private String channelRootBlockHash;
|
||||||
|
|
||||||
|
public String getOwnerBlockchainName() { return ownerBlockchainName; }
|
||||||
|
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
|
||||||
|
|
||||||
|
public Integer getChannelRootBlockNumber() { return channelRootBlockNumber; }
|
||||||
|
public void setChannelRootBlockNumber(Integer channelRootBlockNumber) { this.channelRootBlockNumber = channelRootBlockNumber; }
|
||||||
|
|
||||||
|
public String getChannelRootBlockHash() { return channelRootBlockHash; }
|
||||||
|
public void setChannelRootBlockHash(String channelRootBlockHash) { this.channelRootBlockHash = channelRootBlockHash; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class MessageRef {
|
||||||
|
private String blockchainName;
|
||||||
|
private Integer blockNumber;
|
||||||
|
private String blockHash;
|
||||||
|
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
|
||||||
|
public Integer getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
|
||||||
|
public String getBlockHash() { return blockHash; }
|
||||||
|
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
public class Net_MarkChannelMessagesSeen_Response extends Net_Response {
|
||||||
|
private int seenAccepted;
|
||||||
|
private int inserted;
|
||||||
|
|
||||||
|
public int getSeenAccepted() { return seenAccepted; }
|
||||||
|
public void setSeenAccepted(int seenAccepted) { this.seenAccepted = seenAccepted; }
|
||||||
|
|
||||||
|
public int getInserted() { return inserted; }
|
||||||
|
public void setInserted(int inserted) { this.inserted = inserted; }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user