- 1) Создайте пользователей A и B.
- 2) Под A создайте 2 канала и сообщения.
- 3) Под B проверьте follow/unfollow user и channel.
- 4) Откройте канал: проверьте like/unlike, reply и thread.
-
- 1) Локально: ?localWsPort=7071; через tunnel: ?wsUrl=wss://.../ws.
- 2) Зарегистрируйте пользователя A, затем пользователя B.
- 3) Войдите под A, создайте 2 канала и сообщения.
- 4) Войдите под B и проверьте каналы, лайк/анлайк и подписки/отписки.
-
- `;
- screen.append(help);
-
- if (state.startHint) {
- const notice = document.createElement('div');
- notice.className = 'card auth-status-card';
- notice.textContent = state.startHint;
- screen.append(notice);
- clearStartHint();
- }
-
- screen.append(actions);
+ screen.append(logo, title, actions);
return screen;
}
diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js
index 83d30a8..f14e05c 100644
--- a/shine-UI/js/services/auth-service.js
+++ b/shine-UI/js/services/auth-service.js
@@ -40,6 +40,7 @@ const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
+const CREATE_CHANNEL_BODY_VERSION = 2;
function normalizeServerUrl(url) {
const value = (url || '').trim();
@@ -77,6 +78,17 @@ function opError(op, response) {
return error;
}
+function isLegacyCreateChannelFormatError(error) {
+ const code = String(error?.code || '').trim().toUpperCase();
+ const text = String(error?.message || '').toLowerCase();
+ if (code === 'BAD_BLOCK_FORMAT') return true;
+ return (
+ text.includes('unknown body type/version') ||
+ text.includes('unknown tech body type/version/subtype') ||
+ text.includes('bad_block_format')
+ );
+}
+
function makeClientInfo() {
const ua = navigator.userAgent || 'unknown';
return ua.slice(0, 50);
@@ -267,6 +279,50 @@ function makeCreateChannelBodyBytes({ lineCode, prevLineNumber, prevLineHashHex,
);
}
+function normalizeChannelDescription(value) {
+ const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
+ const bytes = utf8Bytes(text);
+ if (bytes.length > 200) {
+ throw new Error('Описание канала слишком длинное: максимум 200 символов.');
+ }
+ return text;
+}
+
+function makeCreateChannelBodyV2Bytes({
+ lineCode,
+ prevLineNumber,
+ prevLineHashHex,
+ thisLineNumber,
+ channelName,
+ channelDescription = '',
+}) {
+ const check = validateChannelDisplayName(channelName);
+ if (!check.ok) throw new Error(channelNameErrorText(check.code));
+ const cleanName = check.normalized;
+ const cleanDescription = normalizeChannelDescription(channelDescription);
+
+ const nameBytes = utf8Bytes(cleanName);
+ if (nameBytes.length < 1 || nameBytes.length > 255) {
+ throw new Error('Channel name must be 1..255 bytes');
+ }
+
+ const descriptionBytes = utf8Bytes(cleanDescription);
+ if (descriptionBytes.length > 200) {
+ throw new Error('Описание канала слишком длинное: максимум 200 символов.');
+ }
+
+ return concatBytes(
+ int32Bytes(lineCode),
+ int32Bytes(prevLineNumber),
+ hexToBytes(normalizeHex32(prevLineHashHex)),
+ int32Bytes(thisLineNumber),
+ int8Byte(nameBytes.length),
+ nameBytes,
+ int16Bytes(descriptionBytes.length),
+ descriptionBytes,
+ );
+}
+
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
const message = String(text || '').trim();
if (!message) throw new Error('Message text is required');
@@ -828,7 +884,7 @@ export class AuthService {
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
}
- async addBlockCreateChannel({ login, channelName, storagePwd }) {
+ async addBlockCreateChannel({ login, channelName, channelDescription = '', storagePwd }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
@@ -866,25 +922,47 @@ export class AuthService {
thisLineNumber = createdChannels.length + 1;
}
- const bodyBytes = makeCreateChannelBodyBytes({
- lineCode: 0,
- prevLineNumber,
- prevLineHashHex,
- thisLineNumber,
- channelName: cleanChannelName,
- });
+ const submitCreate = async (useV2) => {
+ const bodyBytes = useV2
+ ? makeCreateChannelBodyV2Bytes({
+ lineCode: 0,
+ prevLineNumber,
+ prevLineHashHex,
+ thisLineNumber,
+ channelName: cleanChannelName,
+ channelDescription,
+ })
+ : makeCreateChannelBodyBytes({
+ lineCode: 0,
+ prevLineNumber,
+ prevLineHashHex,
+ thisLineNumber,
+ channelName: cleanChannelName,
+ });
- const payload = await this.addBlockSigned({
- login: cleanLogin,
- storagePwd,
- msgType: MSG_TYPE_TECH,
- msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
- msgVersion: 1,
- bodyBytes,
- });
+ return this.addBlockSigned({
+ login: cleanLogin,
+ storagePwd,
+ msgType: MSG_TYPE_TECH,
+ msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
+ msgVersion: useV2 ? CREATE_CHANNEL_BODY_VERSION : 1,
+ bodyBytes,
+ });
+ };
+
+ let payload;
+ let usedLegacyDescriptionFallback = false;
+ try {
+ payload = await submitCreate(true);
+ } catch (error) {
+ if (!isLegacyCreateChannelFormatError(error)) throw error;
+ payload = await submitCreate(false);
+ usedLegacyDescriptionFallback = true;
+ }
return {
...payload,
+ usedLegacyDescriptionFallback,
channel: {
ownerBlockchainName: blockchainName,
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
diff --git a/shine-UI/js/services/channel-name-rules.js b/shine-UI/js/services/channel-name-rules.js
index 3ee5839..de45ea6 100644
--- a/shine-UI/js/services/channel-name-rules.js
+++ b/shine-UI/js/services/channel-name-rules.js
@@ -7,6 +7,11 @@ export function normalizeChannelDisplayName(value) {
return String(value).trim().replace(/\s+/g, ' ');
}
+export function normalizeChannelDescription(value) {
+ if (value == null) return '';
+ return String(value).trim().replace(/\s+/g, ' ');
+}
+
export function toCanonicalChannelSlug(value) {
const normalized = normalizeChannelDisplayName(value);
if (!normalized) return '';
@@ -76,4 +81,10 @@ export function channelNameErrorText(code) {
}
}
-
+export function channelDescriptionErrorText(value) {
+ const normalized = normalizeChannelDescription(value);
+ if (new TextEncoder().encode(normalized).length > 200) {
+ return 'Описание слишком длинное: максимум 200 байт UTF-8.';
+ }
+ return '';
+}
diff --git a/shine-UI/js/services/channels-ux.js b/shine-UI/js/services/channels-ux.js
new file mode 100644
index 0000000..55f697f
--- /dev/null
+++ b/shine-UI/js/services/channels-ux.js
@@ -0,0 +1,170 @@
+const TOAST_HOST_ID = 'shine-toast-host';
+
+const rtf = (() => {
+ try {
+ return new Intl.RelativeTimeFormat('ru', { numeric: 'auto' });
+ } catch {
+ return null;
+ }
+})();
+
+function toNumber(value) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : 0;
+}
+
+function pickUnit(seconds) {
+ const abs = Math.abs(seconds);
+ if (abs < 60) return ['second', Math.round(seconds)];
+ const minutes = seconds / 60;
+ if (Math.abs(minutes) < 60) return ['minute', Math.round(minutes)];
+ const hours = minutes / 60;
+ if (Math.abs(hours) < 24) return ['hour', Math.round(hours)];
+ const days = hours / 24;
+ if (Math.abs(days) < 30) return ['day', Math.round(days)];
+ const months = days / 30;
+ if (Math.abs(months) < 12) return ['month', Math.round(months)];
+ const years = months / 12;
+ return ['year', Math.round(years)];
+}
+
+export function formatRelativeTime(timestampMs) {
+ const ts = toNumber(timestampMs);
+ if (!ts) return '—';
+
+ const now = Date.now();
+ const diffSeconds = (ts - now) / 1000;
+ const ageSeconds = now >= ts ? (now - ts) / 1000 : 0;
+ const ageHours = ageSeconds / 3600;
+
+ if (ageHours <= 10) {
+ const [unit, value] = pickUnit(diffSeconds);
+ if (rtf) return rtf.format(value, unit);
+
+ const absValue = Math.abs(value);
+ const suffix = value <= 0 ? 'назад' : 'через';
+ const labels = {
+ second: 'сек',
+ minute: 'мин',
+ hour: 'ч',
+ day: 'д',
+ month: 'мес',
+ year: 'г',
+ };
+ return `${suffix} ${absValue} ${labels[unit] || ''}`.trim();
+ }
+
+ try {
+ const dt = new Date(ts);
+ const nowDt = new Date(now);
+ const formatter = new Intl.DateTimeFormat('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ ...(dt.getFullYear() !== nowDt.getFullYear() ? { year: 'numeric' } : {}),
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ return formatter.format(dt);
+ } catch {
+ return new Date(ts).toLocaleString();
+ }
+}
+
+function ensureToastHost() {
+ let host = document.getElementById(TOAST_HOST_ID);
+ if (host) return host;
+
+ host = document.createElement('div');
+ host.id = TOAST_HOST_ID;
+ host.className = 'toast-host';
+ document.body.append(host);
+ return host;
+}
+
+export function showToast(message, { kind = 'success', timeoutMs = 2500 } = {}) {
+ const text = String(message || '').trim();
+ if (!text) return;
+
+ const host = ensureToastHost();
+ const toast = document.createElement('div');
+ toast.className = `toast toast--${kind}`;
+ toast.textContent = text;
+ host.append(toast);
+
+ requestAnimationFrame(() => {
+ toast.classList.add('is-visible');
+ });
+
+ const hide = () => {
+ toast.classList.remove('is-visible');
+ toast.classList.add('is-hiding');
+ setTimeout(() => toast.remove(), 220);
+ };
+
+ setTimeout(hide, Math.max(1200, Number(timeoutMs) || 2500));
+}
+
+export function softHaptic(duration = 15) {
+ try {
+ if (navigator?.vibrate) navigator.vibrate(Math.max(5, Math.min(30, Number(duration) || 15)));
+ } catch {
+ // ignore
+ }
+}
+
+export function animatePress(el) {
+ if (!el) return;
+ el.classList.remove('is-springing');
+ // force reflow
+ // eslint-disable-next-line no-unused-expressions
+ el.offsetWidth;
+ el.classList.add('is-springing');
+}
+
+const CHANNEL_NOTIF_KEY = 'shine-channels-notify-v1';
+
+export function readChannelNotificationsState() {
+ try {
+ const raw = localStorage.getItem(CHANNEL_NOTIF_KEY);
+ if (!raw) return {};
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
+ } catch {
+ // ignore
+ }
+ return {};
+}
+
+export function writeChannelNotificationsState(nextState) {
+ try {
+ localStorage.setItem(CHANNEL_NOTIF_KEY, JSON.stringify(nextState || {}));
+ } catch {
+ // ignore
+ }
+}
+
+export function makeAuthorLabel(login, localNumber) {
+ const cleanLogin = String(login || 'автор');
+ const n = Number(localNumber);
+ if (!Number.isFinite(n) || n < 1) return cleanLogin;
+ return `${cleanLogin} · #${n}`;
+}
+
+export function createSkeletonCard(className = '') {
+ const card = document.createElement('div');
+ card.className = `card skeleton-card ${className}`.trim();
+ card.innerHTML = `
+
+
+
+ `;
+ return card;
+}
+
+export function normalizeChannelDescription(value) {
+ const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
+ if (!text) return '';
+ const chars = Array.from(text);
+ if (chars.length <= 200) return text;
+ return chars.slice(0, 200).join('');
+}
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index c5a632a..1bf280b 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -1216,6 +1216,10 @@ textarea.input {
color: #f5daa0;
line-height: 1.2;
letter-spacing: 0.01em;
+ display: -webkit-box;
+ -webkit-line-clamp: 1;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
}
.channel-row-description {
@@ -1231,6 +1235,10 @@ textarea.input {
line-height: 1.35;
word-break: break-word;
min-height: 18px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
}
.channel-row-owner {
@@ -1519,3 +1527,371 @@ textarea.input {
grid-template-columns: 1fr;
}
}
+
+/* ===== Channels UX Stabilization ===== */
+.toast-host {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: calc(18px + env(safe-area-inset-bottom));
+ display: grid;
+ justify-items: center;
+ gap: 8px;
+ z-index: 60;
+ pointer-events: none;
+}
+
+.toast {
+ min-width: min(88vw, 320px);
+ max-width: min(92vw, 420px);
+ border-radius: 14px;
+ padding: 11px 14px;
+ border: 1px solid rgba(223, 188, 110, 0.45);
+ color: #f2dca8;
+ background: rgba(10, 14, 23, 0.8);
+ backdrop-filter: blur(12px);
+ box-shadow: 0 16px 30px rgba(1, 6, 12, 0.55);
+ opacity: 0;
+ transform: translateY(8px);
+ transition: opacity 0.22s ease, transform 0.22s ease;
+}
+
+.toast.is-visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.toast.is-hiding {
+ opacity: 0;
+ transform: translateY(8px);
+}
+
+.toast.toast--error {
+ color: #ffd8e0;
+ border-color: rgba(234, 122, 150, 0.45);
+}
+
+.skeleton-card {
+ display: grid;
+ gap: 8px;
+}
+
+.skeleton-line {
+ height: 10px;
+ border-radius: 999px;
+ background: linear-gradient(100deg, rgba(180, 151, 80, 0.16), rgba(228, 192, 109, 0.45), rgba(180, 151, 80, 0.16));
+ background-size: 220% 100%;
+ animation: channels-shimmer 1.2s linear infinite;
+}
+
+.skeleton-line.w-40 { width: 40%; }
+.skeleton-line.w-70 { width: 70%; }
+.skeleton-line.w-90 { width: 90%; }
+
+@keyframes channels-shimmer {
+ 0% { background-position: 210% 0; }
+ 100% { background-position: -10% 0; }
+}
+
+.channels-tabs {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ padding: 6px;
+ border-radius: 14px;
+ border: 1px solid rgba(188, 152, 79, 0.34);
+ background: linear-gradient(165deg, rgba(12, 24, 46, 0.92), rgba(9, 17, 34, 0.96));
+}
+
+.channels-tab-btn {
+ min-height: 38px;
+ border-radius: 10px;
+ border: 1px solid rgba(122, 151, 210, 0.26);
+ background: rgba(18, 33, 62, 0.55);
+ color: #b8c9ec;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.channels-tab-btn.is-active {
+ border-color: rgba(218, 183, 100, 0.48);
+ color: #f4d99e;
+ background: linear-gradient(160deg, rgba(193, 154, 76, 0.22), rgba(18, 33, 62, 0.64));
+}
+
+.channels-empty-state {
+ border: 1px dashed rgba(199, 164, 90, 0.36);
+ border-radius: 14px;
+ padding: 16px;
+ display: grid;
+ gap: 10px;
+ color: #b7c6e8;
+ background: rgba(10, 18, 34, 0.52);
+}
+
+.channels-empty-icon {
+ font-size: 20px;
+ color: #d9b56d;
+}
+
+.channel-row {
+ position: relative;
+ grid-template-columns: 46px minmax(0, 1fr) auto;
+}
+
+.channel-row-main {
+ padding-right: 6px;
+}
+
+.channel-row-description {
+ display: none;
+}
+
+.channel-row-controls {
+ display: grid;
+ justify-items: end;
+ align-content: start;
+ gap: 6px;
+}
+
+.channel-menu-trigger {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ border: 1px solid rgba(185, 154, 83, 0.42);
+ background: rgba(14, 25, 48, 0.82);
+ color: #efd9a4;
+ cursor: pointer;
+ font-size: 18px;
+ line-height: 1;
+}
+
+.channel-menu-wrap {
+ position: absolute;
+ top: 52px;
+ right: 12px;
+ z-index: 25;
+ width: min(240px, 70vw);
+ border-radius: 14px;
+ border: 1px solid rgba(206, 170, 90, 0.38);
+ background: rgba(10, 14, 23, 0.8);
+ backdrop-filter: blur(12px);
+ box-shadow: 0 16px 32px rgba(1, 5, 12, 0.62);
+ padding: 10px;
+ display: grid;
+ gap: 8px;
+}
+
+.channel-menu-item {
+ border: 1px solid rgba(125, 154, 212, 0.34);
+ border-radius: 10px;
+ background: rgba(17, 31, 56, 0.72);
+ color: #e6efff;
+ min-height: 36px;
+ padding: 6px 10px;
+ cursor: pointer;
+ text-align: left;
+}
+
+.channel-menu-item.destructive {
+ border-color: rgba(235, 120, 147, 0.46);
+ color: #ffdfe7;
+ background: rgba(84, 20, 38, 0.52);
+}
+
+.channel-menu-item:disabled {
+ opacity: 0.68;
+ cursor: not-allowed;
+}
+
+.channel-menu-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ color: #d9e6ff;
+ font-size: 13px;
+}
+
+.channel-toggle-btn {
+ width: 48px;
+ height: 28px;
+ border-radius: 999px;
+ border: 1px solid rgba(146, 171, 225, 0.45);
+ background: rgba(27, 43, 75, 0.84);
+ position: relative;
+ cursor: pointer;
+ transition: background 0.2s ease, border-color 0.2s ease;
+}
+
+.channel-toggle-btn::after {
+ content: "";
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: #d9e6ff;
+ transition: transform 0.24s cubic-bezier(.2,.9,.3,1.15), background 0.2s ease;
+}
+
+.channel-toggle-btn.is-on {
+ background: rgba(211, 173, 92, 0.34);
+ border-color: rgba(219, 182, 101, 0.6);
+}
+
+.channel-toggle-btn.is-on::after {
+ transform: translateX(20px);
+ background: #f4dca6;
+}
+
+.channel-head-description {
+ margin: 0;
+ color: #c8d7f6;
+ font-size: 14px;
+ line-height: 1.4;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.channel-head-description.is-expanded {
+ -webkit-line-clamp: unset;
+ display: block;
+}
+
+.channel-head-more {
+ border: 0;
+ background: transparent;
+ color: #efcf8b;
+ font-size: 12px;
+ padding: 0;
+ width: fit-content;
+ cursor: pointer;
+}
+
+.author-line {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.author-line-login {
+ font-weight: 500;
+ color: #f3dbab;
+}
+
+.author-line-num {
+ font-weight: 400;
+ color: #95a8d2;
+}
+
+.channel-message-card.is-own-new,
+.thread-node-card.is-own-new {
+ box-shadow: 0 0 0 1px rgba(217, 180, 97, 0.5), 0 12px 24px rgba(2, 8, 16, 0.46);
+}
+
+.is-springing {
+ animation: spring-tap 0.28s ease;
+}
+
+@keyframes spring-tap {
+ 0% { transform: scale(1); }
+ 30% { transform: scale(0.95); }
+ 60% { transform: scale(1.06); }
+ 100% { transform: scale(1); }
+}
+
+@media (max-width: 420px) {
+ .channel-message-actions {
+ grid-template-columns: 1fr;
+ }
+
+ .thread-node-actions {
+ grid-template-columns: 1fr;
+ }
+
+ .channels-action-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* ===== Channels cleanup overrides ===== */
+.screen-content.channels-scroll-clean {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.screen-content.channels-scroll-clean::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ display: none;
+}
+
+.channels-tabs--sticky {
+ position: sticky;
+ top: 0;
+ z-index: 8;
+ backdrop-filter: blur(12px);
+}
+
+.channels-list-content {
+ display: grid;
+ gap: 10px;
+ min-height: 42vh;
+}
+
+.channels-list-body-fade {
+ animation: channels-fade-in 0.2s ease;
+}
+
+@keyframes channels-fade-in {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.channels-menu-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 45;
+ background: transparent;
+}
+
+.channel-menu-wrap--portal {
+ position: fixed;
+ right: auto;
+ top: auto;
+ z-index: 46;
+}
+
+.channels-search-suggest {
+ max-height: 172px;
+ overflow-y: auto;
+ border: 1px solid rgba(150, 174, 224, 0.3);
+ border-radius: 12px;
+ background: rgba(9, 18, 35, 0.94);
+ padding: 6px;
+}
+
+.channel-search-item {
+ width: 100%;
+ text-align: left;
+ border: 1px solid rgba(132, 162, 224, 0.28);
+ border-radius: 9px;
+ background: rgba(16, 30, 56, 0.78);
+ color: #e5eeff;
+ padding: 8px 10px;
+ cursor: pointer;
+ margin-bottom: 6px;
+}
+
+.channel-search-item:last-child {
+ margin-bottom: 0;
+}
+
+.channel-search-item:hover {
+ border-color: rgba(216, 178, 95, 0.52);
+ color: #f3dca8;
+}
diff --git a/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java b/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java
index ea1c6da..3c06221 100644
--- a/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java
+++ b/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java
@@ -3,8 +3,7 @@ package blockchain;
import blockchain.body.*;
/**
- * Парсер body выбирает класс по header: type/subType/version,
- * потому что bodyBytes больше НЕ содержат type/subType/version.
+ * Parser for body record by header type/subType/version.
*/
public final class BodyRecordParser {
@@ -15,25 +14,26 @@ public final class BodyRecordParser {
int t = type & 0xFFFF;
int v = version & 0xFFFF;
+ int st = subType & 0xFFFF;
+
+ // TECH supports Header v1 and CreateChannel v1/v2.
+ if (t == (CreateChannelBody.TYPE & 0xFFFF)) {
+ if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF) && v == (HeaderBody.VER & 0xFFFF)) {
+ return new HeaderBody(subType, version, bodyBytes).check();
+ }
+ if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)
+ && (v == (CreateChannelBody.VER & 0xFFFF) || v == (CreateChannelBody.VER2 & 0xFFFF))) {
+ return new CreateChannelBody(subType, version, bodyBytes).check();
+ }
+ throw new IllegalArgumentException(
+ String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
+ );
+ }
int key = (t << 16) | v;
BodyRecord r = switch (key) {
- case HeaderBody.KEY -> {
- int st = subType & 0xFFFF;
- if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF)) {
- yield new HeaderBody(subType, version, bodyBytes);
- }
- if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)) {
- yield new CreateChannelBody(subType, version, bodyBytes);
- }
- throw new IllegalArgumentException("Unknown TECH subType for type=0 ver=1: subType=" + st);
- }
-
- // TEXT type=1 ver=1: выбираем класс по subType
case TextBody.KEY -> {
- int st = subType & 0xFFFF;
-
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
yield new TextLineBody(subType, version, bodyBytes);
@@ -47,16 +47,16 @@ public final class BodyRecordParser {
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
}
- case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
+ case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
- case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
+ case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
default -> throw new IllegalArgumentException(String.format(
"Unknown body type/version from header: type=%d ver=%d subType=%d",
- t, v, (subType & 0xFFFF)
+ t, v, st
));
};
return r.check();
}
-}
\ No newline at end of file
+}
diff --git a/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java b/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java
index 761fdae..d0833f9 100644
--- a/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java
+++ b/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java
@@ -6,56 +6,54 @@ import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
-import java.util.regex.Pattern;
import java.util.Objects;
/**
- * CreateChannelBody — TECH сообщение создания канала.
+ * TECH body for create channel.
*
- * type=0, ver=1 (в заголовке блока)
- * subType=MsgSubType.TECH_CREATE_CHANNEL (=1)
- *
- * Это сообщение идёт по ТЕХ-ЛИНИИ (hasLine):
- * - prevLineNumber/hash указывают на предыдущее TECH-сообщение (HEADER или прошлый CREATE_CHANNEL)
- * - thisLineNumber: 1,2,3... (тех-нумерация)
- *
- * bodyBytes (BigEndian), новый формат line-prefix:
- * [4] lineCode (для TECH линии обычно 0)
+ * v1 body bytes:
+ * [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
- * [1] channelNameLen (uint8)
- * [N] channelName UTF-8 (^[A-Za-z0-9_]+$)
+ * [1] channelNameLen
+ * [N] channelName UTF-8
*
- * Важно:
- * - канал "0" зарезервирован (создаётся по умолчанию от HEADER), создавать его нельзя.
+ * v2 body bytes:
+ * [4] lineCode
+ * [4] prevLineNumber
+ * [32] prevLineHash32
+ * [4] thisLineNumber
+ * [1] channelNameLen
+ * [N] channelName UTF-8
+ * [2] channelDescriptionLen
+ * [M] channelDescription UTF-8 (0..200 bytes)
*/
public final class CreateChannelBody implements BodyRecord, BodyHasLine {
public static final short TYPE = 0;
- public static final short VER = 1;
+ public static final short VER = 1;
+ public static final short VER2 = 2;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
+ public static final int KEY_V2 = ((TYPE & 0xFFFF) << 16) | (VER2 & 0xFFFF);
public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;
private static final byte[] ZERO32 = new byte[32];
- private static final int MIN_NAME_LENGTH = 3;
private static final int MAX_NAME_LENGTH = 32;
- private static final Pattern ALLOWED_NAME_PATTERN =
- Pattern.compile("^[\\p{IsLatin}\\p{IsCyrillic}0-9 _-]+$");
+ private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
- public final short subType; // из header
- public final short version; // из header
+ public final short subType;
+ public final short version;
- // line
public final int lineCode;
public final int prevLineNumber;
- public final byte[] prevLineHash32; // 32
+ public final byte[] prevLineHash32;
public final int thisLineNumber;
- // payload
public final String channelName;
+ public final String channelDescription;
public CreateChannelBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
@@ -63,14 +61,14 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
this.subType = subType;
this.version = version;
- if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
- throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + (this.version & 0xFFFF));
+ int ver = this.version & 0xFFFF;
+ if (ver != (VER & 0xFFFF) && ver != (VER2 & 0xFFFF)) {
+ throw new IllegalArgumentException("CreateChannelBody version must be 1 or 2, got=" + ver);
}
if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF));
}
- // минимум: lineCode(4) + line(4+32+4) + nameLen(1) + name(1)
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) {
throw new IllegalArgumentException("CreateChannelBody too short");
}
@@ -78,7 +76,6 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.lineCode = bb.getInt();
-
this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32];
@@ -88,16 +85,44 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0");
- if (bb.remaining() != nameLen) {
+ if (bb.remaining() < nameLen) {
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen);
}
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
-
this.channelName = new String(nameBytes, StandardCharsets.UTF_8);
- if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
+ if (ver == (VER2 & 0xFFFF)) {
+ if (bb.remaining() < 2) {
+ throw new IllegalArgumentException("CreateChannelBody v2 missing channelDescriptionLen");
+ }
+
+ int descriptionLen = Short.toUnsignedInt(bb.getShort());
+ if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
+ throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
+ }
+ if (bb.remaining() != descriptionLen) {
+ throw new IllegalArgumentException("CreateChannelBody v2 tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
+ }
+
+ if (descriptionLen == 0) {
+ this.channelDescription = "";
+ } else {
+ byte[] descriptionBytes = new byte[descriptionLen];
+ bb.get(descriptionBytes);
+ this.channelDescription = normalizeDescription(new String(descriptionBytes, StandardCharsets.UTF_8));
+ }
+ if (bb.remaining() != 0) {
+ throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
+ }
+ return;
+ }
+
+ this.channelDescription = "";
+ if (bb.remaining() != 0) {
+ throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
+ }
}
public CreateChannelBody(int lineCode,
@@ -105,11 +130,30 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
byte[] prevLineHash32,
int thisLineNumber,
String channelName) {
+ this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, "", VER);
+ }
+
+ public CreateChannelBody(int lineCode,
+ int prevLineNumber,
+ byte[] prevLineHash32,
+ int thisLineNumber,
+ String channelName,
+ String channelDescription) {
+ this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, channelDescription, VER2);
+ }
+
+ private CreateChannelBody(int lineCode,
+ int prevLineNumber,
+ byte[] prevLineHash32,
+ int thisLineNumber,
+ String channelName,
+ String channelDescription,
+ short version) {
Objects.requireNonNull(channelName, "channelName == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
this.subType = SUBTYPE;
- this.version = VER;
+ this.version = version;
this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber;
@@ -117,32 +161,42 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
this.thisLineNumber = thisLineNumber;
this.channelName = channelName;
+ this.channelDescription = channelDescription == null ? "" : channelDescription;
}
@Override
public CreateChannelBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
- if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF))
+ if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)");
+ }
String normalizedName = normalizeDisplayName(channelName);
- if (normalizedName.isEmpty())
+ if (normalizedName.isEmpty()) {
throw new IllegalArgumentException("channelName is blank");
- int cpLen = normalizedName.codePointCount(0, normalizedName.length());
- // Backward compatibility for historical blocks:
- // strict create-channel rules are enforced in AddBlock handler (ChannelNameRules),
- // but parser-level check must allow legacy channel names during bootstrap/replay.
- if (cpLen > MAX_NAME_LENGTH)
- throw new IllegalArgumentException("channelName length must be <=32");
+ }
- // tech-line: prev обязателен (минимум HEADER=0)
- if (prevLineNumber < 0)
+ int cpLen = normalizedName.codePointCount(0, normalizedName.length());
+ if (cpLen > MAX_NAME_LENGTH) {
+ throw new IllegalArgumentException("channelName length must be <=32");
+ }
+
+ String normalizedDescription = normalizeDescription(channelDescription);
+ byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
+ if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
+ throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
+ }
+
+ if (prevLineNumber < 0) {
throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody");
- if (prevLineHash32 == null || prevLineHash32.length != 32)
+ }
+ if (prevLineHash32 == null || prevLineHash32.length != 32) {
throw new IllegalArgumentException("prevLineHash32 invalid");
- if (thisLineNumber <= 0)
+ }
+ if (thisLineNumber <= 0) {
throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody");
+ }
return this;
}
@@ -152,17 +206,28 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
return value.trim().replaceAll("\\s+", " ");
}
+ private static String normalizeDescription(String value) {
+ if (value == null) return "";
+ return value.trim().replaceAll("\\s+", " ");
+ }
+
@Override
public byte[] toBytes() {
- byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8);
- if (nameUtf8.length == 0 || nameUtf8.length > 255)
+ byte[] nameUtf8 = normalizeDisplayName(channelName).getBytes(StandardCharsets.UTF_8);
+ if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("channelName utf8 len must be 1..255");
+ }
- int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length;
+ boolean isV2 = (version & 0xFFFF) == (VER2 & 0xFFFF);
+ byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
+ if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
+ throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
+ }
+
+ int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + (isV2 ? 2 + descriptionUtf8.length : 0);
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
-
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
@@ -170,12 +235,27 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
+ if (isV2) {
+ bb.putShort((short) (descriptionUtf8.length & 0xFFFF));
+ if (descriptionUtf8.length > 0) {
+ bb.put(descriptionUtf8);
+ }
+ }
+
return bb.array();
}
- /* ====================== BodyHasLine ====================== */
- @Override public int lineCode() { return lineCode; }
- @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
- @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
- @Override public int lineSeq() { return thisLineNumber; }
+ @Override
+ public int lineCode() { return lineCode; }
+
+ @Override
+ public int prevLineBlockGlobalNumber() { return prevLineNumber; }
+
+ @Override
+ public byte[] prevLineBlockHash32() {
+ return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32);
+ }
+
+ @Override
+ public int lineSeq() { return thisLineNumber; }
}
diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
index a76ee59..2d943a3 100644
--- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
+++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
@@ -373,6 +373,7 @@ public final class DatabaseInitializer {
CREATE TABLE IF NOT EXISTS channel_names_state (
slug TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL,
+ channel_description TEXT NOT NULL DEFAULT '',
owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL,
diff --git a/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/shine-server-db/src/main/java/shine/db/SqliteDbController.java
index 66eadda..91a7903 100644
--- a/shine-server-db/src/main/java/shine/db/SqliteDbController.java
+++ b/shine-server-db/src/main/java/shine/db/SqliteDbController.java
@@ -86,6 +86,7 @@ public final class SqliteDbController {
rebuildConnectionsStateTable(st);
}
ensureChannelNamesStateTable(st);
+ ensureChannelNamesDescriptionColumn(c, st);
ensureConnectionsIndexes(st);
ensureReactionsIndexes(st);
ensureChannelNamesIndexes(st);
@@ -179,6 +180,7 @@ public final class SqliteDbController {
CREATE TABLE IF NOT EXISTS channel_names_state (
slug TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL,
+ channel_description TEXT NOT NULL DEFAULT '',
owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL,
@@ -188,6 +190,24 @@ public final class SqliteDbController {
""");
}
+ private static void ensureChannelNamesDescriptionColumn(Connection c, Statement st) throws SQLException {
+ boolean hasDescription = false;
+ try (Statement probe = c.createStatement();
+ ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
+ while (rs.next()) {
+ String name = rs.getString("name");
+ if ("channel_description".equalsIgnoreCase(name)) {
+ hasDescription = true;
+ break;
+ }
+ }
+ }
+
+ if (!hasDescription) {
+ st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_description TEXT NOT NULL DEFAULT ''");
+ }
+ }
+
private static void ensureChannelNamesIndexes(Statement st) throws SQLException {
st.executeUpdate("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug
diff --git a/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java b/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java
index 8c8ea46..f4d42ea 100644
--- a/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java
+++ b/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java
@@ -51,21 +51,23 @@ public final class ChannelNameStateDAO {
INSERT INTO channel_names_state (
slug,
display_name,
+ channel_description,
owner_login,
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
created_at_ms
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getSlug());
ps.setString(2, entry.getDisplayName());
- ps.setString(3, entry.getOwnerLogin());
- ps.setString(4, entry.getOwnerBlockchainName());
- ps.setInt(5, entry.getChannelRootBlockNumber());
- ps.setBytes(6, entry.getChannelRootBlockHash());
- ps.setLong(7, entry.getCreatedAtMs());
+ ps.setString(3, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
+ ps.setString(4, entry.getOwnerLogin());
+ ps.setString(5, entry.getOwnerBlockchainName());
+ ps.setInt(6, entry.getChannelRootBlockNumber());
+ ps.setBytes(7, entry.getChannelRootBlockHash());
+ ps.setLong(8, entry.getCreatedAtMs());
ps.executeUpdate();
}
}
diff --git a/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java b/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java
index 1dbc7d2..34c6191 100644
--- a/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java
+++ b/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java
@@ -5,6 +5,7 @@ import java.util.Arrays;
public class ChannelNameStateEntry {
private String slug;
private String displayName;
+ private String channelDescription;
private String ownerLogin;
private String ownerBlockchainName;
private int channelRootBlockNumber;
@@ -27,6 +28,14 @@ public class ChannelNameStateEntry {
this.displayName = displayName;
}
+ public String getChannelDescription() {
+ return channelDescription;
+ }
+
+ public void setChannelDescription(String channelDescription) {
+ this.channelDescription = channelDescription;
+ }
+
public String getOwnerLogin() {
return ownerLogin;
}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java
index 61d72af..4bf3b41 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java
@@ -267,6 +267,11 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
channelNameStateEntry = new ChannelNameStateEntry();
channelNameStateEntry.setSlug(slug);
channelNameStateEntry.setDisplayName(normalizedName);
+ channelNameStateEntry.setChannelDescription(
+ createChannelBody.channelDescription == null
+ ? ""
+ : ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription)
+ );
channelNameStateEntry.setOwnerLogin(login);
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
channelNameStateEntry.setChannelRootBlockNumber(block.blockNumber);
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java
index de33906..f6258dd 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java
@@ -76,9 +76,11 @@ public final class ChannelNamesStateBootstrapper {
final String displayName;
final String slug;
+ final String channelDescription;
try {
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
slug = ChannelNameRules.toCanonicalSlug(displayName);
+ channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
} catch (Exception badName) {
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
continue;
@@ -94,6 +96,7 @@ public final class ChannelNamesStateBootstrapper {
ChannelNameStateEntry entry = new ChannelNameStateEntry();
entry.setSlug(slug);
entry.setDisplayName(displayName);
+ entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
entry.setOwnerLogin(ownerLogin);
entry.setOwnerBlockchainName(ownerBch);
entry.setChannelRootBlockNumber(blockNumber);
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java
index f8b12ee..6b996c0 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java
@@ -212,6 +212,46 @@ final class ChannelsReadSupport {
}
}
+ static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
+ if (rootNumber == 0) return "";
+
+ // Preferred source: persisted state (fast path, works for CreateChannelBody v2).
+ String stateSql = """
+ SELECT channel_description
+ FROM channel_names_state
+ WHERE owner_bch_name = ? AND channel_root_block_number = ?
+ LIMIT 1
+ """;
+ try (PreparedStatement ps = c.prepareStatement(stateSql)) {
+ ps.setString(1, ownerBch);
+ ps.setInt(2, rootNumber);
+ try (ResultSet rs = ps.executeQuery()) {
+ if (rs.next()) {
+ return String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
+ }
+ }
+ } catch (SQLException ignored) {
+ // keep compatibility for environments where table schema is older/corrupted
+ }
+
+ // Fallback: parse root block directly.
+ String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
+ try (PreparedStatement ps = c.prepareStatement(sql)) {
+ ps.setString(1, ownerBch);
+ ps.setInt(2, rootNumber);
+ try (ResultSet rs = ps.executeQuery()) {
+ if (!rs.next()) return "";
+ byte[] bytes = rs.getBytes("block_bytes");
+ BchBlockEntry e = new BchBlockEntry(bytes);
+ BodyRecord body = e.body;
+ if (body instanceof CreateChannelBody ccb) return ccb.channelDescription == null ? "" : ccb.channelDescription;
+ return "";
+ } catch (Exception ignored) {
+ return "";
+ }
+ }
+ }
+
static boolean isLikedByLogin(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;
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java
index 13b0f9f..64d5e3d 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java
@@ -54,6 +54,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
channel.setOwnerBlockchainName(ownerBch);
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode));
+ channel.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, ownerBch, lineCode));
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
rootRef.setBlockNumber(lineCode);
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java
index 575e0c8..2dc43ea 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java
@@ -64,6 +64,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
channelRef.setOwnerLogin(key.ownerLogin);
channelRef.setOwnerBlockchainName(key.ownerBch);
channelRef.setChannelName(ChannelsReadSupport.detectChannelName(c, key.ownerBch, key.rootNumber));
+ channelRef.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, key.ownerBch, key.rootNumber));
channelRef.setPersonal(key.rootNumber == 0);
Net_ListSubscriptionsFeed_Response.BlockRef rootRef = new Net_ListSubscriptionsFeed_Response.BlockRef();
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java
index 906aae4..49dabfb 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java
@@ -19,6 +19,7 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private String ownerLogin;
private String ownerBlockchainName;
private String channelName;
+ private String channelDescription;
private BlockRef channelRoot;
public String getOwnerLogin() { return ownerLogin; }
@@ -30,6 +31,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = channelName; }
+ public String getChannelDescription() { return channelDescription; }
+ public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
+
public BlockRef getChannelRoot() { return channelRoot; }
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java
index 4fb61b9..247b4de 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java
@@ -42,6 +42,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
private String ownerLogin;
private String ownerBlockchainName;
private String channelName;
+ private String channelDescription;
private boolean personal;
private BlockRef channelRoot;
@@ -54,6 +55,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = channelName; }
+ public String getChannelDescription() { return channelDescription; }
+ public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
+
public boolean isPersonal() { return personal; }
public void setPersonal(boolean personal) { this.personal = personal; }