Compare commits

...
Author SHA256 Message Date
qandClaude Opus 5.5 f8900e531a UI: новый общий стиль, тёмная/светлая тема, настраиваемая палитра
- styles/main.css: роли цветов (по умолчанию «Индиго»), шкала отступов/скруглений/шрифтов,
  цвета отношений для обеих тем; жёсткие цвета в стилях заменены на роли.
- Палитра: пресеты и личные правки, «Оформление» Авто/День/Ночь, долгое нажатие —
  редактор цветов с экспортом/импортом.
- Каналы: пузыри постов автора, плашки дней, строка «Написать в канал…», «О канале»,
  создание канала с адресом из названия; лента открывается на свежих постах.
- Чаты: плоский список, чипы-фильтры, пузыри, плашки дней; нижняя панель скрыта в переписке.
- Корневые разделы — единая шапка; профиль и чужой профиль — общая карточка;
  настройки, кошелёк, сеансы — меню-списки.
- Нижняя панель: иконки без подписей, бейджи на иконках.
- confirmDialog вместо window.confirm/alert; на телефоне диалоги — шторки снизу.
- docs/UI-Design/ISSUES-for-dev.md — найденные ошибки сервера/UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:05:26 +03:00
AidarKC 6e5b57fd7c Новая схема работы Arweave: каждое сообщение отдельным блоком и через Turbo 2026-09-25 13:20:00 +03:00
AidarKC 408e474130 Новая схема работы Arweave через Turbo 2026-09-25 13:19:24 +03:00
AidarKC ef707ec217 Добавить тестовые каналы и Arweave-синхронизацию 2026-09-23 11:14:02 +03:00
AidarKC 8b12760dde Дизайн Артёма 2026-09-22 14:09:02 +03:00
208 changed files with 22243 additions and 7503 deletions
@@ -20,7 +20,7 @@ public final class ArweaveBlockPublisherScheduler {
ArweaveBlocksConfig cfg; ArweaveBlocksConfig cfg;
try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); } try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); }
catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; } catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; }
if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled"); return; } if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled (mode=none)"); return; }
if (!STARTED.compareAndSet(false,true)) return; if (!STARTED.compareAndSet(false,true)) return;
try { try {
ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg); ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg);
@@ -29,7 +29,7 @@ public final class ArweaveBlockPublisherScheduler {
}); });
Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } }; Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } };
executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES); executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES);
log.info("Arweave user-block publisher enabled: interval={}m gateway={}", cfg.publishIntervalMinutes(), cfg.publishGateway()); log.info("Arweave user-block publisher enabled: mode={} interval={}m", cfg.publishMode(), cfg.publishIntervalMinutes());
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); } } catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); }
} }
@@ -8,23 +8,56 @@ import shine.db.entities.BlockEntry;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/** Publishes locally-created signed user DataItems as one standard ANS-104 bundle. */ /** Publishes locally-created signed user DataItems through the configured transport. */
public final class ArweaveBlockPublisherService { public final class ArweaveBlockPublisherService {
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class); private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class);
private final ArweaveBlocksConfig cfg; private final ArweaveBlocksConfig cfg;
private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
private final ArweaveL1Uploader uploader; private final ArweaveL1Uploader arweaveUploader;
private final TurboDataItemUploader turboUploader;
public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) { public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) {
this.cfg = cfg; this.cfg = cfg;
this.uploader = new ArweaveL1Uploader(cfg); this.arweaveUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.ARWEAVE ? new ArweaveL1Uploader(cfg) : null;
this.turboUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.TURBO ? new TurboDataItemUploader(cfg) : null;
} }
public int runCycle() throws Exception { public int runCycle() throws Exception {
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems()); List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
if (candidates.isEmpty()) return 0; if (candidates.isEmpty()) return 0;
return switch (cfg.publishMode()) {
case TURBO -> publishTurbo(candidates);
case ARWEAVE -> publishDirectArweave(candidates);
case NONE -> 0;
};
}
private int publishTurbo(List<BlockEntry> candidates) throws Exception {
int published = 0;
Exception firstFailure = null;
for (BlockEntry e : candidates) {
byte[] raw = e.getBlockBytes();
byte[] id = e.getDataItemId();
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
try {
TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id);
blocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis());
published++;
log.debug("Turbo published SHiNE DataItem {} chain={} block={}", result.dataItemId(), e.getBchName(), e.getBlockNumber());
} catch (Exception ex) {
if (firstFailure == null) firstFailure = ex;
log.warn("Turbo publish failed: chain={} block={} bytes={} error={}",
e.getBchName(), e.getBlockNumber(), raw.length, ex.getMessage());
}
}
if (published > 0) log.info("Published {} SHiNE test DataItems through Turbo", published);
if (published == 0 && firstFailure != null) throw firstFailure;
return published;
}
private int publishDirectArweave(List<BlockEntry> candidates) throws Exception {
List<byte[]> items = new ArrayList<>(); List<byte[]> items = new ArrayList<>();
List<byte[]> ids = new ArrayList<>(); List<byte[]> ids = new ArrayList<>();
long estimated = 32; long estimated = 32;
@@ -39,7 +72,9 @@ public final class ArweaveBlockPublisherService {
e.getBchName(), e.getBlockNumber(), raw.length); e.getBchName(), e.getBlockNumber(), raw.length);
continue; continue;
} }
items.add(raw); ids.add(id); estimated = next; items.add(raw);
ids.add(id);
estimated = next;
} }
if (items.isEmpty()) return 0; if (items.isEmpty()) return 0;
@@ -47,13 +82,12 @@ public final class ArweaveBlockPublisherService {
List<ArweaveL1Uploader.Tag> rootTags = List.of( List<ArweaveL1Uploader.Tag> rootTags = List.of(
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"), new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"), new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0"), new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
// Deliberately different from App=test5590 so discovery returns child DataItems only.
new ArweaveL1Uploader.Tag("App", "test5590-batch")
); );
ArweaveL1Uploader.UploadResult result = uploader.upload(bundle, rootTags); ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
blocksDAO.markArweavePublished(ids, result.txId(), System.currentTimeMillis()); blocksDAO.markArweavePublished(ids, System.currentTimeMillis());
log.info("Published {} SHiNE test DataItems in root tx {} (bundle={} bytes)", items.size(), result.txId(), bundle.length); log.info("Published {} SHiNE test DataItems in direct Arweave root tx {} (bundle={} bytes)",
items.size(), result.txId(), bundle.length);
return items.size(); return items.size();
} }
} }
@@ -14,7 +14,9 @@ import shine.db.dao.SolanaUserPdaCurrentDAO;
import shine.db.entities.BlockchainStateEntry; import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SolanaUserPdaCurrentEntry; import shine.db.entities.SolanaUserPdaCurrentEntry;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
@@ -24,8 +26,9 @@ import java.time.Duration;
import java.util.*; import java.util.*;
/** /**
* Discovers App=test5590 child DataItems, extracts their exact serialized bytes * Discovers individual App=test5590 DataItems and imports the exact signed ANS-104 bytes.
* from the root ANS-104 bundle and imports them through the normal AddBlock checks. * The importer is transport-agnostic: a DataItem may have reached Arweave through Turbo
* or inside a direct server-created ANS-104 bundle.
*/ */
public final class ArweaveBlockSyncService { public final class ArweaveBlockSyncService {
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class); private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class);
@@ -33,7 +36,10 @@ public final class ArweaveBlockSyncService {
private static final Base64.Decoder B64URL = Base64.getUrlDecoder(); private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
private final ArweaveBlocksConfig cfg; private final ArweaveBlocksConfig cfg;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance(); private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance();
private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance(); private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance();
@@ -52,7 +58,7 @@ public final class ArweaveBlockSyncService {
long minHeight = Math.max(cfg.syncStartBlockHeight(), stored); long minHeight = Math.max(cfg.syncStartBlockHeight(), stored);
String cursor = null; String cursor = null;
long maxHeight = minHeight; long maxHeight = minHeight;
Map<String, byte[]> rootCache = new HashMap<>(); long lowestRetryHeight = Long.MAX_VALUE;
int discovered = 0; int discovered = 0;
do { do {
@@ -66,36 +72,40 @@ public final class ArweaveBlockSyncService {
for (JsonNode edge : edges) { for (JsonNode edge : edges) {
nextCursor = edge.path("cursor").asText(null); nextCursor = edge.path("cursor").asText(null);
JsonNode node = edge.path("node"); JsonNode node = edge.path("node");
String itemIdText = node.path("id").asText("").trim(); String dataItemId = node.path("id").asText("").trim();
String rootTx = node.path("bundledIn").path("id").asText("").trim();
long height = node.path("block").path("height").asLong(-1L); long height = node.path("block").path("height").asLong(-1L);
if (itemIdText.isBlank() || rootTx.isBlank() || height < 0) continue; if (dataItemId.isBlank() || height < 0) continue;
maxHeight = Math.max(maxHeight, height); maxHeight = Math.max(maxHeight, height);
byte[] id32;
try { id32 = B64URL.decode(itemIdText); }
catch (IllegalArgumentException bad) { continue; }
if (id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
byte[] bundle = rootCache.computeIfAbsent(rootTx, key -> { byte[] id32;
try { return downloadRootBundle(key); } try {
catch (Exception e) { throw new RootDownloadRuntimeException(e); } id32 = B64URL.decode(dataItemId);
}); if (id32.length != 32) throw new IllegalArgumentException("id length=" + id32.length);
byte[] raw = Ans104Bundle.find(bundle, id32, Math.max(cfg.publishMaxItems() * 4, 100_000)); } catch (Exception e) {
if (raw == null) throw new IOException("DataItem " + itemIdText + " not found in root bundle " + rootTx); log.warn("Ignoring malformed Arweave DataItem id {}: {}", dataItemId, e.getMessage());
Ans104DataItem parsed = new Ans104DataItem(raw); continue;
if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue; }
if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature for " + itemIdText); if (blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
importDAO.enqueueIfMissing(id32, rootTx, height, raw, System.currentTimeMillis());
discovered++; try {
byte[] rawDataItem = downloadSignedDataItem(dataItemId);
if (importDAO.enqueueIfMissing(id32, height, rawDataItem, System.currentTimeMillis())) discovered++;
} catch (Exception e) {
lowestRetryHeight = Math.min(lowestRetryHeight, height);
log.warn("Cannot retrieve signed DataItem {} at height {} yet: {}", dataItemId, height, e.getMessage());
}
} }
boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false); boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false);
cursor = hasNext ? nextCursor : null; cursor = hasNext ? nextCursor : null;
if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor"); if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor");
} while (cursor != null); } while (cursor != null);
// Keep one-height overlap: the next query includes this height and deduplicates IDs. // Keep inclusive overlap. If a gateway has indexed GraphQL before offsets, do not advance past that item.
importDAO.setLastBlockHeight(maxHeight, System.currentTimeMillis()); long checkpoint = lowestRetryHeight == Long.MAX_VALUE ? maxHeight : Math.min(maxHeight, lowestRetryHeight);
if (discovered > 0) log.info("Arweave discovery queued {} new SHiNE test DataItems through height {}", discovered, maxHeight); importDAO.setLastBlockHeight(checkpoint, System.currentTimeMillis());
if (discovered > 0) {
log.info("Arweave discovery queued {} new SHiNE test DataItems; checkpoint={}", discovered, checkpoint);
}
} }
private void drainQueue() throws Exception { private void drainQueue() throws Exception {
@@ -108,13 +118,12 @@ public final class ArweaveBlockSyncService {
if (pending.isEmpty()) break; if (pending.isEmpty()) break;
for (ArweaveBlockImportDAO.QueueItem q : pending) { for (ArweaveBlockImportDAO.QueueItem q : pending) {
Ans104DataItem item; Ans104DataItem item;
BchBlockEntry block;
try { try {
item = new Ans104DataItem(q.rawDataItem()); item = new Ans104DataItem(q.rawDataItem());
if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch"); if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch");
if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag"); if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag");
if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature"); if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature");
block = new BchBlockEntry(q.rawDataItem()); new BchBlockEntry(q.rawDataItem());
} catch (Exception e) { } catch (Exception e) {
importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis()); importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis());
continue; continue;
@@ -158,7 +167,10 @@ public final class ArweaveBlockSyncService {
private JsonNode graphQlPage(long minHeight, String cursor) throws Exception { private JsonNode graphQlPage(long minHeight, String cursor) throws Exception {
String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\""; String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\"";
String query = "query { transactions(tags:[{name:\"App\",values:[\"test5590\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id bundledIn { id } block { height } } } } }"; String query = "query { transactions(tags:[{name:\"" + ArweaveBlocksConfig.TEST_TAG_NAME + "\",values:[\""
+ ArweaveBlocksConfig.TEST_TAG_VALUE + "\"]}], block:{min:" + minHeight + "}, first:"
+ cfg.syncPageSize() + ", after:" + after
+ ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id block { height } } } } }";
String body = MAPPER.writeValueAsString(Map.of("query", query)); String body = MAPPER.writeValueAsString(Map.of("query", query));
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql")) HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql"))
.timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json") .timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json")
@@ -168,18 +180,73 @@ public final class ArweaveBlockSyncService {
return MAPPER.readTree(resp.body()); return MAPPER.readTree(resp.body());
} }
private byte[] downloadRootBundle(String txId) throws Exception { /**
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/" + txId)) * Gateways normally expose only the payload at /{dataItemId}. SHiNE needs the complete signed
.timeout(Duration.ofMinutes(5)).GET().build(); * DataItem, so obtain its exact offset/size inside the root L1 transaction and range-read it.
HttpResponse<byte[]> resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray()); */
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Arweave root HTTP " + resp.statusCode() + " tx=" + txId); private byte[] downloadSignedDataItem(String dataItemId) throws Exception {
byte[] body = resp.body(); JsonNode offsets = getOffsets(dataItemId);
if (body == null || body.length == 0) throw new IOException("Empty Arweave root bundle " + txId); String rootTxId = offsets.path("rootTxId").asText("").trim();
if (body.length > cfg.syncMaxRootBundleBytes()) throw new IOException("Root bundle exceeds syncMaxRootBundleBytes: " + body.length); long rootOffset = offsets.path("rootOffset").asLong(-1L);
return body; long size = offsets.path("size").asLong(-1L);
if (rootTxId.isBlank() || rootOffset < 0 || size <= 0) {
throw new IOException("Bad AR.IO offsets for " + dataItemId + ": " + offsets);
}
if (size > cfg.syncMaxDataItemBytes()) {
throw new IOException("DataItem exceeds syncMaxDataItemBytes: " + size);
}
if (rootOffset > Long.MAX_VALUE - size) throw new IOException("DataItem offset overflow");
long endInclusive = rootOffset + size - 1;
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/raw/" + rootTxId))
.timeout(Duration.ofMinutes(2))
.header("Range", "bytes=" + rootOffset + "-" + endInclusive)
.header("Accept", "application/octet-stream")
.GET().build();
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
try (InputStream in = resp.body()) {
if (resp.statusCode() != 206) {
throw new IOException("Gateway ignored root range for DataItem " + dataItemId + ": HTTP " + resp.statusCode());
}
byte[] bytes = readExactlyBounded(in, (int) size);
if (bytes.length != size) throw new IOException("Truncated DataItem range: expected=" + size + " got=" + bytes.length);
Ans104DataItem parsed = new Ans104DataItem(bytes);
byte[] expectedId = B64URL.decode(dataItemId);
if (!Arrays.equals(parsed.id32(), expectedId)) {
throw new IOException("Range returned another ANS-104 DataItem for " + dataItemId);
}
return bytes;
}
} }
private JsonNode getOffsets(String dataItemId) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/ar-io/offsets/" + dataItemId))
.timeout(Duration.ofSeconds(30)).header("Accept","application/json").GET().build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() == 404) throw new IOException("AR.IO offsets not indexed yet");
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("AR.IO offsets HTTP " + resp.statusCode() + ": " + safe(resp.body()));
}
return MAPPER.readTree(resp.body());
}
private static byte[] readExactlyBounded(InputStream in, int expected) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(expected);
byte[] buf = new byte[Math.min(64 * 1024, Math.max(1024, expected))];
int remaining = expected;
while (remaining > 0) {
int n = in.read(buf, 0, Math.min(buf.length, remaining));
if (n < 0) break;
out.write(buf, 0, n);
remaining -= n;
}
return out.toByteArray();
}
private static String safe(String value) {
String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim();
return v.length() <= 500 ? v : v.substring(0, 500);
}
private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");} private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");}
private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");} private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");}
private static final class RootDownloadRuntimeException extends RuntimeException { RootDownloadRuntimeException(Throwable cause){super(cause);} }
} }
@@ -1,12 +1,14 @@
package server.archive; package server.archive;
import blockchain.Ans104DataItem;
import utils.config.AppConfig; import utils.config.AppConfig;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Locale;
/** Configuration of the new per-user-block ANS-104 Arweave transport. */ /** Configuration of per-user-block ANS-104 Arweave/Turbo transport. */
public record ArweaveBlocksConfig( public record ArweaveBlocksConfig(
boolean publishEnabled, PublishMode publishMode,
int publishIntervalMinutes, int publishIntervalMinutes,
int publishMaxItems, int publishMaxItems,
long publishMaxBundleBytes, long publishMaxBundleBytes,
@@ -15,22 +17,44 @@ public record ArweaveBlocksConfig(
int minConfirmations, int minConfirmations,
int confirmPollSeconds, int confirmPollSeconds,
int confirmTimeoutMinutes, int confirmTimeoutMinutes,
String turboUploadUrl,
String turboPaidByAddress,
Path turboWalletJwkPath,
boolean syncEnabled, boolean syncEnabled,
int syncIntervalMinutes, int syncIntervalMinutes,
int syncPageSize, int syncPageSize,
int syncQueueBatchSize, int syncQueueBatchSize,
long syncStartBlockHeight, long syncStartBlockHeight,
long syncMaxRootBundleBytes, long syncMaxDataItemBytes,
String syncGateway String syncGateway
) { ) {
public enum PublishMode {
TURBO,
ARWEAVE,
NONE;
static PublishMode parse(String value) {
String normalized = value == null ? "none" : value.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "turbo" -> TURBO;
case "arweave" -> ARWEAVE;
case "none", "" -> NONE;
default -> throw new IllegalArgumentException(
"arweave.blocks.publish.mode must be turbo, arweave or none; got: " + value);
};
}
}
public static final String TEST_TAG_NAME = "App"; public static final String TEST_TAG_NAME = "App";
public static final String TEST_TAG_VALUE = "test5590"; public static final String TEST_TAG_VALUE = "test5590";
public static final String CHANNEL_TAG_NAME = "c"; public static final String CHANNEL_TAG_NAME = "c_test5590";
public static ArweaveBlocksConfig load() { public static ArweaveBlocksConfig load() {
AppConfig c = AppConfig.getInstance(); AppConfig c = AppConfig.getInstance();
long maxDataItemBytes = parseLong(c.getParam("arweave.blocks.sync.maxDataItemBytes"), Ans104DataItem.MAX_DATA_ITEM_BYTES);
if (maxDataItemBytes > Ans104DataItem.MAX_DATA_ITEM_BYTES) maxDataItemBytes = Ans104DataItem.MAX_DATA_ITEM_BYTES;
return new ArweaveBlocksConfig( return new ArweaveBlocksConfig(
c.getBoolean("arweave.blocks.publish.enabled", false), PublishMode.parse(c.getParam("arweave.blocks.publish.mode")),
positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"), positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"),
positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"), positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"),
positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"), positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"),
@@ -39,23 +63,32 @@ public record ArweaveBlocksConfig(
nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"), nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"),
positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"), positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"),
positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"), positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"),
orDefault(c.getParam("arweave.blocks.publish.turbo.uploadUrl"), "https://turbo.ardrive.io/tx"),
blankToNull(c.getParam("arweave.blocks.publish.turbo.paidByAddress")),
optionalPath(c.getParam("arweave.blocks.publish.turbo.walletJwkPath")),
c.getBoolean("arweave.blocks.sync.enabled", false), c.getBoolean("arweave.blocks.sync.enabled", false),
positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"), positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"),
clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100), clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100),
positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"), positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"),
nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"), nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"),
positiveLong(parseLong(c.getParam("arweave.blocks.sync.maxRootBundleBytes"), 256L * 1024 * 1024), "sync.maxRootBundleBytes"), positiveLong(maxDataItemBytes, "sync.maxDataItemBytes"),
orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com") orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com")
); );
} }
public boolean publishEnabled() { return publishMode != PublishMode.NONE; }
public void validatePublisher() { public void validatePublisher() {
if (publishEnabled && walletJwkPath == null) { if (publishMode == PublishMode.ARWEAVE && walletJwkPath == null) {
throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required when publisher is enabled"); throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required for mode=arweave");
}
if (publishMode == PublishMode.TURBO && (turboUploadUrl == null || turboUploadUrl.isBlank())) {
throw new IllegalArgumentException("arweave.blocks.publish.turbo.uploadUrl is required for mode=turbo");
} }
} }
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); } private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
private static String blankToNull(String v) { return v == null || v.isBlank() ? null : v.trim(); }
private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); } private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); }
private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); } private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); }
private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;} private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
@@ -0,0 +1,106 @@
package server.archive;
import blockchain.Ans104DataItem;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
/** Uploads an already user-signed ANS-104 DataItem to Turbo without modifying it. */
public final class TurboDataItemUploader {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64URL_DECODER = Base64.getUrlDecoder();
public record UploadResult(String dataItemId, String owner) {}
private final ArweaveBlocksConfig cfg;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private volatile String resolvedPaidByAddress;
public TurboDataItemUploader(ArweaveBlocksConfig cfg) {
this.cfg = Objects.requireNonNull(cfg);
}
public UploadResult upload(byte[] rawDataItem, byte[] expectedId32) throws Exception {
if (rawDataItem == null || rawDataItem.length == 0) throw new IllegalArgumentException("Turbo DataItem is empty");
Ans104DataItem item = new Ans104DataItem(rawDataItem);
if (!item.verifySignature()) throw new IllegalArgumentException("Turbo DataItem has bad ANS-104 signature");
if (expectedId32 != null && !Arrays.equals(item.id32(), expectedId32)) {
throw new IllegalArgumentException("Turbo DataItem id does not match blocks.data_item_id");
}
String expectedId = B64URL.encodeToString(item.id32());
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(cfg.turboUploadUrl()))
.timeout(Duration.ofMinutes(2))
.header("Content-Type", "application/octet-stream")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofByteArray(rawDataItem));
String paidBy = paidByAddress();
if (paidBy != null) request.header("x-paid-by", paidBy);
HttpResponse<String> response = http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
String body = response.body() == null ? "" : response.body().trim();
if (response.statusCode() == 409 && body.toLowerCase().contains("data item exists")) {
return new UploadResult(expectedId, B64URL.encodeToString(item.owner32()));
}
if (response.statusCode() < 200 || response.statusCode() >= 300) {
String hint = response.statusCode() == 402
? " (Turbo payment required: check server credits / Credit Share Approval / x-paid-by)"
: "";
throw new IOException("Turbo HTTP " + response.statusCode() + hint + ": " + safe(body));
}
if (body.isBlank()) return new UploadResult(expectedId, B64URL.encodeToString(item.owner32()));
JsonNode json;
try { json = MAPPER.readTree(body); }
catch (Exception ignored) { return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); }
String returnedId = json.path("id").asText("").trim();
if (!returnedId.isBlank() && !returnedId.equals(expectedId)) {
throw new IOException("Turbo returned another DataItem id: expected=" + expectedId + " got=" + returnedId);
}
return new UploadResult(expectedId, json.path("owner").asText(""));
}
/**
* x-paid-by contains the payer's public native address, never the private key.
* A configured Arweave JWK is used only to derive that public address.
*/
private String paidByAddress() throws Exception {
if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress;
synchronized (this) {
if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress;
String explicit = cfg.turboPaidByAddress();
if (explicit != null && !explicit.isBlank()) return resolvedPaidByAddress = explicit.trim();
if (cfg.turboWalletJwkPath() == null) {
resolvedPaidByAddress = "";
return null;
}
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.turboWalletJwkPath(), StandardCharsets.UTF_8));
String modulus = jwk.path("n").asText("").trim();
if (modulus.isBlank()) throw new IllegalStateException("Turbo payer JWK missing n");
byte[] owner = B64URL_DECODER.decode(modulus);
resolvedPaidByAddress = B64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(owner));
return resolvedPaidByAddress;
}
}
private static String safe(String value) {
String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim();
return v.length() <= 500 ? v : v.substring(0, 500);
}
}
@@ -16,7 +16,7 @@ final class Ans104DataItemTest {
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8); byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
List<Ans104DataItem.Tag> tags = List.of( List<Ans104DataItem.Tag> tags = List.of(
new Ans104DataItem.Tag("App", "test5590"), new Ans104DataItem.Tag("App", "test5590"),
new Ans104DataItem.Tag("c", "books") new Ans104DataItem.Tag("c_test5590", "books")
); );
byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data); byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data);
@@ -28,7 +28,7 @@ final class Ans104DataItemTest {
assertArrayEquals(owner, parsed.owner32()); assertArrayEquals(owner, parsed.owner32());
assertArrayEquals(data, parsed.data()); assertArrayEquals(data, parsed.data());
assertTrue(parsed.hasTag("App", "test5590")); assertTrue(parsed.hasTag("App", "test5590"));
assertEquals("books", parsed.tagValue("c")); assertEquals("books", parsed.tagValue("c_test5590"));
assertTrue(parsed.verifySignature()); assertTrue(parsed.verifySignature());
assertEquals(32, parsed.id32().length); assertEquals(32, parsed.id32().length);
} }
@@ -51,11 +51,11 @@ final class Ans104DataItemTest {
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8); byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
List<Ans104DataItem.Tag> tags = List.of( List<Ans104DataItem.Tag> tags = List.of(
new Ans104DataItem.Tag("App", "test5590"), new Ans104DataItem.Tag("App", "test5590"),
new Ans104DataItem.Tag("c", "books") new Ans104DataItem.Tag("c_test5590", "books")
); );
byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data); byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data);
byte[] expected = hex("7e67d0debce103606d697a1e3785130ca20cb89cd8a06f9a65b98b2d2427eaf411fcf7d482da268e44f3ac25c57c3cb9"); byte[] expected = hex("1abe0371d12268b34be32ca5ebf3d3d2189f9d311d9004c142538ca8a4312faa329d1a4873df72dd01b632a2ecce6b87");
assertArrayEquals(expected, actual); assertArrayEquals(expected, actual);
} }
@@ -41,6 +41,7 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_22 = 22; public static final int SCHEMA_VERSION_22 = 22;
public static final int SCHEMA_VERSION_23 = 23; public static final int SCHEMA_VERSION_23 = 23;
public static final int SCHEMA_VERSION_24 = 24; public static final int SCHEMA_VERSION_24 = 24;
public static final int SCHEMA_VERSION_25 = 25;
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
@@ -65,6 +66,7 @@ public final class DatabaseInitializer {
public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql"; public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql";
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql"; public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql"; public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
private DatabaseInitializer() {} private DatabaseInitializer() {}
@@ -230,6 +232,10 @@ public final class DatabaseInitializer {
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE); runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
currentVersion = SCHEMA_VERSION_24; currentVersion = SCHEMA_VERSION_24;
} }
if (currentVersion < SCHEMA_VERSION_25) {
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
currentVersion = SCHEMA_VERSION_25;
}
} }
} }
@@ -6,12 +6,12 @@ import java.sql.*;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/** Persistent discovery/import queue for ANS-104 SHiNE blocks found through Arweave. */ /** Persistent discovery/import queue for individual ANS-104 SHiNE DataItems found through Arweave. */
public final class ArweaveBlockImportDAO { public final class ArweaveBlockImportDAO {
public static final String STATUS_PENDING = "PENDING"; public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_REJECTED = "REJECTED"; public static final String STATUS_REJECTED = "REJECTED";
public record QueueItem(byte[] dataItemId, String rootTxId, long blockHeight, public record QueueItem(byte[] dataItemId, long blockHeight,
byte[] rawDataItem, String status, String lastError, byte[] rawDataItem, String status, String lastError,
long firstSeenAtMs, long updatedAtMs) {} long firstSeenAtMs, long updatedAtMs) {}
@@ -51,22 +51,21 @@ public final class ArweaveBlockImportDAO {
} }
/** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */ /** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */
public boolean enqueueIfMissing(byte[] dataItemId, String rootTxId, long blockHeight, byte[] rawDataItem, long nowMs) public boolean enqueueIfMissing(byte[] dataItemId, long blockHeight, byte[] rawDataItem, long nowMs)
throws SQLException { throws SQLException {
String sql = """ String sql = """
INSERT INTO arweave_block_import_queue( INSERT INTO arweave_block_import_queue(
data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
) VALUES(?,?,?,?,?,'',?,?) ) VALUES(?,?,?,?, '',?,?)
ON CONFLICT(data_item_id) DO NOTHING ON CONFLICT(data_item_id) DO NOTHING
"""; """;
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setBytes(1, dataItemId); ps.setBytes(1, dataItemId);
ps.setString(2, rootTxId); ps.setLong(2, blockHeight);
ps.setLong(3, blockHeight); ps.setBytes(3, rawDataItem);
ps.setBytes(4, rawDataItem); ps.setString(4, STATUS_PENDING);
ps.setString(5, STATUS_PENDING); ps.setLong(5, nowMs);
ps.setLong(6, nowMs); ps.setLong(6, nowMs);
ps.setLong(7, nowMs);
return ps.executeUpdate() > 0; return ps.executeUpdate() > 0;
} }
} }
@@ -82,7 +81,7 @@ public final class ArweaveBlockImportDAO {
public List<QueueItem> listPending(int limit) throws SQLException { public List<QueueItem> listPending(int limit) throws SQLException {
int safeLimit = Math.max(1, Math.min(limit, 100_000)); int safeLimit = Math.max(1, Math.min(limit, 100_000));
String sql = """ String sql = """
SELECT data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms SELECT data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
FROM arweave_block_import_queue FROM arweave_block_import_queue
WHERE status=? WHERE status=?
ORDER BY block_height ASC, first_seen_at_ms ASC ORDER BY block_height ASC, first_seen_at_ms ASC
@@ -95,7 +94,7 @@ public final class ArweaveBlockImportDAO {
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { while (rs.next()) {
out.add(new QueueItem( out.add(new QueueItem(
rs.getBytes("data_item_id"), rs.getString("root_tx_id"), rs.getLong("block_height"), rs.getBytes("data_item_id"), rs.getLong("block_height"),
rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"), rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"),
rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms"))); rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms")));
} }
@@ -27,9 +27,9 @@ public final class BlocksDAO {
login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash, to_login,to_bch_name,to_block_number,to_block_hash,
block_hash,block_signature,data_item_id, block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, arweave_publish_pending,arweave_published_at_ms,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
"""; """;
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1; int i = 1;
@@ -48,7 +48,6 @@ public final class BlocksDAO {
ps.setBytes(i++, e.getDataItemId()); ps.setBytes(i++, e.getDataItemId());
ps.setBoolean(i++, e.isArweavePublishPending()); ps.setBoolean(i++, e.isArweavePublishPending());
if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs()); if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs());
setNullableString(ps, i++, e.getArweaveRootTxId());
setNullableInt(ps, i++, e.getEditedByBlockNumber()); setNullableInt(ps, i++, e.getEditedByBlockNumber());
setNullableInt(ps, i++, e.getLineCode()); setNullableInt(ps, i++, e.getLineCode());
setNullableInt(ps, i++, e.getPrevLineNumber()); setNullableInt(ps, i++, e.getPrevLineNumber());
@@ -74,7 +73,7 @@ public final class BlocksDAO {
String sql = """ String sql = """
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id, to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, arweave_publish_pending,arweave_published_at_ms,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
FROM blocks FROM blocks
WHERE arweave_publish_pending = TRUE WHERE arweave_publish_pending = TRUE
@@ -89,18 +88,18 @@ public final class BlocksDAO {
return out; return out;
} }
public void markArweavePublished(List<byte[]> dataItemIds, String rootTxId, long publishedAtMs) throws SQLException { public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
if (dataItemIds == null || dataItemIds.isEmpty()) return; if (dataItemIds == null || dataItemIds.isEmpty()) return;
String sql = """ String sql = """
UPDATE blocks UPDATE blocks
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?, arweave_root_tx_id=? SET arweave_publish_pending=FALSE, arweave_published_at_ms=?
WHERE data_item_id=? WHERE data_item_id=?
"""; """;
try (Connection c = db.getConnection()) { try (Connection c = db.getConnection()) {
c.setAutoCommit(false); c.setAutoCommit(false);
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
for (byte[] id : dataItemIds) { for (byte[] id : dataItemIds) {
ps.setLong(1, publishedAtMs); ps.setString(2, rootTxId); ps.setBytes(3, id); ps.addBatch(); ps.setLong(1, publishedAtMs); ps.setBytes(2, id); ps.addBatch();
} }
ps.executeBatch(); c.commit(); ps.executeBatch(); c.commit();
} catch (Exception e) { c.rollback(); throw e; } } catch (Exception e) { c.rollback(); throw e; }
@@ -133,7 +132,7 @@ public final class BlocksDAO {
private static String baseSelect(){return """ private static String baseSelect(){return """
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id, to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, arweave_publish_pending,arweave_published_at_ms,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
FROM blocks FROM blocks
""";} """;}
@@ -144,7 +143,7 @@ public final class BlocksDAO {
e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes")); e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes"));
e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash")); e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash"));
e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id")); e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id"));
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms")); e.setArweaveRootTxId(rs.getString("arweave_root_tx_id")); e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms"));
e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number")); e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number"));
return e; return e;
} }
@@ -40,7 +40,6 @@ public class BlockEntry {
private byte[] dataItemId; private byte[] dataItemId;
private boolean arweavePublishPending; private boolean arweavePublishPending;
private Long arweavePublishedAtMs; private Long arweavePublishedAtMs;
private String arweaveRootTxId;
private Integer editedByBlockNumber; private Integer editedByBlockNumber;
@@ -95,8 +94,6 @@ public class BlockEntry {
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; } public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; } public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; } public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
public String getArweaveRootTxId() { return arweaveRootTxId; }
public void setArweaveRootTxId(String arweaveRootTxId) { this.arweaveRootTxId = arweaveRootTxId; }
public Integer getEditedByBlockNumber() { return editedByBlockNumber; } public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; } public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; }
@@ -0,0 +1,11 @@
-- Turbo/direct-Arweave transport no longer stores root transaction IDs for user DataItems.
ALTER TABLE blocks
DROP COLUMN IF EXISTS arweave_root_tx_id;
ALTER TABLE arweave_block_import_queue
DROP COLUMN IF EXISTS root_tx_id;
UPDATE db_schema_version
SET schema_version=25,
updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)
WHERE id=1;
@@ -500,7 +500,6 @@ CREATE TABLE IF NOT EXISTS blocks (
data_item_id BYTEA NOT NULL, data_item_id BYTEA NOT NULL,
arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE, arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE,
arweave_published_at_ms BIGINT, arweave_published_at_ms BIGINT,
arweave_root_tx_id TEXT,
edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0), edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0),
line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0), line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0),
prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0), prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0),
@@ -535,7 +534,6 @@ VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS arweave_block_import_queue ( CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
data_item_id BYTEA PRIMARY KEY, data_item_id BYTEA PRIMARY KEY,
root_tx_id TEXT NOT NULL,
block_height BIGINT NOT NULL, block_height BIGINT NOT NULL,
raw_data_item BYTEA NOT NULL, raw_data_item BYTEA NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
@@ -2081,7 +2079,7 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
WHERE archive_head_tx_id <> ''; WHERE archive_head_tx_id <> '';
INSERT INTO db_schema_version(id,schema_version,updated_at_ms) INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) VALUES(1,25,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
COMMIT; COMMIT;
@@ -409,10 +409,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L); channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
} }
// Channel DataItems are indexed by a signed canonical channel slug tag: c=<slug>. // Channel DataItems are indexed by a signed canonical channel slug tag: c_test5590=<slug>.
try { try {
String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry); String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry);
String actualChannelSlug = block.getDataItem().tagValue("c"); String actualChannelSlug = block.getDataItem().tagValue("c_test5590");
if (expectedChannelSlug != null) { if (expectedChannelSlug != null) {
if (!expectedChannelSlug.equals(actualChannelSlug)) { if (!expectedChannelSlug.equals(actualChannelSlug)) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
@@ -10,6 +10,7 @@ import shine.db.dao.SyncServersDAO;
import shine.db.entities.BlockEntry; import shine.db.entities.BlockEntry;
import shine.db.entities.SyncServerEntry; import shine.db.entities.SyncServerEntry;
import utils.blockchain.BlockchainNameUtil; import utils.blockchain.BlockchainNameUtil;
import utils.config.AppConfig;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -46,6 +47,11 @@ public final class AddBlockSyncService {
private final SyncServersDAO syncServersDAO = SyncServersDAO.getInstance(); private final SyncServersDAO syncServersDAO = SyncServersDAO.getInstance();
public void replicateAsync(String blockchainName, int blockNumber) { public void replicateAsync(String blockchainName, int blockNumber) {
if (!isEnabled()) {
log.debug("AddBlock sync skipped: blockchain.sync.enabled=false blockchainName={} blockNumber={}",
blockchainName, blockNumber);
return;
}
EXECUTOR.execute(() -> { EXECUTOR.execute(() -> {
try { try {
replicate(blockchainName, blockNumber); replicate(blockchainName, blockNumber);
@@ -56,6 +62,10 @@ public final class AddBlockSyncService {
}); });
} }
private static boolean isEnabled() {
return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true);
}
private void replicate(String blockchainName, int blockNumber) throws Exception { private void replicate(String blockchainName, int blockNumber) throws Exception {
String ownerLogin = normalize(BlockchainNameUtil.loginFromBlockchainName(blockchainName)); String ownerLogin = normalize(BlockchainNameUtil.loginFromBlockchainName(blockchainName));
if (ownerLogin == null) { if (ownerLogin == null) {
@@ -327,6 +327,25 @@ public final class SolanaUsersSyncService
state.lastSeenSignature() state.lastSeenSignature()
); );
if (state.lastSeenSignature() == null) {
log.warn(
"History checkpoint is empty. Running current-state full snapshot bootstrap instead of replaying all historical transactions."
);
runFullSnapshotFallback(
state,
fetchResult,
nowMs
);
markReadyAfterSync(
state,
nowMs
);
return;
}
if (state.lastSeenSignature() != null if (state.lastSeenSignature() != null
&& !fetchResult.anchorFound()) { && !fetchResult.anchorFound()) {
@@ -16,6 +16,7 @@ import shine.db.entities.SolanaUserPdaCurrentEntry;
import shine.db.entities.SyncServerEntry; import shine.db.entities.SyncServerEntry;
import server.sync.BlockchainResyncGuard; import server.sync.BlockchainResyncGuard;
import utils.blockchain.BlockchainNameUtil; import utils.blockchain.BlockchainNameUtil;
import utils.config.AppConfig;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import java.util.List; import java.util.List;
@@ -56,6 +57,10 @@ public final class PeriodicBlockchainSyncService {
private PeriodicBlockchainSyncService() {} private PeriodicBlockchainSyncService() {}
public static void startOrLog() { public static void startOrLog() {
if (!isEnabled()) {
log.info("Periodic blockchain sync disabled by blockchain.sync.enabled=false");
return;
}
if (!STARTED.compareAndSet(false, true)) { if (!STARTED.compareAndSet(false, true)) {
return; return;
} }
@@ -68,6 +73,10 @@ public final class PeriodicBlockchainSyncService {
log.info("Periodic blockchain sync scheduled: startup + every {} hours", PERIOD_HOURS); log.info("Periodic blockchain sync scheduled: startup + every {} hours", PERIOD_HOURS);
} }
private static boolean isEnabled() {
return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true);
}
private static void runCycleSafe() { private static void runCycleSafe() {
try { try {
runCycle(); runCycle();
@@ -27,6 +27,13 @@ solana.users.sync.pollIntervalSeconds=300
# ------------------------------------------------------------ # ------------------------------------------------------------
sync.importUserProfileFromPartner.enabled=false sync.importUserProfileFromPartner.enabled=false
# ------------------------------------------------------------
# Прямая межсерверная синхронизация пользовательских блокчейнов
# через sync_servers: ListBlockchainHeads/GetBlockchainBlock -> локальный AddBlock.
# Для проверки режима Solana + Arweave без прямых связей серверов можно выключить.
# ------------------------------------------------------------
blockchain.sync.enabled=true
# ------------------------------------------------------------ # ------------------------------------------------------------
# Server public info # Server public info
# Эти поля используются JSON-операцией GetServerInfo. # Эти поля используются JSON-операцией GetServerInfo.
@@ -131,11 +138,14 @@ test.freeAvatar.walletJwkPath=
# ============================================================ # ============================================================
# Arweave per-user-block transport (ANS-104) # Arweave per-user-block transport (ANS-104)
# Test namespace: each user DataItem is signed with App=test5590. # Test namespace: each user DataItem is signed with App=test5590.
# Channel DataItems additionally contain c=<canonical-channel-slug>. # Channel DataItems additionally contain c_test5590=<canonical-channel-slug>.
# publish.mode: turbo | arweave | none
# ============================================================ # ============================================================
arweave.blocks.publish.enabled=false arweave.blocks.publish.mode=none
arweave.blocks.publish.intervalMinutes=15 arweave.blocks.publish.intervalMinutes=15
arweave.blocks.publish.maxItems=10000 arweave.blocks.publish.maxItems=10000
# Direct Arweave L1 fallback: server combines user DataItems into one standard ANS-104 bundle.
arweave.blocks.publish.maxBundleBytes=134217728 arweave.blocks.publish.maxBundleBytes=134217728
arweave.blocks.publish.gateway=https://arweave.net arweave.blocks.publish.gateway=https://arweave.net
arweave.blocks.publish.walletJwkPath= arweave.blocks.publish.walletJwkPath=
@@ -143,10 +153,18 @@ arweave.blocks.publish.minConfirmations=0
arweave.blocks.publish.confirmPollSeconds=30 arweave.blocks.publish.confirmPollSeconds=30
arweave.blocks.publish.confirmTimeoutMinutes=180 arweave.blocks.publish.confirmTimeoutMinutes=180
# Turbo: uploads each already user-signed DataItem separately, without re-signing it.
# paidByAddress is the public Turbo payer address. If it is empty and turbo.walletJwkPath is set,
# the Arweave payer address is derived locally from that JWK. The private key is never sent to Turbo.
# For paid uploads of someone else's signed DataItem, Turbo Credit Share Approval must exist for its signer.
arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx
arweave.blocks.publish.turbo.paidByAddress=
arweave.blocks.publish.turbo.walletJwkPath=
arweave.blocks.sync.enabled=false arweave.blocks.sync.enabled=false
arweave.blocks.sync.intervalMinutes=15 arweave.blocks.sync.intervalMinutes=15
arweave.blocks.sync.gateway=https://turbo-gateway.com arweave.blocks.sync.gateway=https://turbo-gateway.com
arweave.blocks.sync.pageSize=100 arweave.blocks.sync.pageSize=100
arweave.blocks.sync.queueBatchSize=10000 arweave.blocks.sync.queueBatchSize=10000
arweave.blocks.sync.startBlockHeight=0 arweave.blocks.sync.startBlockHeight=0
arweave.blocks.sync.maxRootBundleBytes=268435456 arweave.blocks.sync.maxDataItemBytes=8388608
@@ -82,7 +82,7 @@ public final class AddBlockSender {
List<Ans104DataItem.Tag> tags = new ArrayList<>(); List<Ans104DataItem.Tag> tags = new ArrayList<>();
tags.add(new Ans104DataItem.Tag("App", "test5590")); tags.add(new Ans104DataItem.Tag("App", "test5590"));
String channelSlug = channelSlugFor(body); String channelSlug = channelSlugFor(body);
if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c", channelSlug)); if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c_test5590", channelSlug));
byte[] signingMessage = Ans104DataItem.buildSigningMessage(owner32, tags, frame); byte[] signingMessage = Ans104DataItem.buildSigningMessage(owner32, tags, frame);
byte[] signature64 = utils.crypto.Ed25519Util.sign(signingMessage, loginPrivKey); byte[] signature64 = utils.crypto.Ed25519Util.sign(signingMessage, loginPrivKey);
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.12.21 client.version=1.13.1
server.version=1.10.8 server.version=1.11.0
+18
View File
@@ -43,6 +43,24 @@
- `scripts/deploy_server.sh` — обновить существующий серверный jar и перезапустить systemd service. - `scripts/deploy_server.sh` — обновить существующий серверный jar и перезапустить systemd service.
- `scripts/deploy_ui.sh` — обновить существующий UI, проверить Caddy root и подставить `deploy-config.js`. - `scripts/deploy_ui.sh` — обновить существующий UI, проверить Caddy root и подставить `deploy-config.js`.
## Временные статические сайты
Для дизайн-примеров, standalone-viewer'ов и временных тестовых страниц используется папка:
```text
shine-UI/static-sites/
```
Она деплоится обычным UI deploy вместе со всем `shine-UI`. Если Caddy root указывает на UI-каталог и используется стандартный fallback `try_files {path} /index.html`, отдельный Caddy-route для каждой новой подпапки не нужен.
Примеры URL после deploy:
```text
https://<host>/static-sites/
https://<host>/static-sites/design-examples/channels-v1/
https://<host>/static-sites/arweave-viewer/
```
Production wrappers: Production wrappers:
- `scripts/production_shineupme_server.sh` - `scripts/production_shineupme_server.sh`
+2 -2
View File
@@ -82,7 +82,7 @@ App = test5590
Если блок относится к конкретному каналу, он дополнительно содержит: Если блок относится к конкретному каналу, он дополнительно содержит:
```text ```text
c = <canonical_channel_slug> c_test5590 = <canonical_channel_slug>
``` ```
Slug входит в подпись DataItem и не может быть изменён сервером после подписи. Slug входит в подпись DataItem и не может быть изменён сервером после подписи.
@@ -107,7 +107,7 @@ Slug входит в подпись DataItem и не может быть изм
1. распарсить полный ANS-104 DataItem; 1. распарсить полный ANS-104 DataItem;
2. проверить `App=test5590`; 2. проверить `App=test5590`;
3. проверить `c`, если тип блока требует канал; 3. проверить `c_test5590`, если тип блока требует канал;
4. проверить ANS-104 Ed25519 подпись; 4. проверить ANS-104 Ed25519 подпись;
5. проверить, что `owner` равен текущему blockchain public key пользователя; 5. проверить, что `owner` равен текущему blockchain public key пользователя;
6. распарсить Frame v1 и body; 6. распарсить Frame v1 и body;
+103 -44
View File
@@ -2,96 +2,155 @@
## Цель ## Цель
Каждый пользовательский блок уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер не переподписывает пользовательский контент: он проверяет его, хранит в PostgreSQL и объединяет готовые DataItems в стандартный ANS-104 bundle. Каждый пользовательский блок SHiNE уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер проверяет и хранит **точно эти signed bytes** и может публиковать их одним из двух транспортов: через Turbo по одному DataItem либо через прямую Arweave L1-транзакцию в составе стандартного большого ANS-104 bundle.
## Child DataItem tags Способ публикации — локальная политика конкретного сервера. Формат пользовательского блока и импорт от него не зависят.
Обязательно для тестового контура: ## User DataItem tags
Для тестового контура обязательно:
```text ```text
App=test5590 App=test5590
``` ```
Дополнительно для блоков конкретного канала: Для блоков конкретного канала дополнительно:
```text ```text
c=<canonical_channel_slug> c_test5590=<canonical_channel_slug>
``` ```
Теги входят в ANS-104 подпись пользователя. Теги входят в ANS-104 подпись пользователя. Старый тестовый тег `c` новым кодом не создаётся и не принимается как channel tag.
## Publisher ## Publisher modes
По умолчанию цикл — раз в 15 минут. Настройка:
```text
arweave.blocks.publish.mode=turbo | arweave | none
```
### `turbo`
```text ```text
blocks.arweave_publish_pending=true blocks.arweave_publish_pending=true
↓ ↓
готовые serialized DataItems готовый signed user DataItem из blocks.block_bytes
↓ ↓
ANS-104 binary bundle POST в Turbo как application/octet-stream
↓ ↓
обычная Arweave L1 transaction Turbo bundling / Arweave
``` ```
Если pending-блоков нет, транзакция не создаётся. DataItem **не переподписывается** сервером. Его `data_item_id = SHA-256(user signature)` до и после загрузки должен оставаться тем же.
Root transaction содержит стандартные bundle tags: Для Turbo можно задать публичный payer address напрямую:
```text
arweave.blocks.publish.turbo.paidByAddress=...
```
либо путь к серверному Arweave JWK:
```text
arweave.blocks.publish.turbo.walletJwkPath=/path/to/server-turbo-wallet.json
```
Из JWK локально вычисляется только публичный Arweave address для `x-paid-by`; приватный ключ Turbo upload endpoint не получает.
Важно: если upload уже требует оплаты, а signed DataItem принадлежит другому signer, Turbo Credits серверного кошелька используются через Credit Share Approval в пользу signer-адреса. Для маленьких DataItem, попадающих под действующий free tier Turbo, payer может не понадобиться. Код не должен рассчитывать на вечное существование free tier: HTTP `402` считается ошибкой оплаты и блок остаётся pending.
### `arweave`
Сохраняется прежний fallback:
```text
pending user DataItems
↓
standard ANS-104 binary bundle
↓
server Arweave RSA/JWK signature
↓
Arweave L1
```
Root transaction содержит только стандартные bundle tags:
```text ```text
Bundle-Format=binary Bundle-Format=binary
Bundle-Version=2.0.0 Bundle-Version=2.0.0
Content-Type=application/octet-stream Content-Type=application/octet-stream
App=test5590-batch
``` ```
`App=test5590-batch` намеренно отличается от child `App=test5590`, чтобы discovery-запрос находил пользовательские блоки, а не root bundles. Специальный `App=test5590-batch` больше не используется. Важны вложенные user DataItems, у которых уже есть `App=test5590`.
После успешной L1-загрузки сервер ставит child-блокам: ### `none`
Сервер принимает и хранит блоки локально, но publisher не отправляет их в Arweave/Turbo. Importer при этом может работать независимо.
## Состояние публикации в БД
После успешной публикации:
- `arweave_publish_pending=false`; - `arweave_publish_pending=false`;
- `arweave_published_at_ms`; - заполняется `arweave_published_at_ms`.
- `arweave_root_tx_id`.
## Importer `arweave_root_tx_id` больше не хранится: один и тот же пользовательский DataItem может быть физически упакован разными bundler-ами, а стабильным сетевым идентификатором SHiNE является именно `data_item_id`.
Каждый сервер может независимо искать: ## Importer: только individual DataItems
Importer всегда выполняет один discovery-запрос:
```text ```text
App=test5590 App=test5590
``` ```
через GraphQL gateway с cursor pagination. Он **не ищет root bundles** и не зависит от `publish.mode`.
Для каждого нового DataItem: Это одинаково работает для:
1. взять `id` и `bundledIn.id`; - DataItem, отправленного через Turbo;
2. получить root bundle; - DataItem, находящегося внутри большого direct-Arweave ANS-104 bundle сервера.
3. извлечь точные serialized bytes child DataItem по bundle index;
4. проверить `dataItemId == SHA256(signature)`;
5. проверить ANS-104 Ed25519 подпись;
6. определить пользователя по `owner`;
7. применить обычные проверки `AddBlock`;
8. записать в PostgreSQL с `arweave_publish_pending=false`.
### Блоки могут прийти не по порядку После того как AR.IO gateway распаковал/indexed bundle, child DataItem присутствует в GraphQL как отдельная сущность со своим `id` и собственными tags.
Discovery/import использует persistent queue `arweave_block_import_queue`. Если, например, block 102 увиден раньше block 101, block 102 остаётся `PENDING`; после появления 101 очередь повторно проигрывается. ### Получение полного signed DataItem
## Дедупликация и несколько серверов Обычная выдача DataItem по gateway URL может представлять только payload, а SHiNE для криптографической проверки нужны полные serialized ANS-104 bytes.
Один и тот же готовый DataItem имеет один `data_item_id = SHA256(signature)`. Если несколько серверов включили его в разные root bundles, локально это всё равно один логический блок: `blocks.data_item_id` уникален. Поэтому importer:
Импортированный из Arweave блок **не ставится обратно в publish queue**. Это предотвращает бесконечное переархивирование между серверами. 1. получает `data_item_id` через GraphQL `App=test5590`;
2. запрашивает `GET /ar-io/offsets/{data_item_id}`;
3. получает `rootTxId`, `rootOffset`, `size`;
4. делает range-read `GET /raw/{rootTxId}` ровно по этому диапазону;
5. разбирает полученные bytes как `Ans104DataItem`;
6. проверяет, что `SHA-256(signature) == data_item_id`;
7. проверяет Ed25519 ANS-104 signature;
8. определяет пользователя по `owner`;
9. импортирует через обычную логику `AddBlock` без повторной публикации.
Если GraphQL уже увидел DataItem, но gateway ещё не подготовил offsets, checkpoint не продвигается за этот height и DataItem будет повторён в следующем цикле.
## Очередь и порядок блоков
`arweave_block_import_queue` хранит:
- `data_item_id`;
- `block_height`;
- полный `raw_data_item`;
- status/error/timestamps.
`root_tx_id` очереди больше не нужен.
Если block N+1 увиден раньше N, он остаётся `PENDING`; после появления предыдущего блока очередь повторно проигрывается.
## Дедупликация
`blocks.data_item_id` уникален. Один signed DataItem остаётся одним логическим SHiNE-блоком независимо от того, сколько серверов или bundler-ов физически включили его в Arweave.
Импортированный блок записывается через `AddBlock` с отключённой повторной публикацией, поэтому серверы не создают цикл переархивирования.
## Локальное хранение ## Локальное хранение
Пользовательские blockchain-файлы на диске больше не используются. Полный serialized DataItem находится в `blocks.block_bytes` PostgreSQL. Полный serialized signed DataItem хранится в `blocks.block_bytes` PostgreSQL. Пользовательские `.bch`-файлы не являются источником истины.
## Настройки
См. `application.properties` и `CODEX_APPLY_ANS104_TEST5590_PATCH.md`.
## Что намеренно не входит в этот патч
Remote/homeserver signing path, связанный с внешним homeserver/ESP32 signer, не мигрируется этим патчем. Каталог `ESP32/` не изменяется. До отдельной миграции новый Frame v1/ANS-104 production path рассчитан на клиент, у которого локально доступен blockchain Ed25519 key.
+15
View File
@@ -1,5 +1,20 @@
# История изменений документации блокчейна # История изменений документации блокчейна
## 2026-09-23 — Turbo transport для individual ANS-104 DataItems
- Базовый коммит-ориентир: `3483a0a`; изменения подготовлены как patch без нового git-коммита.
- Publisher получил режимы `turbo | arweave | none`: Turbo отправляет каждый исходный user-signed DataItem отдельно, direct Arweave fallback сохраняет standard ANS-104 bundle, `none` отключает внешнюю публикацию.
- Удалён технический namespace `App=test5590-batch`: importer всегда ищет только individual `App=test5590` DataItems независимо от способа их физической упаковки.
- Channel tag тестового контура изменён с `c` на `c_test5590`; новый тег является частью пользовательской ANS-104 подписи.
- Importer получает точные serialized signed DataItem bytes через AR.IO offsets + range-read root transaction и проверяет `data_item_id`/Ed25519 signature перед `AddBlock`.
- Из PostgreSQL удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`; добавлена migration v25.
## 2026-09-23 — Тестовые каналы и Arweave-only синхронизация
- Базовый коммит-ориентир: `3483a0a`.
- Добавлены тестовые каналы и publisher для генерации пользовательских POST-блоков через обычный `AddBlock`.
- Добавлена возможность отключать прямую межсерверную синхронизацию блоков настройкой `blockchain.sync.enabled=false`.
- Arweave-импорт переведён на root ANS-104 bundle discovery: сервер находит batch-транзакции `App=test5590-batch`, разбирает вложенные signed DataItem и импортирует SHiNE-блоки с дедупликацией по `data_item_id`.
- Проверен тестовый сценарий Arweave-only: t3 поднял блоки из Arweave без прямой межсерверной синхронизации с t2.
## 2026-08-25 19:45:18 +0400 ## 2026-08-25 19:45:18 +0400
- Базовый коммит-ориентир: `3a58519`. - Базовый коммит-ориентир: `3a58519`.
- Добавлены runtime-агрегаты статистики: - Добавлены runtime-агрегаты статистики:
@@ -1,108 +1,79 @@
# Инструкция Codex: применить ANS-104 test5590 patch # Применение patch: Turbo + direct Arweave для `App=test5590`
## Цель ## Что меняется
Перевести пользовательский blockchain SHiNE на Frame v1 внутри готовых ANS-104 DataItems и убрать старый SHINE-ARCHIVE/файловое хранение цепочек. - `arweave.blocks.publish.mode=turbo|arweave|none` вместо boolean publisher switch.
- `turbo`: каждый готовый user-signed ANS-104 DataItem отправляется в Turbo отдельно.
- `arweave`: сохранён прямой L1 fallback — несколько user DataItems собираются в standard ANS-104 bundle.
- `none`: наружу ничего не публикуется.
- Importer всегда ищет только individual `App=test5590` DataItems.
- `App=test5590-batch` больше не используется.
- Channel tag: `c_test5590=<canonical_channel_slug>` вместо `c=...`.
- Удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`.
- Схема PostgreSQL: v25.
## Жёсткое ограничение ## Минимальная настройка Turbo
**Не изменять ничего в `ESP32/`.** В этом patch нет ни одного файла `ESP32/**`.
Remote/homeserver signer, завязанный на устройство, намеренно не мигрирован. Не пытаться «заодно исправить» его в рамках этого patch.
## Применение
1. Распаковать patch поверх корня репозитория, сохраняя относительные пути.
2. Удалить все пути из корневого `DELETE_FILES.txt`.
3. Проверить, что `git diff -- ESP32` пуст.
4. Использовать чистую/dev test DB. `migration_v24.sql` намеренно откажется мигрировать непустую blockchain DB, потому что backward compatibility со старым block format не требуется.
## Arweave config
Минимально для публикации:
```properties ```properties
arweave.blocks.publish.enabled=true arweave.blocks.publish.mode=turbo
arweave.blocks.publish.intervalMinutes=15 arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx
arweave.blocks.publish.gateway=https://arweave.net
arweave.blocks.publish.walletJwkPath=/ABSOLUTE/SECRET/PATH/arweave-wallet.json
``` ```
JWK не коммитить. Для действующего free tier маленьких DataItem этого может быть достаточно.
Для discovery/import: Если upload платный и расходы должны идти с server Turbo Credits:
```properties
arweave.blocks.publish.turbo.walletJwkPath=/home/player/SHiNE/secrets/turbo-wallet.json
# либо вместо JWK сразу публичный адрес:
# arweave.blocks.publish.turbo.paidByAddress=<server payer address>
```
JWK не отправляется Turbo: из него вычисляется публичный address для `x-paid-by`.
Для чужого signed DataItem платные Turbo Credits требуют действующего Credit Share Approval от server payer к signer-адресу DataItem. Если его нет, Turbo вернёт HTTP 402, а блок останется pending для повторной попытки.
## Direct Arweave fallback
```properties
arweave.blocks.publish.mode=arweave
arweave.blocks.publish.walletJwkPath=/home/player/SHiNE/secrets/arweave-wallet.json
arweave.blocks.publish.gateway=https://arweave.net
```
Root bundle больше не получает `App=test5590-batch`; child DataItems уже содержат `App=test5590` и именно их индексирует importer.
## Отключение публикации
```properties
arweave.blocks.publish.mode=none
```
Это не отключает `arweave.blocks.sync.enabled`: read/import и publish независимы.
## Importer
```properties ```properties
arweave.blocks.sync.enabled=true arweave.blocks.sync.enabled=true
arweave.blocks.sync.intervalMinutes=15
arweave.blocks.sync.gateway=https://turbo-gateway.com arweave.blocks.sync.gateway=https://turbo-gateway.com
arweave.blocks.sync.startBlockHeight=0 arweave.blocks.sync.maxDataItemBytes=8388608
``` ```
На тестах желательно установить `startBlockHeight` на высоту начала `test5590`, чтобы не сканировать лишнюю историю. Importer:
## Test namespace 1. GraphQL `App=test5590`;
2. `/ar-io/offsets/<dataItemId>`;
3. range `GET /raw/<rootTxId>`;
4. проверка exact signed DataItem ID + signature;
5. обычный `AddBlock` import.
Child DataItem: ## Миграция БД
```text При старте schema v24 автоматически применит `migration_v25.sql`, которая удаляет два root-tx поля и ставит version 25.
App=test5590
```
Channel child: ## Проверка после применения
```text 1. Создать новый channel/post и проверить signed tag `c_test5590=<canonical slug>`.
App=test5590 2. В `mode=turbo` убедиться, что `blocks.data_item_id` совпадает с Turbo response `id` и pending становится false.
c=<canonical_channel_slug> 3. На втором сервере включить sync и убедиться, что DataItem находится GraphQL-запросом `App=test5590` и импортируется без прямой server-to-server связи.
``` 4. Переключить первый сервер в `mode=arweave`, создать ещё несколько блоков и убедиться, что тот же importer второго сервера видит child DataItems без знания root bundle ID.
5. Проверить `mode=none`: новые локальные блоки остаются pending, наружу ничего не отправляется.
Root bundle:
```text
Bundle-Format=binary
Bundle-Version=2.0.0
App=test5590-batch
```
Перед production-start test namespace должен быть заменён отдельным осознанным изменением.
## Проверки после применения
Из корня репозитория:
```bash
node --check shine-UI/js/services/ans104-data-item.js
node --check shine-UI/js/services/auth-service.js
node --check shine-UI/js/app.js
node --check shine-UI/js/pages/settings-view.js
```
Java/Gradle:
```bash
./gradlew testClasses
./gradlew test
```
Затем локальный smoke test по штатной инструкции проекта, например `./gradlew startLocal`.
В среде, где готовился patch, Gradle wrapper не смог скачать Gradle 8.14 из-за отсутствия внешнего сетевого доступа к `services.gradle.org`. Поэтому полный Gradle compile/test обязательно прогнать после применения в обычной dev-среде.
## Smoke scenario
1. Создать/использовать тестового пользователя с локальным blockchain Ed25519 key.
2. Добавить обычный block и убедиться, что `blocks.block_bytes` начинается с ANS-104 DataItem, а `data_item_id` заполнен.
3. Создать channel и post; проверить `c=<canonical slug>`.
4. Включить publisher, дождаться цикла или вызвать сервис тестом; проверить root Arweave tx.
5. На второй чистой test DB включить importer и убедиться, что `App=test5590` blocks восстанавливаются в правильном порядке.
6. Убедиться, что imported blocks имеют `arweave_publish_pending=false`.
7. Проверить, что повторный discovery не создаёт дублей.
## Не делать в этом patch
- не добавлять backward compatibility Frame v0;
- не возвращать `.bch` storage;
- не возвращать SHINE-ARCHIVE;
- не менять ESP32;
- не мигрировать remote/homeserver signing без отдельного решения пользователя;
- не заменять `prevHash` на Arweave DataItem ID.
+4 -3
View File
@@ -14,7 +14,7 @@
```text ```text
User User
-> создаёт Frame v1 -> создаёт Frame v1
-> tags: App=test5590, при канале c=<slug> -> tags: App=test5590, при канале c_test5590=<slug>
-> Ed25519 подписывает ANS-104 deep-hash -> Ed25519 подписывает ANS-104 deep-hash
-> готовый DataItem -> готовый DataItem
-> AddBlock -> AddBlock
@@ -22,8 +22,9 @@ User
Server Server
-> verify DataItem + SHiNE chain -> verify DataItem + SHiNE chain
-> PostgreSQL -> PostgreSQL
-> каждые ~15 минут ANS-104 bundle -> publish.mode=turbo: каждый signed DataItem через Turbo
-> Arweave L1 ИЛИ publish.mode=arweave: большой standard ANS-104 bundle -> Arweave L1
ИЛИ publish.mode=none: наружу не публиковать
Other servers Other servers
-> GraphQL App=test5590 -> GraphQL App=test5590
+14
View File
@@ -22,6 +22,20 @@ PostgreSQL является единственным локальным хран
4. последовательно проигрывает удалённую цепочку с блока 0; 4. последовательно проигрывает удалённую цепочку с блока 0;
5. никаких файловых swap/recovery операций нет. 5. никаких файловых swap/recovery операций нет.
Этот прямой sync включается параметром:
```properties
blockchain.sync.enabled=true
```
Для проверки режима без прямых server-to-server связей его можно выключить в override-конфиге сервера:
```properties
blockchain.sync.enabled=false
```
При выключенном прямом sync сервер по-прежнему может синхронизировать пользователей из Solana и импортировать пользовательские блоки через Arweave, если включён `arweave.blocks.sync.enabled`.
## Arweave sync ## Arweave sync
Дополнительно каждый сервер может независимо включить `ArweaveBlockSyncScheduler`. Дополнительно каждый сервер может независимо включить `ArweaveBlockSyncScheduler`.
+290
View File
@@ -0,0 +1,290 @@
# SHiNE — дизайн приложения
Версия 0.2 · 22 сентября 2026.
Статус: первый перенос списка каналов, ленты, ветки и общего редактора выполнен в рабочем дереве. Состав изменений, автоматические проверки и оставшаяся визуальная приёмка описаны в [IMPLEMENTATION.md](IMPLEMENTATION.md). Точные размеры и поведение на устройствах остаются целями приёмки, а не утверждением о выполненном тестировании на телефонах.
Этот файл — актуальный источник решений по дизайну. Галерея `channels-v1` остаётся историей четырёх исходных вариантов: она ещё не показывает итоговое сочетание двух тем, компактной шапки и полноэкранного ответа. При расхождении руководствоваться этим документом.
## 1. Задача и границы
Сделать общение понятным с первого открытия. Начинаем со списка каналов, ленты канала, ветки обсуждения и мобильного редактора ответа. Затем распространяем выбранную систему на личные сообщения, связи, уведомления, профиль, поиск, создание каналов и настройки.
Основа — предоставленные пользователем скриншоты Threads и SHiNE. Заимствуем знакомые расположения и сценарии, сохраняя особенности продукта. Этот документ не утверждает новые возможности API и не меняет правила записи, реакции или блокчейна.
Исходные направления представлены в [HTML-галерее](channels-v1/index.html). Выбрана одна система интерфейса с двумя темами: композиция Ночного SHiNE, ночная палитра из него же, дневная палитра из Тёплого SHiNE. Шапка канала компактная по образцу Компактного клуба; редактор ответа полноэкранный, как у Компактного клуба / Чистого монохрома.
Тема меняет цвета, но не геометрию, навигацию, типографику или порядок действий. Из Тёплого SHiNE берём палитру, а не карточную компоновку, увеличенные заголовки Georgia и редактор-лист.
## 2. Принципы для всего приложения
1. Содержимое важнее оформления: текст без свечения, минимум декоративных рамок, спокойные поверхности.
2. Один акцентный цвет обозначает активный раздел, главное действие и выбранное состояние.
3. Главное действие доступно без прокрутки до конца. Его содержание зависит от роли и текущего экрана.
4. Всегда видно, где пользователь находится и кому отвечает. Возврат сохраняет позицию списка или ленты.
5. Редкие и технические действия помещаются в меню, но остаются доступными.
6. Пустота, загрузка и ошибка — полноценные состояния. Ошибка не уничтожает введённый текст.
7. Метки, иконки и расположение действий повторяются во всех разделах.
8. Не выдавать отсутствующую функцию за существующую. В частности, не добавлять репост, GIF-поиск или сортировку по популярности только потому, что они есть в референсе.
## 3. Принятые решения
| Решение пользователя | Правило для реализации |
| --- | --- |
| Основа — Ночной SHiNE | Плоская широкая лента, сдержанные поверхности, единые линейные иконки |
| Дневной и ночной режим сразу | Оба режима обязательны для каждого переносимого компонента и состояния |
| Дневные цвета — из Тёплого | Кремовые поверхности, тёмный тёплый текст, янтарно-коричневый акцент |
| Шапка сообщений — компактнее, как в Клубе | Панель 56 px, без большого описания и декоративного баннера перед лентой |
| Ответ — как у Клуба / Монохрома | Полноэкранный мобильный редактор, короткий контекст, основное место отдано вводу |
| Лайки — общее количество | Один счётчик. Типы лайков и их статистика не входят в первый перенос |
| Статистика позднее, возможно долгим тапом | Возможное будущее взаимодействие, не обязательство реализовать жест сейчас |
| Оглавление используется редко | Только в меню канала, без отдельной кнопки в шапке или под описанием |
| Плотность мокапов подходит | Сохраняем ритм Ночного SHiNE; уплотняем шапку, не всю ленту до варианта Клуба |
Эти решения приняты 22.09.2026 в обсуждении с пользователем. Повторно выбирать направление не требуется.
## 4. Базовые параметры
Ниже единые размеры для обеих тем. Референс плотности — Ночной SHiNE. Не переносить мелкие служебные подписи галереи буквально: текст остаётся читаемым на телефоне. Размеры задавать через общие токены с поддержкой системного увеличения шрифта; фиксированные высоты ниже являются минимальными, если тексту требуется больше места.
| Параметр | Целевое значение |
| --- | --- |
| Основной текст публикации / ответа | 15 px / 23 px; редактор ввода 16 px / 24 px |
| Имя автора | 14 px / 20 px, вес 600 |
| Вторичная информация | 12 px / 18 px |
| Заголовок корневого экрана | 24 px / 30 px, вес 600 |
| Название канала в компактной панели | 17 px / 22 px, вес 600 |
| Заголовок обычной модальной панели / редактора | 16 px / 24 px, вес 600 |
| Шкала отступов | 4 / 8 / 12 / 16 / 24 / 32 px |
| Горизонтальные поля телефона | 16 px; между аватаром и текстом 10–12 px |
| Вертикальный ритм | Публикация: 16 px сверху, 8 px снизу; абзацы: 8–12 px; строка канала: 14 px сверху и снизу |
| Аватар | 36 px в ленте; 48 px в списке; 32 px у ответа; 40 px в редакторе |
| Область нажатия | Не менее 44 × 44 px для всех действий, включая меню, закрытие, лайки и вкладки |
| Иконки | Единая линейная система, 20–24 px, одинаковая толщина штриха |
| Скругления | Поле/файл/кнопка 12 px; медиа 14 px; меню-лист 20 px; полноэкранный ответ без скругления внешнего края |
| Контраст текста | Цель WCAG AA; проверить утверждённую палитру |
| Анимация | 150–200 мс, отключение при reduced motion |
### 4.1. Семантические цвета двух тем
Использовать роли, а не прямые цвета внутри страниц. Имена ниже описывают контракт дизайна; при реализации сопоставить их существующей системе переменных, не создавать параллельную систему токенов. Основная палитра взята из выбранных мокапов; статусные цвета — дополнение спецификации.
| Роль | Ночь | День | Где используется |
| --- | --- | --- | --- |
| `background` | `#101B20` | `#FAF7F0` | Основной фон, шапка и навигация |
| `surface` | `#17272D` | `#FFFDF8` | Поле поиска, меню, карточка файла |
| `surface-selected` | `#213B3A` | `#EFE6D6` | Выбранная вкладка, лёгкое выделение |
| `text-primary` | `#E8F2F1` | `#302C25` | Основной текст |
| `text-secondary` | `#98ADAF` | `#706658` | Время, авторские идентификаторы, подсказки |
| `border-subtle` | `#2A3B40` | `#E6DFD1` | Декоративное разделение строк |
| `border-control` | `#71888A` | `#978B79` | Граница поля, если нужна для распознавания контроля |
| `accent` | `#94E1CE` | `#93511E` | Главное действие, ссылки, активная навигация |
| `on-accent` | `#102C27` | `#FFFAF3` | Текст на залитой акцентом кнопке |
| `focus-ring` | `#94E1CE` | `#93511E` | Контур клавиатурного фокуса |
| `reaction-active` | `#F29AAB` | `#A43350` | Сердце выбранного лайка, не системная ошибка |
| `danger` | `#FFABA8` | `#B13135` | Ошибка, подтверждение удаления |
| `success` | `#94E1CE` | `#346A48` | Успешное действие, с текстовым пояснением |
| `warning` | `#E6C184` | `#845B14` | Предупреждение, с текстовым пояснением |
| `scrim` | `#00000099` | `#302C2566` | Затемнение под меню/диалогом |
Шрифт системный sans-serif в обеих темах: `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`. Georgia из тёплого концепта не переносить. Не использовать свечение текста и цветные тени кнопок. Допустима лёгкая тень у всплывающего меню, но не у каждой публикации.
Небольшая коррекция исходной дневной палитры: вторичный текст затемнён с `#7B7264` до `#706658`. Расчёт контраста исходного цвета на основном фоне дал 4.43:1, на выбранной поверхности — 3.83:1. Новый цвет даёт соответственно 5.26:1 и 4.55:1, сохраняя тёплый характер. Это уточнение для читаемости, а не смена выбранной палитры.
`border-subtle` не служит единственным указателем поля или выбранного состояния. Контраст проверять для конкретных пар: обычный текст ≥ 4.5:1; значимые иконки/границы и фокус ≥ 3:1. Не снижать контраст вторичного текста общей прозрачностью контейнера. Disabled обозначать одновременно неактивностью и стилем; ожидаемое действие пояснять при необходимости.
### 4.2. Выбор темы
Рабочее решение для реализации: настройка «Оформление» с пунктами «Как на устройстве», «Дневное», «Ночное». По умолчанию — как на устройстве. Явный выбор сохраняется локально и имеет приоритет; изменение системной темы применяется только в режиме «Как на устройстве». Это проектное допущение, пользователь отдельно не выбирал способ переключения.
Обновление темы не перезагружает экран, не закрывает редактор и не сбрасывает черновик или прокрутку. Выбранный режим применяется до первой видимой отрисовки, без вспышки противоположного фона. Все модальные окна, меню, системные поля и состояния используют ту же тему. Фотографии и аватары не инвертируются. Не нужна постоянная кнопка солнца/луны в тесной шапке канала.
## 5. Список каналов
Верх: заголовок «Каналы», видимая кнопка создания, поиск. Фильтры «Все», «Подписки», «Мои» вынесены из выпадающего заголовка на поверхность.
Строка: аватар, название, автор/идентификатор при необходимости, однострочное превью, время, число непрочитанных. Длинное название сокращается в строке, полное доступно внутри канала. На карточке не нужно одновременно показывать описание, технический путь, общее число сообщений и длинное превью.
Поиск в прототипе фильтрует примеры по названию и автору. В текущем приложении серверный поиск начинается с логина/начала логина автора: полноценный глобальный поиск по названию здесь является предложением, а не обещанием текущего API. При реализации можно сохранить существующую семантику с подсказкой «Найти по @автору».
Нижняя навигация сохраняет пять существующих разделов: «Личные», «Каналы», «Связи», «Уведомления», «Профиль». Иконки приводятся к одной системе. Высота рабочей зоны 64 px плюс нижний safe area; иконка 22 px, подпись 11 px. Активный пункт отмечается акцентом и мягкой подложкой под иконкой. При крупном системном шрифте панель может увеличивать высоту; нельзя уменьшать шрифт, скрывать смысловые подписи или обрезать «Уведомления» ради фиксированной высоты.
Не выделять первую строку автоматически только потому, что она первая. Непрочитанное отмечается счётчиком и более заметным превью, выбранное — фактическим состоянием выбора. Просмотр канала и подписка — разные действия; открытие строки не подписывает пользователя автоматически.
## 6. Канал и сообщения
### 6.1. Компактная шапка
Верхняя панель высотой 56 px плюс верхний safe area: возврат 44 px, растягивающийся блок названия, уведомления 44 px, меню 44 px. Внутри блока — название 17/22 px и вторичная строка `@автор` 12/16 px. Название в одну строку с многоточием; полное название, идентификатор и описание доступны в «О канале». На очень узком экране и при крупном шрифте уведомления переносятся в меню, чтобы сохранить место названию. Название канала открывает информацию о канале.
Под панелью сразу начинается лента. Убрать большой блок описания, декоративный градиентный баннер и постоянно видимый статус «Вы подписаны». Описание и управление подпиской — в меню/информации о канале. Для неподписанного читателя допустима компактная строка «Подписаться» над лентой; после подписки она исчезает. Метка «Новые сообщения» занимает одну строку и появляется только при наличии непрочитанного.
Порядок меню канала: «О канале», «Оглавление», настройка уведомлений, действие подписки/отписки, затем доступные владельцу действия. Пункты показываются по реальным правам. Оглавление не дублируется в шапке, под описанием или в строке действий публикации. При пустом оглавлении показать «В этом канале пока нет оглавления»; создание доступно владельцу только через существующий сценарий.
### 6.2. Публикация
Публикация: автор, время, меню, текст, вложение и действия. Порядок действий — лайк, открыть обсуждение, поделиться; явное «Ответить» открывает редактор. Это разделяет чтение ветки и написание ответа, сейчас они завязаны на разные области карточки.
У лайка сейчас только общий счётчик `likesCount`: не суммировать его повторно с подкатегориями. Один тап ставит/снимает лайк; выбранное состояние — заполненное сердце и `reaction-active`. Повторное действие во время запроса блокируется, при ошибке состояние и число восстанавливаются с коротким пояснением. Нулевой счётчик можно показывать как `0`, одинаково во всех разделах. У кнопки есть доступное название и состояние, например «Нравится, 24» и `aria-pressed`.
Статистику типов не показывать ни на панели, ни в меню первого переноса. Долгий тап по лайкам — отложенная идея пользователя; порог жеста, содержимое и доступную альтернативу согласовать позднее. Если жест появится, ему понадобится явный пункт меню для клавиатуры и пользователей, которые не знают о долгом тапе. Сейчас не добавлять неработающую подсказку или заглушку для этого жеста.
«Данные блокчейна», доступная история, редактирование и удаление помещаются в меню. Редактирование и удаление доступны только для собственных сообщений; удаление требует понятного подтверждения с описанием реального эффекта существующего протокола. Отдельные оценки не возвращаются на панель действий: в коде они намеренно скрыты.
Кнопка публикации закреплена снизу у владельца. У читателя в ленте нет поля, которое обещало бы писать непосредственно в чужой канал. Ответ доступен под публикацией, а на экране ветки — также в нижнем поле. Для неподписанного пользователя нужна заметная подписка, для гостя — корректное приглашение войти перед действием.
Вложения: компактная карточка файла с названием, типом и размером; изображения — в ширину текста с ограничением высоты. У галереи нужен индикатор количества. Реальные превью, загрузка и ошибки файлов проектируются отдельно; изображение в мокапе — встроенная SVG-иллюстрация без внешних ресурсов.
## 7. Ветка обсуждения
Исходное сообщение остаётся наверху, ниже — счётчик и ответы в последовательном порядке. Не предлагать «Популярные» до подтверждения алгоритма и источника данных.
Для вложенных ответов показывать «В ответ …» и короткий контекст. Не уменьшать ширину текстовой колонки бесконечными отступами: после одного уровня показывать связь подписью или открывать подветку, используя имеющуюся маршрутизацию. Тонкая линия допустима только для обозначения реальной связи, не вокруг каждого комментария. Плотность ответов сохраняется близкой к Ночному SHiNE; выбор полноэкранного редактора из Клуба не означает переноса всей его плотности.
Возврат ведёт в тот же канал и на прежнюю позицию. После отправки новый ответ появляется в ветке; при ошибке остаются текст и вложения. Длинные ветки потребуют отдельной проверки пагинации, состояния загрузки и перехода к конкретному ответу.
## 8. Мобильный редактор ответа
Полноэкранный мобильный редактор в обеих темах — принятое решение. Он закрывает нижнюю навигацию и занимает доступную область приложения; не является маленькой центральной модалкой или частично открытым листом. Нет ручки перетаскивания, переключателя разворачивания и декоративного верхнего отступа.
Верхняя панель 56 px: закрытие 44 px слева, заголовок «Ответ» по центру; место справа не заполнять лишними действиями. Ниже — короткий контекст исходного сообщения, собственный аватар, поле и вложения. Нижняя панель: «Прикрепить», счётчик и кнопка «Ответить». Для нового сообщения тот же редактор с заголовком «Новое сообщение» и кнопкой «Опубликовать», без цитаты.
При первом открытии показать адресата и цитату максимум в две строки плюс «Показать целиком» при обрезке. Не повторять весь исходный пост с изображением в редакторе. Для ответа на файл без текста показать название/тип вложения. Раскрытая цитата находится в прокручиваемой области и не вытесняет навсегда поле. Адресат берётся из выбранного сообщения, а не всегда из автора канала.
Поле ввода занимает всё оставшееся свободное место, растёт вместе с текстом в общей прокручиваемой области. Шапка и нижняя панель остаются видимыми. При открытии по нажатию фокус переходит в поле; курсор при восстановлении черновика остаётся в тексте. Системная клавиатура не должна перекрывать отправку: учитывать visual viewport, поворот устройства и безопасные отступы. Не рисовать свою клавиатуру в приложении — иллюстрация есть только в галерее.
Крестик, системный возврат и Escape закрывают редактор с одинаковой политикой черновика. Возврат восстанавливает место в обсуждении и фокус инициирующей кнопки. На desktop редактор может быть центрированной панелью шириной до 640 px и высотой до доступного viewport с теми же тремя зонами; на телефоне всегда полный экран.
Enter создаёт новую строку. Отправка — по явной кнопке; Ctrl/Cmd+Enter можно поддержать как дополнительное desktop-действие с подсказкой. Не отправлять во время IME/composition. В текущем редакторе есть обработчик plain Enter, поэтому это намеренное изменение поведения, которое нужно отдельно учитывать при переносе.
Состояния отправки:
- Пустой текст без вложений: кнопка неактивна.
- Есть текст или вложение: отправка доступна; лимит текста 2000 символов сохранён из текущего редактора ответа.
- Загрузка вложения: прогресс, возможность отмены, запрет отправлять незавершённое вложение.
- Отправка: индикатор, блокировка повторного нажатия.
- Ошибка: понятная причина, повтор, сохранённый текст.
- Успех: новый ответ и восстановленный контекст обсуждения.
- Закрытие: черновик остаётся; очистка по отдельному явному действию, не по Escape.
В исходной HTML-галерее воспроизведены пустое/заполненное поле, лимит, выбранное вложение, закрытие с черновиком и локальная успешная отправка. Загрузка/ошибка сети и прогресс — требования будущей реализации. Счётчик 2000 относится к ответу согласно просмотренному коду; лимиты публикаций и вложений брать из соответствующих существующих валидаторов, не вводить общий лимит для всех сообщений только по этому документу.
Черновик в приложении изолировать ключом «аккаунт + канал + идентификатор исходного сообщения + режим редактора». Никогда не переносить текст ответа на другой пост молча. В первом переносе достаточно сохранения при закрытии/возврате в рамках текущей сессии интерфейса. Переживание перезагрузки/закрытия приложения не обещать, пока не выбраны хранение и политика очистки. При выходе из аккаунта его черновики не показываются другому пользователю.
## 9. Что подтверждено кодом
| Возможность | Где прочитано | Решение макета |
| --- | --- | --- |
| Все / свои / подписки; поиск; создание | `shine-UI/js/pages/channels-list.js`, `render`, `openChannelFinderModal` | Показать поиск и фильтры явно |
| Непрочитанные, превью, автор и время | Там же, построение строк списка | Сохранить иерархию без перегрузки |
| Публикация владельцем | `shine-UI/js/pages/channel-view.js`, ветка `isOwnChannel` | Закреплённое действие только для роли владельца |
| Подписка и оглавление | `channel-view.js` | Сделать доступными в информации о канале |
| Лайки трёх категорий, ответ, отправка ссылки | `channel-view.js`, построение действий сообщения | Только общий счётчик; детализация отложена; ветка отдельно от ответа |
| Технические данные и редактирование | `channel-view.js` | Перенести в меню, учитывать авторство |
| Оценки временно скрыты | Комментарий возле действий сообщения в `channel-view.js` | Не возвращать без решения продукта |
| Ответы и вложения; 2000 символов | `openReplyModal` в `channel-view.js` и `channel-thread-view.js` | Мобильный редактор с контекстом |
Просмотр кода был целевым, не полным аудитом. Права всех типов каналов, статусы пользователей, истории, специальные подтипы и поведение гостя нужно проверить перед реализацией. Переключатель роли в галерее предназначен для оценки размещения действий, не моделирует авторизацию.
## 10. Состояния для следующей итерации
| Область | Нужные дополнительные макеты |
| --- | --- |
| Список | Пустые подписки; первый запуск; загрузка; ошибка; нет результатов |
| Канал | Гость; неподписанный; владелец; пустой канал; недоступный канал; очень длинное название |
| Сообщение | Длинный текст; несколько изображений; файл без текста; удалённое; изменённое; специальный подтип |
| Ветка | Нет ответов; много ответов; вложенная ветка; переход по ссылке |
| Ответ | Длинная цитата; ошибка отправки; загрузка файла; черновик; клавиатура; возврат фокуса |
| Доступность | Клавиатура; screen reader; крупный шрифт; контраст; reduced motion |
## 11. Общие компоненты для единообразия
| Компонент | Единое правило |
| --- | --- |
| TopBar | Общие высота, поля, возврат и меню. Большой заголовок только у корневого экрана, компактный у вложенного |
| Primary button | Заливка `accent`, текст `on-accent`, высота 44 px, радиус 12 px; одно главное действие в области |
| Secondary button | Поверхность/контур, без конкуренции с primary; тот же размер текста и зона нажатия |
| Icon button | Иконка 22 px внутри зоны 44 px, доступное название; одинаковые pressed/focus/disabled |
| Field | Метка по смыслу, текст 16 px, вспомогательная подпись и ошибка под полем; placeholder не заменяет метку |
| Search | Небольшое поле с лупой; очистка видна при непустом запросе; отдельно загрузка, отсутствие результатов и ошибка |
| Tabs | Одинаковый selected-стиль, минимум 44 px по высоте; не создавать страницу со своей системой вкладок |
| Avatar | Круглый, устойчивый размер; fallback с инициалами; выбранная тема не перекрашивает фотографию |
| List row | Аватар/иконка → основной и вторичный текст → время/счётчик; вся строка открывает объект |
| Overflow menu | Те же отступы и строки 44 px; на телефоне допустим общий лист, на desktop привязка к кнопке |
| Attachment | Единая карточка имени/типа/размера, отдельные состояния загрузки и ошибки; медиа не растягивает страницу по ширине |
| Empty state | Короткий заголовок, причина/следующий шаг, одно уместное действие; без большого декоративного баннера |
| Inline error | Возле неудавшегося действия, понятный текст и повтор; технические подробности раскрываются отдельно |
| Toast | Короткое подтверждение без перехвата фокуса; не единственный способ сообщить о блокирующей ошибке |
| Confirmation | Называет действие и объект; основной акцент для обычного действия, `danger` для удаления |
| Fullscreen editor | Один визуальный контракт публикации/ответа/редактирования с вариантами заголовка и контекста |
Каждый компонент определяет normal, hover для указателя, pressed, focus-visible, disabled, loading и error, если они применимы. Selected и pressed различать: selected сохраняется после нажатия, pressed — краткий отклик. Выбор и ошибку не кодировать только цветом.
Для клавиатуры соблюдать логичный порядок фокуса; у диалогов — семантика dialog, фокус внутри и возврат инициатору. Screen reader получает названия и состояния переключателей, общий счётчик лайков и информацию об отправке. Обновление ленты не должно каждый раз перехватывать фокус или зачитывать весь экран.
### 11.1. Общая адаптация
Телефоны 320–430 CSS px: одна колонка, боковые поля 16 px, никакого горизонтального скролла страницы. Горизонтальная прокрутка допустима только внутри явно обозначенной галереи вложений. Длинные слова/ссылки переносятся; имена и заголовки сокращаются только там, где предусмотрен доступ к полному тексту.
При ширине 600 px и выше текстовая колонка не шире 640 px, центрируется; верхняя панель, лента и composer выровнены по её краям. Desktop-навигация использует существующий shell. Новую многоколоночную архитектуру приложения этот документ не требует.
Safe area учитывается ровно один раз владельцем оболочки. При открытом редакторе нет двойной нижней панели. При увеличении текста до 200% допускается рост высот; кнопки не накладываются, действия не исчезают. Анимация открытия 150–200 мс; при reduced motion переход без движения.
### 11.2. Применение к остальным разделам
| Раздел | Что наследует | Что требует отдельного проектирования |
| --- | --- | --- |
| Личные сообщения | Палитры, текст, аватары, строки списка, вложения, поля и меню | Геометрия диалога, входящие/исходящие, статусы доставки/прочтения, звонки |
| Связи | Строки людей, профильные аватары, кнопки, пустые состояния | Запросы, типы отношений и правила доступности |
| Уведомления | Строки, типографика, счётчики, непрочитанное | Группировка, переход к объекту, состояния удалённого объекта |
| Профиль | TopBar, разделители, поля, кнопки и обе темы | Состав профиля, редактирование, настройки оформления |
| Вход/регистрация | Цвета, поля, ошибки и focus-стили | Чувствительные сценарии, последовательность шагов, тексты восстановления |
| Создание/настройка канала | Общие формы, редакторы, меню и подтверждения | Права, типы канала и специфические настройки |
Наследование визуальной системы не означает изменения протоколов или применения ленты каналов к личному диалогу. Каждый следующий раздел добавляет свои сценарии в отдельный документ со ссылкой на эту основу, а не копирует собственные палитры и наборы кнопок.
## 12. Допущения и отложенные решения
Блокирующих вопросов для документа нет. Рабочие решения: тема по системе с ручным выбором, размеры из раздела 4, сохранение черновика внутри текущей сессии, единый редактор с адаптацией для desktop.
До переноса проверить по коду матрицу прав всех типов каналов, доступность ответа для гостя/неподписанного, ограничения вложений и существующие переменные темы. Если для предложения нужны новые серверные данные, явно отметить зависимость и сохранить доступный сценарий; не расширять API автоматически.
Позднее: статистика типов лайков и долгий тап; глобальный поиск по названию; постоянные черновики; подробные сценарии остальных разделов. Эти пункты не являются задачами текущего переноса.
## 13. Перенос в приложение и критерии готовности
На следующем этапе использовать существующие AppShell/TopBar/Dropdown/Avatar/Composer. Не копировать автономный JS из мокапа в production: он работает на демонстрационных данных и не реализует lifecycle приложения.
Последовательность: сопоставить текущие токены и владельцев компонентов → подготовить обе палитры → реализовать общие варианты компонентов → перенести список/канал/ветку/ответ → закрыть состояния из раздела 10 → проверить устройства и темы → расширять на другие разделы по отдельным задачам. Перед реализацией удобно обновить автономный мокап выбранного сочетания; исходную галерею сохранять как историю выбора.
Матрица приёмки для первого переноса:
| Проверка | Ожидаемый результат |
| --- | --- |
| День ↔ ночь на каждом экране | Геометрия совпадает; нет чужих тёмных/светлых элементов и вспышки при старте |
| Переключение темы с открытым ответом | Текст, вложения, адресат, фокус и позиция не сбрасываются |
| Шапка канала | Компактная; нет баннера описания и кнопки оглавления вне меню |
| Роль читателя/владельца/гостя | Только доступные действия; нет ложного обещания писать в чужой канал |
| Лайки | Один общий счётчик; постановка/снятие; защита от повторного запроса; восстановление после ошибки |
| Оглавление | Доступно через меню, включая пустое состояние |
| Ответ на пост и ответ на комментарий | Верный адресат и цитата; полноэкранный редактор; отправка видна над клавиатурой |
| Длинный ответ и Enter | Перенос строки без отправки; поле и курсор доступны при прокрутке |
| Черновик | Закрытие/возврат сохраняет текст в текущей сессии; другой адресат не получает чужой текст |
| Вложения | Текст или вложение; прогресс/ошибка; отправка незавершённого файла недоступна |
| Навигация | Возврат сохраняет позицию; отправленный ответ виден; обновление не пересоздаёт страницу целиком |
| Загрузка/ошибка/пустота | Понятные отдельные состояния; введённые данные не теряются |
| 320 / 390 / 430 px, большой экран, 200% текст | Нет наложений и горизонтальной прокрутки страницы; все действия достижимы |
| Android / iOS, экранная клавиатура, поворот | Поле и отправка доступны; safe area не дублируется |
| Клавиатура / screen reader / reduced motion | Фокус видим и возвращается; семантика доступна; движение отключается |
Все четыре экрана проверяются в обеих темах, включая меню, редактор, ошибку и пустое состояние. Тесты поведения не заменяют визуальную проверку на телефоне.
Документ служит общим справочником приложения. Новые разделы описывают сценарии, владельцев компонентов, состояния и принятые решения, сохраняя принципы разделов 2, 4 и 11. Обнаруженные при реализации ограничения записывать как явные отклонения с причиной, а не незаметно создавать новый стиль страницы.
## 14. Журнал решений
| Дата | Версия | Решение |
| --- | --- | --- |
| 22.09.2026 | 0.1 | Четыре самостоятельных HTML-направления для обсуждения |
| 22.09.2026 | 0.2 | Пользователь выбрал Ночной SHiNE, дневную тёплую палитру, компактную шапку, полноэкранный ответ; общее число лайков, оглавление в меню; плотность принята |
| 22.09.2026 | 0.2 | Специфицированы семантические цвета двух тем, общие компоненты, состояния, адаптация, правила расширения и матрица приёмки. Это документация будущего переноса, не выполненное изменение приложения |
+46
View File
@@ -0,0 +1,46 @@
# Первый перенос дизайна каналов
22 сентября 2026. Изменения в рабочем дереве; без коммита и деплоя. Основа — [DESIGN.md](DESIGN.md), версия 0.2.
## Реализовано
- Ночная и дневная семантические палитры; системный режим по умолчанию, ручной выбор в настройках, применение до первой отрисовки. Смена темы не перемонтирует страницу.
- Список каналов: плоские строки, превью, время и непрочитанное; видимые фильтры «Все / Подписки / Мои», создание и поиск. Поле фильтрует загруженный список; «По @автору» открывает существующий серверный поиск.
- Лента: компактная шапка, оглавление в меню, плоские публикации, общий лайк без промежуточного диалога, отдельные обсуждение и ответ. Технические данные, история и собственные редактирование/удаление — в меню. Удаление подтверждается с предупреждением о сохранении истории в блокчейне.
- Ветка: единый вид публикаций и ответов, контекст вложенного ответа без растущих горизонтальных отступов. Порядок данных и существующие пределы загрузки не менялись.
- Общий редактор публикации/ответа/редактирования: полный мобильный экран, desktop-панель до 640 px, три зоны, VisualViewport и safe area. Контекст ответа, растущее поле, вложения, счётчик, отправка, ошибка и очистка черновика. Enter — новая строка, Ctrl/Cmd+Enter — отправка, IME защищён.
- Черновики в памяти текущей вкладки, изолированные по аккаунту, цели и режиму. Для ответа цель — полная ссылка на сообщение (blockchain/номер/hash); один и тот же ответ восстанавливается из ленты и ветки. Тип новой публикации сохраняется вместе с текстом. После перезагрузки черновики не обещаются.
- Закреплённое действие публикации у владельца; закреплённый ответ в ветке. Гостевые действия обозначают необходимость входа. Позиции чтения сохраняются внутри текущей вкладки отдельно по аккаунту и экрану.
- Общие TopBar, Dropdown, Tabs, Avatar и Toolbar приведены к выбранному направлению. Стили общей галереи вложений перенесены из `features/chat.css` к владельцу `components/attachments.css`; логика DM и формат вложений не менялись.
## Проверено автоматически
Проверка: `shine-UI/channel-design-check.mjs`. Она исполняет настоящий DOM producer страниц и компонентов в JSDOM; транспорт/API изолированы, тест ничего не публикует.
- Редактор: пустое состояние, Enter и Ctrl+Enter, IME, фокус/Tab/возврат инициатору, восстановление черновика, разделение аккаунтов, очистка после успеха, сохранение после ошибки, вложение, системный возврат и гостевой вход.
- Карточки канала/ветки: текст, общий лайк, контекст ответа, меню по авторству, повторный cleanup.
- Список/канал/ветка: успешная пустая загрузка, сохранение корневого элемента при refresh, идемпотентный cleanup, отсутствие DOM-изменений от позднего ответа API после dispose.
- Темы, сохранение позиции чтения, навигация Dropdown клавишами и возврат фокуса.
- `node --check` изменённых production JS, разрешение относительных импортов всех 135 JS, парсинг 17 изменённых CSS, отсутствие новых повторов селекторов внутри одного CSS-контекста и роста `!important`, корректность CSS manifest, `git diff --check`.
- Расчёт контраста токенов: основной текст/фон 15.34:1 ночью и 12.98:1 днём; вторичный текст/фон 7.45:1 и 5.26:1; вторичный текст/поверхность 6.55:1 и 5.54:1; текст основной кнопки/акцент 9.86:1 и 5.88:1. Это расчёт палитры, не полный аудит итогового CSS-cascade.
Воспроизводимый запуск из корня продукта (Node 24):
```bash
check_dir=$(mktemp -d)
npm install --prefix "$check_dir" --no-audit --no-fund --ignore-scripts jsdom postcss
SHINE_UI_TEST_DEPS="$check_dir/node_modules" node --experimental-vm-modules shine-UI/channel-design-check.mjs
git diff --check
```
Тестовые зависимости устанавливаются вне репозитория, в runtime приложения не добавляются.
## Границы и оставшаяся приёмка
API, формат блоков и операции записи не изменены. Категории лайков не выведены в интерфейс. Глобальный поиск по названию и фиктивный переключатель уведомлений канала не добавлены: рабочий серверный контракт для них не введён.
Используется существующий менеджер загрузок Arweave с его статусами/ошибками. Пока менеджер открыт, редактор блокирует отправку незавершённого вложения. Закрытие редактора закрывает менеджер, но не гарантирует отмену уже переданного транспортному слою upload-запроса; новая транспортная отмена и процент прогресса не заявляются.
Подключённого браузера в данной сессии нет: встроенный browser-инструмент вернул пустой список. Поэтому **визуальная приёмка не выполнена**. DOM-тесты не подтверждают реальные размеры, CSS-cascade, экранную клавиатуру, screen reader или сетевую публикацию. До выпуска необходимо пройти матрицу раздела 13 DESIGN.md: обе темы, 320/390/430 px и desktop, 200% текст, длинные сообщения/имена, вложения, Android/iOS, поворот, safe area, ошибки реального сервера и восстановление фокуса после фактического обновления ленты. Также проверить соседние экраны, использующие изменённые общие компоненты.
Локальный запуск приложения по правилам проекта: `./gradlew startLocal`. Production не затрагивался.
+41
View File
@@ -0,0 +1,41 @@
# Замечания к серверу и UI (найдено при редизайне)
Код по этим пунктам не менялся.
## Ошибки
1. **Лента «Подписки» пуста у логинов с заглавными буквами** (например `User_t2_03`).
`Net_ListSubscriptionsFeed_Handler.loadFollowedChannels` ищет `connections_state.login = ?` в исходном регистре,
а в `connections_state` логин хранится в нижнем регистре (FK на `solana_user_pda_current.normalized_login`).
Подписки в базе есть, но `followedChannels` пуст. Отсюда же «подписка не срабатывает» в UI:
кнопка не меняется на «Отписаться», канал не появляется в «Подписках».
2. **Превью в списке чатов — «Сообщение недоступно»**, хотя в самом чате сообщения расшифровываются.
`messages-list.js` строит превью по `dialog.lastMessageBlobB64`, `catch` глушит настоящую ошибку.
Проверить, не бывает ли так у реальных пользователей (например, если последнее сообщение отправлено с другого устройства).
3. **Прямой переход по адресу** `/settings`, `/profile`, `/channels/new` при загрузке страницы уводит на «Личные».
4. **`?localWsPort=` теряется при навигации**: после обновления страницы UI молча подключается к `shineup.me`.
## Безопасность / конфигурация
5. **Приватный ключ web-push в репозитории**: `SHiNE-server/src/main/resources/application.properties` → `webpush.vapid.private`.
Если он совпадает с продовым — перевыпустить и убрать из git.
6. По умолчанию сервер представляется логином прода `server.SHiNE.login=shineupme` — при запуске вне прода легко перепутать.
## На будущее
7. **Каждое действие перерисовывает страницу целиком** (лайк → шапка на мгновение «Канал / Загрузка…», лента перестраивается),
отсюда «дёрганье» при переходах назад. Нужны оптимистичные обновления (лайк/ответ меняют только свой элемент),
кэш загруженных лент между переходами, обновление без полной перерисовки.
8. **Кэш модулей после деплоя.** Страницы в `js/app.js` подключаются с метками `?v=…`, а `js/components/*` и `js/services/*` — без меток,
браузер может держать старую версию. Для изменённых страниц метки подняты до `?v=202609260900`;
для остальных модулей стоит проверить заголовки кэширования или ввести общую метку версии, как у CSS и `app.js`.
## Не сделано в редизайне
- Автопроверка серверов в настройках.
- Число подписчиков в шапке канала (нет в API).
+41
View File
@@ -0,0 +1,41 @@
# Дизайн SHiNE
Статус: направление выбрано, спецификация 0.2 от 22.09.2026. Основа — Ночной SHiNE; дневная тема — тёплая палитра; компактная шапка канала; полноэкранный ответ. У лайков только общий счётчик, оглавление в меню.
- [Общий дизайн-документ](DESIGN.md) — актуальная спецификация выбранного стиля, обе темы, общие компоненты, сценарии и критерии приёмки.
- [Открыть исходную HTML-галерею](channels-v1/index.html) — четыре варианта, четыре экрана в каждом; история выбора.
Галерея сохранена без изменений и ещё не отражает итоговое сочетание решений. Для последующего переноса источником истины служит `DESIGN.md`, а не детали одного старого варианта. Код приложения не менялся.
## Как посмотреть
Откройте `channels-v1/index.html` в обычном браузере. Сервер, сборка, вход в аккаунт и интернет не нужны. Стили и JavaScript лежат рядом, поэтому при передаче макетов сохраняйте всю папку `channels-v1`.
Вверху переключаются экраны всех вариантов одновременно. Стрелка возле названия открывает один вариант. На узком экране галерея выстраивается вертикально.
Отдельные варианты:
1. [Чистый монохром](channels-v1/index.html?variant=mono).
2. [Тёплый SHiNE](channels-v1/index.html?variant=warm).
3. [Ночной SHiNE](channels-v1/index.html?variant=night).
4. [Компактный клуб](channels-v1/index.html?variant=club).
## Что попробовать
1. Нажать на канал и открыть обсуждение через значок комментариев.
2. Ответить на исходное сообщение или конкретному собеседнику.
3. Набрать ответ, закрыть редактор, открыть его снова: черновик сохраняется в памяти страницы.
4. Прикрепить файл, удалить вложение, отправить текст или только вложение.
5. Включить «Макет клавиатуры», чтобы оценить оставшееся место. Это иллюстрация, настоящая клавиатура телефона работает отдельно.
6. Переключить роль на владельца: в канале появляется постоянная кнопка публикации.
7. Проверить поиск и фильтры списка; открыть оглавление и меню сообщения.
Все действия демонстрационные. Выбранный файл не читается и никуда не загружается: отображается только имя. После перезагрузки страницы демонстрационные сообщения, реакции и черновики исчезают. Уведомления, подписка и поиск не обращаются к API. Другие разделы нижней навигации показывают пояснение о границах макета.
## Границы
Макеты намеренно изолированы от `shine-UI`: они не импортируют приложение и не участвуют в его сборке. Здесь допускаются экспериментальные компоненты; при переносе утверждённого дизайна нужно использовать существующие владельцы AppShell, TopBar, Dropdown, Avatar и Composer.
## Проверка
Результаты проверки и ограничения приведены в [VALIDATION.md](VALIDATION.md). Визуальная проверка в браузере в текущей сессии недоступна: подключённых браузеров нет. Это рабочие HTML-макеты для обсуждения, не проверенная на устройствах реализация приложения.
+35
View File
@@ -0,0 +1,35 @@
# Проверка HTML-мокапов
Дата: 22.09.2026.
## Выполнено
- `node --check channels-v1/mockups.js` — синтаксис JavaScript корректен.
- CSS разобран PostCSS без ошибок синтаксиса.
- В изолированном jsdom проверены все четыре направления: начальная отрисовка, поиск и отсутствие результатов, фильтр «Мои», открытие канала, роли читателя/владельца, переключение лайка, открытие ветки, ответ конкретному участнику, неактивная пустая отправка, сохранение черновика при закрытии, успешная отправка, независимый контекст второй публикации.
- Проверено, что HTML-подобный текст ответа отображается как текст, без создания пользовательских HTML-элементов.
- Проверены выбор/удаление вложения и возможность отправить вложение без текста, переключение макета клавиатуры, Escape и снятие `inert` с основного содержимого.
- Проверены публикация владельца и её сохранение при переключении роли, отдельное открытие каждого из четырёх вариантов через `?variant=`.
- После последних изменений повторены синтаксическая проверка и проверки взаимодействий — успешно.
- Git показывает только новую папку `docs/UI-Design/`. Существующие файлы приложения не изменялись.
Инструменты DOM-проверки и временный сценарий установлены в `/tmp/shine-ui-review-gporxy`, зависимости приложения не менялись.
## Ограничения проверки
Подключённый Browser недоступен: runtime сообщил `No browser is available`, список браузеров пуст. Скриншоты не получены. jsdom проверяет структуру и поведение, но не рассчитывает реальную геометрию и не заменяет визуальную проверку браузером.
Перед переносом в приложение обязательна проверка в реальном браузере: ширины 320/390/430 px, увеличение текста, длинные названия, прокрутка и восстановление позиции, экранная клавиатура Android/iOS, фокус и screen reader, контраст, состояния загрузки и ошибки. Эмуляция клавиатуры в галерее показывает только занятое ею место.
Никакие серверные операции, публикации, загрузки файлов или изменения production не выполнялись.
## Уточнение документа 0.2
22.09.2026, после выбора пользователем направления:
- Обновлены `DESIGN.md` и `README.md`; HTML-галерея оставлена историческим референсом, это явно отмечено в обоих документах.
- Убраны устаревшие вопросы о выборе темы/редактора и указания показывать статистику лайков или оглавление вне меню.
- Добавлены единые компоненты, цвета двух тем, компактная шапка, полноэкранный редактор, состояния и матрица приёмки будущего переноса.
- Локально рассчитан контраст основных пар цветов. Основной текст/фон: ночь 15.34:1, день 12.98:1. Вторичный текст/фон: ночь 7.45:1, день после коррекции 5.26:1. Вторичный текст/выбранная поверхность: ночь 5.10:1, день 4.55:1. Текст акцентной кнопки: ночь 9.86:1, день 5.88:1. Граница контрола/поверхность: ночь 4.10:1, день 3.29:1.
- Эти числа относятся к непрозрачным цветам токенов. Проверка реальных CSS-состояний, прозрачностей, наложений, всех статусных цветов и геометрии потребуется при реализации; полного аудита доступности пока нет.
- Изменения документа не означают обновления приложения или визуальной проверки итогового сочетания.
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SHiNE — четыре направления UI</title>
<link rel="stylesheet" href="mockups.css">
<script src="mockups.js" defer></script>
</head>
<body>
<header class="studio-header">
<a class="brand" href="index.html" aria-label="SHiNE — все варианты"><span class="brand-spark">✳</span> SHiNE<span class="brand-caption">DESIGN STUDY / 01</span></a>
<span class="draft-label">Концепции · сентябрь 2026</span>
</header>
<main>
<section class="intro">
<div><div class="eyebrow">КАНАЛЫ / СООБЩЕНИЯ / ОТВЕТЫ</div><h1>Ближе к разговору.</h1><p>Четыре характера. Один привычный путь от канала к ответу.<br>Выберите экран и сравните — или нажмите на элементы внутри макета.</p></div>
<div class="intro-note"><span>01—04</span><p>Меньше визуального шума.<br>Больше места людям и мыслям.</p></div>
</section>
<section class="review-bar" aria-label="Управление просмотром">
<div class="screen-switch" role="group" aria-label="Экран для всех вариантов">
<button class="review-button selected" data-screen="list" aria-pressed="true">01 Каналы</button>
<button class="review-button" data-screen="feed" aria-pressed="false">02 Сообщения</button>
<button class="review-button" data-screen="thread" aria-pressed="false">03 Ветка</button>
<button class="review-button" data-screen="reply" aria-pressed="false">04 Ответ</button>
</div>
<div class="review-options"><label>Роль <select id="role"><option value="reader">Читатель</option><option value="owner">Владелец</option></select></label><label class="keyboard-option"><input type="checkbox" id="keyboard"> Макет клавиатуры</label><a class="compare-link" href="index.html">Все варианты ↗</a></div>
</section>
<div id="gallery" class="gallery"></div>
<section class="decision-note"><span class="eyebrow">С ЧЕГО НАЧАТЬ ОБСУЖДЕНИЕ</span><h2>Сначала удобство. Затем характер.</h2><div class="decision-grid"><p><b>01 / Читаемость</b>Где комфортнее читать длинный пост и замечать новый ответ?</p><p><b>02 / Плотность</b>Нужны просторные сообщения или больше содержимого на одном экране?</p><p><b>03 / Окно ответа</b>Полный экран или лист поверх ветки? Проверьте с макетом клавиатуры.</p></div><p class="disclaimer">Демонстрационные данные. Отправка и реакции работают только внутри этой страницы. Поиск фильтрует примеры. Другие разделы приложения не проектировались. <a href="../DESIGN.md">Дизайн-документ ↗</a></p></section>
</main>
<footer class="studio-footer"><span>SHiNE / пространство для общения</span><span>Рабочий материал · не финальный дизайн</span></footer>
</body>
</html>
+212
View File
@@ -0,0 +1,212 @@
* { box-sizing: border-box; }
:root { color-scheme: light; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #242622; background: #f1f0eb; }
body { margin: 0; }
button, input, textarea, select { font: inherit; }
button, a, select { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
button:disabled { cursor: default; opacity: .4; }
button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid #728de3; outline-offset: 3px; }
a { color: inherit; }
.studio-header { margin: 0 42px; padding: 26px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #d5d6ce; gap: 16px; }
.brand { display: flex; align-items: center; gap: 9px; font-size: 25px; font-weight: 760; text-decoration: none; letter-spacing: -1px; }
.brand-spark { font-size: 36px; font-weight: 400; }
.brand-caption { margin-left: 22px; font-size: 10px; letter-spacing: 2px; font-weight: 600; }
.draft-label { font-size: 12px; color: #63675f; }
main { padding: 0 42px; max-width: 1900px; margin: auto; }
.intro { display: flex; justify-content: space-between; align-items: end; padding: 48px 0 34px; gap: 30px; }
.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 1.8px; }
h1 { font-size: clamp(34px, 4vw, 60px); line-height: 1.1; letter-spacing: -2.8px; font-weight: 550; margin: 16px 0; }
.intro p { color: #62665d; font-size: 14px; line-height: 1.7; margin: 0; }
.intro-note { border-left: 1px solid #c6c8bd; padding-left: 24px; min-width: 250px; }
.intro-note > span { font-size: 38px; letter-spacing: -2px; }
.intro-note p { font-size: 12px; margin-top: 10px; }
.review-bar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; padding: 16px 0; border-top: 1px solid #d5d6ce; border-bottom: 1px solid #d5d6ce; }
.screen-switch { display: flex; flex-wrap: wrap; gap: 4px; }
.review-button { border: 0; padding: 11px 14px; background: transparent; border-radius: 7px; font-size: 12px; color: #62665d; min-height: 42px; }
.review-button.selected { background: #292d27; color: white; }
.review-options { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; font-size: 12px; color: #62665d; }
.review-options label { display: flex; align-items: center; gap: 6px; }
.review-options select { padding: 8px; border: 1px solid #d5d6ce; border-radius: 7px; background: transparent; color: #242622; }
.compare-link { display: none; }
.gallery { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; padding: 30px 0 36px; align-items: start; }
.concept { min-width: 0; }
.concept-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.concept-number { font-size: 11px; border: 1px solid #c9cdc2; border-radius: 50%; width: 27px; height: 27px; display: grid; place-items: center; }
.concept-heading h2 { font-size: 17px; font-weight: 600; margin: 0; letter-spacing: -.5px; }
.open-concept { margin-left: auto; padding: 10px; text-decoration: none; font-size: 18px; }
.concept-description { font-size: 12px; line-height: 1.6; color: #6b7065; min-height: 40px; margin: 0 0 18px; }
.phone { --bg: #111212; --surface: #1c1d1d; --text: #f3f3f1; --muted: #a0a2a0; --line: #2c2d2d; --accent: #f3f3f1; --on-accent: #161717; --soft: #252727; position: relative; display: flex; flex-direction: column; height: 760px; width: 100%; max-width: 410px; margin: auto; color: var(--text); background: var(--bg); border: 1px solid #bfc2b8; border-radius: 32px; overflow: hidden; box-shadow: 0 14px 35px #252d2210; font-size: 14px; line-height: 1.45; isolation: isolate; }
.phone[data-theme="warm"] { --bg: #faf7f0; --surface: #fffdf8; --text: #302c25; --muted: #7b7264; --line: #e6dfd1; --accent: #93511e; --on-accent: #fffaf3; --soft: #efe6d6; }
.phone[data-theme="night"] { --bg: #101b20; --surface: #17272d; --text: #e8f2f1; --muted: #98adaf; --line: #2a3b40; --accent: #94e1ce; --on-accent: #102c27; --soft: #213b3a; }
.phone[data-theme="club"] { --bg: #f9fafb; --surface: #fff; --text: #202b3c; --muted: #6b7688; --line: #e2e7ee; --accent: #4c5caa; --on-accent: #fff; --soft: #eaeefa; }
.status-bar { display: flex; justify-content: space-between; padding: 15px 24px 6px; font-size: 11px; font-weight: 700; flex: 0 0 38px; }
.status-icons { display: flex; gap: 5px; align-items: center; font-size: 10px; }
.icon { height: 21px; width: 21px; display: inline-block; vertical-align: middle; flex-shrink: 0; }
.status-icons .icon { height: 16px; width: 16px; }
.phone button { color: inherit; }
.icon-btn { background: transparent; border: 0; width: 44px; height: 44px; flex: 0 0 44px; display: inline-grid; place-items: center; border-radius: 50%; padding: 0; }
.icon-btn:hover { background: var(--soft); }
.app-header { display: flex; align-items: center; padding: 4px 12px 10px; gap: 6px; min-height: 66px; border-bottom: 1px solid var(--line); }
.header-text { flex: 1; min-width: 0; }
.header-text h3 { font-size: 17px; letter-spacing: -.4px; margin: 0; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.header-text small { color: var(--muted); font-size: 10px; }
.list-header { padding-left: 22px; }
.list-header h3 { font-size: 25px; letter-spacing: -.9px; }
.mini-brand { color: var(--accent); font-size: 26px; line-height: 1; margin-right: 3px; }
.view { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; overscroll-behavior: contain; }
.search-field { margin: 15px 18px 10px; display: flex; gap: 9px; align-items: center; background: var(--surface); border-radius: 12px; padding: 0 12px; border: 1px solid var(--line); color: var(--muted); }
.search-field input { width: 100%; min-width: 0; border: 0; background: transparent; height: 42px; outline-offset: 0; color: var(--text); font-size: 13px; }
.search-field input::placeholder { color: var(--muted); }
.filter-tabs { display: flex; padding: 0 18px 12px; gap: 7px; }
.filter { font-size: 11px; padding: 0 11px; min-height: 36px; background: transparent; border: 1px solid transparent; border-radius: 20px; color: var(--muted); }
.filter[aria-pressed="true"] { color: var(--text); background: var(--soft); }
.section-label { padding: 10px 20px 7px; display: flex; justify-content: space-between; text-transform: uppercase; letter-spacing: 1.3px; font-size: 9px; font-weight: 700; color: var(--muted); }
.channel-row { display: flex; width: 100%; align-items: center; gap: 12px; padding: 17px 18px; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; }
.channel-row:hover { background: var(--soft); }
.avatar { width: 40px; height: 40px; border-radius: 50%; background: #bdc5a8; color: #283124; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; font-size: 13px; font-weight: 650; letter-spacing: -.5px; }
.avatar.city { background: #ded3bb; color: #635039; }
.avatar.blue { background: #bac5d9; color: #354663; }
.avatar.rose { background: #d9bcbb; color: #704444; }
.avatar.green { background: #b9cdc0; color: #345747; }
.avatar.violet { background: #cbc2d9; color: #56476c; }
.avatar.self { background: var(--soft); color: var(--accent); }
.channel-row .avatar { width: 48px; height: 48px; font-size: 23px; }
.channel-copy { min-width: 0; flex: 1; }
.channel-copy strong { display: block; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.channel-copy small { display: block; color: var(--muted); font-size: 10px; margin: 2px 0; }
.channel-copy p { margin: 3px 0 0; font-size: 12px; color: var(--muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.channel-tail { display: flex; align-items: end; flex-direction: column; gap: 11px; align-self: stretch; padding-top: 2px; color: var(--muted); font-size: 10px; }
.badge { min-width: 20px; height: 20px; background: var(--accent); color: var(--on-accent); padding: 2px 6px; border-radius: 10px; text-align: center; font-size: 10px; font-weight: 700; }
.list-tip { padding: 24px 24px 16px; color: var(--muted); font-size: 11px; text-align: center; }
.bottom-nav { border-top: 1px solid var(--line); display: flex; justify-content: space-around; flex: 0 0 73px; align-items: start; padding: 9px 6px 12px; background: var(--bg); }
.nav-item { border: 0; background: transparent; display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 8px; width: 20%; min-height: 44px; opacity: .65; }
.nav-item[aria-current="page"] { opacity: 1; color: var(--accent); }
.nav-item[aria-current="page"] .icon { background: var(--soft); border-radius: 7px; box-shadow: 0 0 0 5px var(--soft); }
.channel-intro { margin: 0; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); }
.channel-intro p { margin: 0 0 12px; color: var(--muted); font-size: 12px; line-height: 1.6; }
.channel-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.text-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; padding: 0 4px; min-height: 36px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 600; }
.text-button .icon { width: 16px; height: 16px; }
.pill { border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 8px 11px; font-size: 10px; min-height: 36px; }
.unread-line { display: flex; align-items: center; gap: 10px; color: var(--accent); font-size: 9px; padding: 13px 20px 0; }
.unread-line::before, .unread-line::after { content: ""; height: 1px; flex: 1; background: var(--line); }
.post { padding: 18px 18px 10px; border-bottom: 1px solid var(--line); }
.post-header { display: flex; align-items: center; gap: 9px; }
.post-header .avatar { width: 34px; height: 34px; font-size: 11px; }
.author { min-width: 0; flex: 1; font-size: 12px; }
.author strong { font-weight: 650; }
.author small { color: var(--muted); font-size: 10px; display: block; }
.post .more { width: 36px; height: 36px; flex-basis: 36px; color: var(--muted); }
.post p { font-size: 14px; line-height: 1.6; margin: 10px 0 12px; overflow-wrap: anywhere; }
.post-title { font-weight: 600; }
.landscape { height: 142px; overflow: hidden; border-radius: 12px; position: relative; background: #c9d6cf; margin: 10px 0 8px; }
.landscape svg { width: 100%; height: 100%; display: block; }
.media-caption { position: absolute; bottom: 10px; left: 12px; font-size: 9px; letter-spacing: 1px; color: #fff; text-transform: uppercase; }
.post-actions { display: flex; gap: 14px; align-items: center; color: var(--muted); }
.action { border: 0; background: transparent; min-height: 44px; display: flex; align-items: center; gap: 6px; padding: 0 2px; font-size: 11px; color: var(--muted); }
.action[aria-pressed="true"] { color: #dc7477; }
.action[aria-pressed="true"] .icon { fill: currentColor; }
.reply-link { margin-left: auto; font-size: 10px; }
.attachment { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); background: var(--surface); border-radius: 12px; padding: 12px; margin: 10px 0; }
.attachment > .icon { color: var(--accent); }
.attachment strong { font-size: 11px; display: block; }
.attachment small { color: var(--muted); display: block; font-size: 10px; }
.composer-bar { display: flex; align-items: center; padding: 10px 16px; gap: 10px; border-top: 1px solid var(--line); background: var(--bg); flex: 0 0 auto; }
.composer-trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 46px; padding: 10px 14px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); border-radius: 24px; text-align: left; font-size: 12px; }
.composer-trigger.primary { background: var(--accent); color: var(--on-accent); border-color: transparent; justify-content: center; font-weight: 650; }
.thread-heading { padding: 13px 20px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; border-bottom: 1px solid var(--line); }
.thread-heading strong { color: var(--text); }
.reply { display: flex; padding: 16px 18px 0; gap: 10px; }
.reply-body { flex: 1; min-width: 0; padding-bottom: 9px; border-bottom: 1px solid var(--line); }
.reply .avatar { width: 32px; height: 32px; font-size: 10px; }
.reply p { font-size: 13px; margin: 6px 0 2px; line-height: 1.55; overflow-wrap: anywhere; }
.reply small { color: var(--muted); font-size: 10px; }
.reply strong { font-size: 12px; }
.reply .post-actions { gap: 16px; }
.reply-context { color: var(--accent); font-size: 10px; }
.empty { padding: 40px 25px; text-align: center; color: var(--muted); font-size: 13px; }
.overlay { position: absolute; inset: 38px 0 0; z-index: 5; background: #0009; display: flex; flex-direction: column; justify-content: end; }
.sheet { display: flex; flex-direction: column; background: var(--bg); max-height: 100%; min-height: 78%; border-radius: 24px 24px 0 0; overflow: hidden; box-shadow: 0 -8px 40px #0002; }
.sheet.full { height: 100%; border-radius: 0; }
.sheet-handle { width: 32px; height: 4px; border-radius: 4px; background: var(--line); align-self: center; margin-top: 9px; }
.sheet-header { display: flex; align-items: center; padding: 7px 12px; border-bottom: 1px solid var(--line); gap: 6px; }
.sheet-header h4 { margin: 0; flex: 1; text-align: center; font-size: 15px; }
.sheet-header .expand { color: var(--muted); }
.editor-scroll { overflow-y: auto; flex: 1; min-height: 0; padding: 18px; }
.source-preview { display: flex; gap: 10px; padding-bottom: 20px; position: relative; }
.source-preview::after { content: ""; position: absolute; left: 19px; top: 46px; bottom: 3px; width: 1px; background: var(--line); }
.source-preview p { font-size: 12px; color: var(--muted); margin: 5px 0; line-height: 1.5; }
.source-preview strong { font-size: 12px; }
.source-preview small { color: var(--muted); font-size: 10px; }
.editor-row { display: flex; gap: 10px; }
.editor-main { flex: 1; min-width: 0; }
.editor-main strong { display: block; font-size: 12px; margin: 2px 0; }
.editor-main > small { color: var(--muted); display: block; font-size: 10px; margin: 3px 0 10px; }
.editor-input { resize: none; border: 0; width: 100%; min-height: 115px; background: transparent; color: var(--text); font-size: 15px; line-height: 1.6; padding: 3px 0; }
.editor-input::placeholder { color: var(--muted); }
.editor-input:focus-visible { outline: none; box-shadow: 0 2px 0 var(--accent); }
.editor-footer { display: flex; align-items: center; gap: 4px; padding: 11px 16px 16px; border-top: 1px solid var(--line); background: var(--bg); }
.char-count { color: var(--muted); font-size: 10px; margin-left: auto; margin-right: 10px; }
.submit { border: 0; border-radius: 22px; padding: 12px 18px; background: var(--accent); color: var(--on-accent); font-size: 12px; font-weight: 650; min-height: 44px; }
.phone .submit { color: var(--on-accent); }
.editor-attachment { margin-top: 10px; display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: var(--soft); border-radius: 10px; font-size: 10px; }
.editor-attachment span { flex: 1; overflow-wrap: anywhere; }
.keyboard { flex: 0 0 auto; background: var(--surface); padding: 10px 4px 18px; border-top: 1px solid var(--line); }
.keyboard-label { font-size: 9px; text-align: center; color: var(--muted); margin-bottom: 10px; }
.keys { display: flex; justify-content: center; gap: 3px; margin: 5px 0; }
.keys span { display: grid; place-items: center; height: 31px; flex: 1; max-width: 29px; border-radius: 5px; font-size: 13px; color: var(--text); background: var(--soft); box-shadow: 0 1px 0 #0003; }
.keys .space { max-width: 150px; flex: 5; font-size: 10px; }
.keys .wide { max-width: 45px; flex: 1.7; font-size: 11px; }
.menu-sheet { min-height: 0; padding-bottom: 24px; }
.menu-content { padding: 10px 20px; }
.menu-action { border: 0; background: transparent; width: 100%; text-align: left; display: flex; gap: 12px; align-items: center; min-height: 47px; font-size: 13px; border-bottom: 1px solid var(--line); }
.menu-content p { color: var(--muted); font-size: 12px; line-height: 1.6; }
.toast { position: absolute; bottom: 86px; left: 18px; right: 18px; padding: 12px 15px; border-radius: 12px; background: var(--text); color: var(--bg); z-index: 10; font-size: 12px; box-shadow: 0 4px 20px #0003; }
.concept-notes { margin-top: 19px; font-size: 12px; color: #666d61; line-height: 1.65; }
.concept-notes strong { color: #33392f; font-weight: 600; }
.swatches { display: flex; gap: 5px; margin-bottom: 12px; }
.swatches span { width: 19px; height: 19px; border-radius: 50%; border: 1px solid #0002; }
.concept-tag { display: inline-block; border: 1px solid #c9cdc2; padding: 3px 7px; border-radius: 4px; font-size: 9px; letter-spacing: .3px; margin-bottom: 8px; }
.decision-note { padding: 36px 0; border-top: 1px solid #d5d6ce; }
.decision-note h2 { font-weight: 500; letter-spacing: -.7px; font-size: 27px; margin: 13px 0; }
.decision-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 38px; }
.decision-grid p { font-size: 13px; line-height: 1.7; color: #62665d; }
.decision-grid b { display: block; font-weight: 600; color: #33392f; margin-bottom: 6px; }
.disclaimer { font-size: 11px; color: #62665d; line-height: 1.7; margin-top: 24px; }
.studio-footer { display: flex; justify-content: space-between; margin: 0 42px; padding: 22px 0; border-top: 1px solid #d5d6ce; color: #73796b; font-size: 10px; gap: 15px; }
/* Варианты отличаются также плотностью, иерархией и формой редактора. */
.phone[data-theme="mono"] .post { padding-left: 18px; }
.phone[data-theme="mono"] .post-content { margin-left: 43px; }
.phone[data-theme="mono"] .landscape { height: 128px; }
.phone[data-theme="warm"] .channel-row { margin: 0 12px 8px; width: calc(100% - 24px); border: 1px solid var(--line); border-radius: 16px; padding: 14px 12px; background: var(--surface); }
.phone[data-theme="warm"] .channel-intro { background: var(--soft); margin: 14px 16px 0; border: 0; border-radius: 16px; }
.phone[data-theme="warm"] .post { margin: 14px 12px; padding: 15px 14px 7px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); }
.phone[data-theme="warm"] .post-title { font-family: Georgia, serif; font-size: 20px; line-height: 1.3; }
.phone[data-theme="warm"] .reply .avatar { border-radius: 12px; }
.phone[data-theme="night"] .list-header { border-bottom: 0; padding-top: 12px; }
.phone[data-theme="night"] .channel-intro { background: linear-gradient(125deg, #1d3937, #16272e); border-bottom: 1px solid #36544f; }
.phone[data-theme="night"] .channel-row { border-bottom-color: #21343b; }
.phone[data-theme="night"] .channel-row:first-child { background: #172b2e; box-shadow: inset 3px 0 var(--accent); }
.phone[data-theme="night"] .composer-trigger { border-radius: 13px; }
.phone[data-theme="night"] .post-title { font-size: 17px; font-weight: 550; }
.phone[data-theme="club"] .channel-row { padding: 12px 16px; gap: 10px; }
.phone[data-theme="club"] .channel-row .avatar { width: 42px; height: 42px; border-radius: 13px; }
.phone[data-theme="club"] .channel-copy small { display: none; }
.phone[data-theme="club"] .filter-tabs { border-bottom: 1px solid var(--line); padding-bottom: 0; gap: 12px; }
.phone[data-theme="club"] .filter { border-radius: 0; padding: 0 1px; }
.phone[data-theme="club"] .filter[aria-pressed="true"] { background: transparent; color: var(--accent); border-bottom: 2px solid var(--accent); }
.phone[data-theme="club"] .post { padding: 12px 16px 5px; }
.phone[data-theme="club"] .post p { font-size: 13px; margin: 7px 0; }
.phone[data-theme="club"] .landscape { height: 108px; }
.phone[data-theme="club"] .channel-intro { padding: 12px 16px 8px; }
.phone[data-theme="club"] .channel-intro p { margin-bottom: 5px; }
.phone[data-theme="club"] .reply { padding-top: 12px; }
.phone[data-theme="club"] .reply-body { border-left: 2px solid var(--line); border-bottom: 0; padding-left: 12px; }
.phone[data-theme="club"] .composer-trigger { border-radius: 10px; }
.gallery.single { display: block; max-width: 410px; margin: auto; }
.single .concept-description { min-height: 0; }
body.focused .compare-link { display: inline; }
@media (max-width: 1490px) { .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 900px; margin: auto; gap: 40px; } .concept { max-width: 410px; width: 100%; justify-self: center; } }
@media (max-width: 760px) { .studio-header { margin: 0 20px; padding: 18px 0; } main { padding: 0 20px; } .brand-caption, .draft-label, .intro-note { display: none; } .intro { padding: 30px 0 24px; } h1 { letter-spacing: -1.5px; } .intro p { font-size: 13px; } .gallery { grid-template-columns: minmax(0, 1fr); gap: 32px; } .screen-switch { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .review-button { padding: 10px; } .review-options { gap: 12px; } .phone { height: 760px; border-radius: 25px; } .decision-grid { grid-template-columns: 1fr; gap: 0; } .studio-footer { margin: 0 20px; } }
@media (max-width: 380px) { main { padding: 0 10px; } .post-actions { gap: 10px; } .phone { font-size: 13px; } .channel-row { padding-left: 12px; padding-right: 12px; gap: 8px; } .header-text h3 { font-size: 15px; } .concept-heading h2 { font-size: 16px; } }
@media (prefers-reduced-motion: no-preference) { .sheet { animation: appear .18s ease-out; } @keyframes appear { from { transform: translateY(22px); opacity: .5; } to { transform: translateY(0); opacity: 1; } } }
+246
View File
@@ -0,0 +1,246 @@
/* Изолированный прототип: без API, импортов приложения и постоянного хранилища. */
'use strict';
const icons = {
back: '<path d="m14 5-7 7 7 7M7 12h14"/>',
close: '<path d="m6 6 12 12M6 18 18 6"/>',
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
heart: '<path d="M20.8 4.8a5.5 5.5 0 0 0-7.8 0L12 6l-1.1-1.2a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.4a5.5 5.5 0 0 0 0-7.8Z"/>',
chat: '<path d="M21 11.5a9 9 0 0 1-9 9 9.7 9.7 0 0 1-4-.9L3 21l1.4-4.7A9 9 0 1 1 21 11.5Z"/>',
share: '<path d="m21 3-7 18-4-8-8-3 19-7ZM10 13 21 3"/>',
channels: '<rect x="3" y="4" width="18" height="13" rx="3"/><path d="m8 21 4-4 4 4M7 8h10M7 12h6"/>',
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20v-2a6 6 0 0 1 12 0v2M16 5a3 3 0 0 1 0 6M18 14a5 5 0 0 1 3 5"/>',
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>',
user: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
book: '<path d="M12 5v16M3 4c4-1 6-1 9 1 3-2 5-2 9-1v15c-4-1-6-1-9 2-3-3-5-3-9-2Z"/>',
file: '<path d="M14 2H5v20h14V7l-5-5ZM14 2v6h5M8 13h8M8 17h5"/>',
attach: '<path d="m9 17 9-9a3 3 0 0 0-4-4L4 14a5 5 0 0 0 7 7L21 11M7 14l8-8"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="4"/><circle cx="8" cy="8" r="1"/><path d="m3 17 6-6 4 4 3-3 5 5"/>',
expand: '<path d="M14 3h7v7M21 3l-7 7M10 21H3v-7M3 21l7-7"/>',
check: '<path d="m5 12 4 4L19 6"/>',
edit: '<path d="m15 4 5 5M4 20l5-1L21 7a2 2 0 0 0-4-4L5 15l-1 5Z"/>',
wifi: '<path d="M3 8a15 15 0 0 1 18 0M6 12a10 10 0 0 1 12 0M9 16a5 5 0 0 1 6 0M12 20h.01"/>',
battery: '<rect x="2" y="6" width="18" height="12" rx="2"/><path d="M23 10v4M5 9h11v6H5Z"/>',
};
const icon = name => `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] || icons.chat}</svg>`;
const escapeText = value => String(value).replace(/[&<>"']/g, c => ({'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;'}[c]));
const button = (action, label, name, extra = '') => `<button class="icon-btn" data-action="${action}" aria-label="${label}" title="${label}" ${extra}>${icon(name)}</button>`;
const concepts = [
{ id:'mono', number:'01', title:'Чистый монохром', description:'Контент на первом плане. Знакомый ритм Threads.', colors:['#111212','#2c2d2d','#f3f3f1'], tag:'МАКСИМАЛЬНО ПРИВЫЧНО', note:'Плоская лента, аватар на полях и полноэкранный ответ. <strong>Самая нейтральная отправная точка.</strong>' },
{ id:'warm', number:'02', title:'Тёплый SHiNE', description:'Бумажные оттенки. Больше воздуха и мягкости.', colors:['#faf7f0','#efe6d6','#93511e'], tag:'МЯГКИЙ И ЛИЧНЫЙ', note:'Карточки отделяют разговоры, тёплый акцент выделяет действия. Ответ открывается <strong>округлым листом снизу.</strong>' },
{ id:'night', number:'03', title:'Ночной SHiNE', description:'Глубокий синий. Спокойный свет в деталях.', colors:['#101b20','#213b3a','#94e1ce'], tag:'ХАРАКТЕР SHiNE', note:'Широкий текст и выразительная шапка канала. <strong>Мой кандидат на развитие:</strong> узнаваемый, но сдержанный.' },
{ id:'club', number:'04', title:'Компактный клуб', description:'Больше разговоров на одном экране.', colors:['#f9fafb','#eaeefa','#4c5caa'], tag:'ПЛОТНО И СОБРАННО', note:'Компактные строки, подчёркнутые вкладки и линии ответов. Редактор <strong>на весь экран</strong>, без потери контекста.' },
];
const channels = [
{ title:'Город и люди', owner:'anna', slug:'city', symbol:'⌂', color:'city', preview:'Маленькие открытия по дороге домой', time:'12 мин', unread:3, following:true },
{ title:'Дизайн каждый день', owner:'mikhail', slug:'design', symbol:'Aa', color:'blue', preview:'Как сделать сложное простым?', time:'38 мин', unread:7, following:true },
{ title:'Тихие маршруты', owner:'lena', slug:'walks', symbol:'↗', color:'green', preview:'Сохранили маршрут на выходные', time:'1 ч', unread:2, following:true },
{ title:'Книжная полка', owner:'olga', slug:'books', symbol:'≋', color:'rose', preview:'Что сейчас читаете?', time:'2 ч', unread:0, following:true },
{ title:'SHiNE · новости', owner:'shine', slug:'news', symbol:'✳', color:'violet', preview:'Место, где начинаются разговоры', time:'вчера', unread:0, following:false },
{ title:'Мои заметки', owner:'you', slug:'notes', symbol:'✎', color:'blue', preview:'Идея для следующей встречи', time:'вчера', unread:0, following:false },
];
let role = 'reader';
let showKeyboard = false;
const states = new Map();
const mainText = 'Иногда лучший маршрут — чуть длиннее обычного. Сегодня свернула к реке и нашла это место. А где вы перезагружаетесь после рабочего дня?';
const secondText = 'Собрала наши любимые места в один список. Добавляйте свои находки в ответах!';
const landscape = () => `<div class="landscape" role="img" aria-label="Иллюстрация: тихая река, холмы и солнце"><svg viewBox="0 0 400 180" preserveAspectRatio="xMidYMid slice"><rect width="400" height="180" fill="#cad8d0"/><circle cx="290" cy="46" r="24" fill="#f5e7b5"/><path d="M0 87Q65 35 136 79T270 70T400 61V180H0" fill="#8faaa0"/><path d="M0 125Q73 67 155 102T310 86T400 101V180H0" fill="#527b70"/><path d="M0 153Q98 104 184 130T400 110V180H0" fill="#31594f"/><path d="M216 100Q105 130 236 145T201 180H311Q347 151 239 137T242 101Z" fill="#c2d2ba"/><path d="M32 138V59m0 4-15 30h30ZM361 129V47m0 4-18 34h36Z" fill="#30534a" stroke="#30534a" stroke-width="4"/></svg><span class="media-caption">У реки / 18:42</span></div>`;
function header(s) {
if (s.screen === 'list') return `<header class="app-header list-header"><span class="mini-brand">✳</span><div class="header-text"><h3>Каналы</h3></div>${button('new-channel','Создать канал','plus')}</header>`;
return `<header class="app-header">${button('back','Назад','back')}<div class="header-text"><h3>${s.screen === 'thread' ? 'Обсуждение' : escapeText(s.channel.title)}</h3><small>${s.screen === 'thread' ? escapeText(s.channel.title) : '@'+escapeText(s.channel.owner)+' / '+escapeText(s.channel.slug)}</small></div>${button('notifications',s.muted ? 'Включить уведомления' : 'Настроить уведомления','bell')}${button('channel-menu','Меню канала','more')}</header>`;
}
function nav() {
return `<nav class="bottom-nav" aria-label="Разделы приложения">${[['chat','Личные'],['channels','Каналы'],['people','Связи'],['bell','Уведомления'],['user','Профиль']].map(([i,label])=>`<button class="nav-item" data-action="${i==='channels'?'list':'outside'}" ${i==='channels'?'aria-current="page"':''}>${icon(i)}<span>${label}</span></button>`).join('')}</nav>`;
}
function rows(s) {
const filtered = channels.filter(c => (s.filter!=='mine'||c.owner==='you') && (s.filter!=='following'||c.following) && `${c.title} ${c.owner}`.toLowerCase().includes(s.query.toLowerCase()));
return filtered.length ? filtered.map(c=>`<button class="channel-row" data-action="open-channel" data-index="${channels.indexOf(c)}"><span class="avatar ${c.color}">${c.symbol}</span><span class="channel-copy"><strong>${c.title}</strong><small>@${c.owner} / ${c.slug}</small><p>${c.preview}</p></span><span class="channel-tail"><span>${c.time}</span>${c.unread?`<span class="badge" aria-label="${c.unread} непрочитанных">${c.unread}</span>`:''}</span></button>`).join('') : '<p class="empty">Каналы не найдены.<br>Попробуйте другое название или логин.</p>';
}
function list(s) {
return `<label class="search-field">${icon('search')}<input data-search aria-label="Поиск каналов" placeholder="Название или @автор" value="${escapeText(s.query)}"></label><div class="filter-tabs" role="group" aria-label="Фильтр каналов">${[['all','Все'],['following','Подписки'],['mine','Мои']].map(([value,label])=>`<button class="filter" data-action="filter" data-value="${value}" aria-pressed="${s.filter===value}">${label}</button>`).join('')}</div><div class="section-label"><span>${s.filter==='mine'?'Ваши каналы':'Ваше пространство'}</span><span>SHiNE</span></div><div class="channel-rows">${rows(s)}</div><p class="list-tip">Хорошие разговоры начинаются<br>с общих интересов.</p>`;
}
function actions(s, id, count, replies) {
const liked = s.likes.has(id);
replies = 2 + s.replies.filter(reply => reply.post === id).length;
return `<div class="post-actions"><button class="action" data-action="like" data-post="${id}" aria-label="Нравится" aria-pressed="${liked}">${icon('heart')}<span>${count+(liked?1:0)}</span></button><button class="action" data-action="thread" data-post="${id}" aria-label="Открыть ${replies} ответов">${icon('chat')}<span>${replies}</span></button><button class="action" data-action="share" aria-label="Поделиться">${icon('share')}</button><button class="action reply-link" data-action="reply" data-post="${id}">Ответить</button></div>`;
}
function post(s, second = false) {
return `<article class="post"><div class="post-header"><span class="avatar city">АК</span><div class="author"><strong>${escapeText(s.channel.owner==='anna'?'Анна К.':s.channel.owner)}</strong><small>${second?'вчера, 19:10':'12 минут назад'}</small></div><button class="icon-btn more" data-action="post-menu" aria-label="Действия с сообщением">${icon('more')}</button></div><div class="post-content">${second?'<p>Собрала наши любимые места в один список. Добавляйте свои находки в ответах!</p><div class="attachment">'+icon('file')+'<div><strong>Места для прогулок.pdf</strong><small>PDF · 552 КБ</small></div></div>':`<p class="post-title">Маленькие открытия</p><p>${mainText}</p>${landscape()}`}${actions(s, second?'second':'first',second?12:24, second?3:2+s.replies.length)}</div></article>`;
}
function feed(s) {
return `<section class="channel-intro"><p>Замечаем красоту рядом. Делимся местами,<br>историями и маленькими открытиями.</p><div class="channel-tools"><button class="text-button" data-action="contents">${icon('book')} Оглавление</button><button class="pill" data-action="subscribe">${s.subscribed?'Вы подписаны':'Подписаться'}</button></div></section><div class="unread-line">Новые сообщения</div>${post(s)}${post(s,true)}`;
}
function replyRow(name, initials, color, text, target='') {
return `<article class="reply"><span class="avatar ${color}">${initials}</span><div class="reply-body">${target?`<div class="reply-context">В ответ ${escapeText(target)}</div>`:''}<strong>${escapeText(name)}</strong> <small>· сейчас</small><p>${escapeText(text)}</p><div class="post-actions"><button class="action" data-action="reply-to" data-author="${escapeText(name)}" data-text="${escapeText(text)}">${icon('chat')} Ответить</button></div></div></article>`;
}
function thread(s) {
const replies=s.replies.filter(r=>r.post===s.activePost);
const second=s.activePost==='second';
return `${post(s,second)}<div class="thread-heading"><strong>Ответы · ${2+replies.length}</strong><span>По порядку</span></div>${replyRow('Михаил','МЛ','blue',second?'Спасибо за список! Добавлю ещё парк у старого моста.':'У воды всегда легче выдохнуть. Это возле старого моста?')}${replyRow('Лена','ЛС','green',second?'В выходные попробую один из маршрутов.':'А я ухожу гулять без наушников. Город звучит совсем иначе.')}${replies.map(r=>replyRow('Вы','ВЫ','self',r.text,r.target)).join('')}`;
}
function composer(s) {
if (s.screen==='list') return '';
if (s.screen==='feed' && role==='reader') return '';
return `<div class="composer-bar"><button class="composer-trigger ${s.screen==='feed'?'primary':''}" data-action="${s.screen==='feed'?'new-post':'reply'}"><span>${s.screen==='feed'?'Написать в канал':'Ответить '+s.channel.owner+'…'}</span>${icon(s.screen==='feed'?'plus':'edit')}</button></div>`;
}
function render(s) {
const previousView = s.phone.querySelector('.view');
s.scrolls ||= {};
if (previousView && s.renderedScreen) s.scrolls[s.renderedScreen] = previousView.scrollTop;
s.phone.innerHTML = `<div class="status-bar"><span>9:41</span><span class="status-icons">${icon('wifi')}${icon('battery')}</span></div>${header(s)}<div class="view">${s.screen==='list'?list(s):s.screen==='feed'?feed(s):thread(s)}</div>${composer(s)}${nav()}`;
if(s.screen==='feed') for(const text of s.posts) {
const item=document.createElement('article'); item.className='post';
const author=document.createElement('strong'); author.textContent='Вы · сейчас';
const body=document.createElement('p'); body.textContent=text; item.append(author,body);
s.phone.querySelector('.view').append(item);
}
s.phone.querySelector('.view').scrollTop = s.scrolls[s.screen] || 0;
s.renderedScreen = s.screen;
if (s.editor) mountEditor(s);
}
function keyboard() {
return `<div class="keyboard" aria-hidden="true"><div class="keyboard-label">Макет клавиатуры · не интерактивный</div>${['йцукенгшщзх','фывапролджэ','ячсмитьбю'].map(row=>`<div class="keys">${[...row].map(c=>`<span>${c}</span>`).join('')}</div>`).join('')}<div class="keys"><span class="wide">123</span><span class="space">Русский</span><span class="wide">↵</span></div></div>`;
}
function mountEditor(s) {
const mode = s.editor;
const full = s.id==='mono'||s.id==='club'||s.expanded;
const overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.innerHTML = `<section class="sheet ${full?'full':''}" role="dialog" aria-modal="true" aria-label="${mode==='post'?'Новое сообщение':'Ответ на сообщение'}" tabindex="-1">${full?'':'<div class="sheet-handle"></div>'}<div class="sheet-header">${button('close-editor','Закрыть и сохранить черновик','close')}<h4>${mode==='post'?'Новое сообщение':'Ответ'}</h4>${button('expand','Развернуть или свернуть редактор','expand','aria-pressed="'+full+'"')}</div><div class="editor-scroll">${mode==='post'?'':`<div class="source-preview"><span class="avatar city">${s.replyTo===s.channel.owner?'АК':escapeText(s.replyTo.slice(0,2).toUpperCase())}</span><div><strong>${escapeText(s.replyTo)}</strong><small> · ${escapeText(s.channel.title)}</small><p>${escapeText(s.replyText)}</p></div></div>`}<div class="editor-row"><span class="avatar self">ВЫ</span><div class="editor-main"><strong>Вы</strong><small>${mode==='post'?'Публикация в «'+escapeText(s.channel.title)+'»':'Ответ '+escapeText(s.replyTo)+' · виден в обсуждении'}</small><textarea class="editor-input" maxlength="2000" aria-label="Текст ${mode==='post'?'сообщения':'ответа'}" placeholder="${mode==='post'?'О чём хотите рассказать?':'Продолжите разговор…'}">${escapeText(s.drafts[mode])}</textarea><div class="selected-attachment">${attachmentMarkup(s)}</div></div></div></div><div class="editor-footer">${button('attach','Прикрепить файл','attach')}<input type="file" hidden data-file><span class="char-count">${s.drafts[mode].length} / 2000</span><button class="submit" data-action="send" ${!s.drafts[mode].trim()&&!s.attachments[mode]?'disabled':''}>${mode==='post'?'Опубликовать':'Ответить'}</button></div>${showKeyboard?keyboard():''}</section>`;
s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=true);
s.phone.append(overlay);
overlay.querySelector('.sheet').focus({preventScroll:true});
}
function attachmentMarkup(s) {
const name = s.attachments[s.editor];
return name?`<div class="editor-attachment">${icon('file')}<span>${escapeText(name)}</span>${button('remove-attachment','Удалить вложение','close')}</div>`:'';
}
function removeOverlay(s) {
s.phone.querySelector('.overlay')?.remove();
s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=false);
}
function closeEditor(s) {
s.editor = null;
removeOverlay(s);
if (s.returnFocus?.isConnected) s.returnFocus.focus({preventScroll:true});
else s.phone.querySelector('[data-action="reply"], [data-action="new-post"], [data-action="back"]')?.focus({preventScroll:true});
}
function openEditor(s, mode='reply', author=s.channel.owner, text=s.activePost==='second'?secondText:mainText, trigger=null) {
if (s.editor) closeEditor(s);
s.editor=mode;
s.replyTo=author;
s.replyText=text;
s.returnFocus=trigger;
s.expanded=false;
mountEditor(s);
}
function toast(s, message) {
s.phone.querySelector('.toast')?.remove();
clearTimeout(s.toastTimer);
const el=document.createElement('div');
el.className='toast'; el.setAttribute('role','status'); el.textContent=message;
s.phone.append(el);
s.toastTimer=setTimeout(()=>el.remove(),3500);
}
function menu(s, title, contents, trigger) {
removeOverlay(s);
s.returnFocus=trigger;
const el=document.createElement('div'); el.className='overlay';
el.innerHTML=`<section class="sheet menu-sheet" role="dialog" aria-modal="true" aria-label="${escapeText(title)}" tabindex="-1"><div class="sheet-handle"></div><div class="sheet-header">${button('close-menu','Закрыть','close')}<h4>${escapeText(title)}</h4><span style="width:44px"></span></div><div class="menu-content">${contents}</div></section>`;
s.phone.querySelectorAll(':scope > *').forEach(child=>child.inert=true);
s.phone.append(el); el.querySelector('.sheet').focus({preventScroll:true});
}
const menuAction=(action,label,i='file')=>`<button class="menu-action" data-action="${action}">${icon(i)}${label}</button>`;
function updateEditor(s) {
const modal=s.phone.querySelector('.overlay');
modal.querySelector('.char-count').textContent=`${s.drafts[s.editor].length} / 2000`;
modal.querySelector('[data-action="send"]').disabled=!s.drafts[s.editor].trim()&&!s.attachments[s.editor];
modal.querySelector('.selected-attachment').innerHTML=attachmentMarkup(s);
}
function handle(s, event) {
const el=event.target.closest('[data-action]');
if (!el || el.disabled) return;
const action=el.dataset.action;
if (action==='filter') { s.filter=el.dataset.value; render(s); }
else if (action==='open-channel') { s.channel=channels[Number(el.dataset.index)]; s.screen='feed'; s.activePost='first'; s.replies=[]; s.posts=[]; s.likes.clear(); render(s); }
else if (action==='list'||action==='back') { s.screen=action==='list'||s.screen==='feed'?'list':'feed'; render(s); }
else if (action==='thread') { s.activePost=el.dataset.post||s.activePost; s.screen='thread'; render(s); }
else if (action==='reply'||action==='new-post') { s.activePost=el.dataset.post||s.activePost; openEditor(s,action==='new-post'?'post':'reply',s.channel.owner,s.activePost==='second'?secondText:mainText,el); }
else if (action==='reply-to') openEditor(s,'reply',el.dataset.author,el.dataset.text,el);
else if (action==='close-editor') { closeEditor(s); toast(s,'Черновик сохранён до закрытия страницы'); }
else if (action==='expand') { s.expanded=!s.expanded; removeOverlay(s); mountEditor(s); }
else if (action==='attach') s.phone.querySelector('[data-file]').click();
else if (action==='remove-attachment') { s.attachments[s.editor]=''; updateEditor(s); }
else if (action==='send') {
const mode=s.editor;
const text=s.drafts[mode].trim();
const attachment=s.attachments[mode];
if (!text&&!attachment) return;
if (mode==='reply') s.replies.push({text:text+(attachment?'\n📎 '+attachment:''),target:s.replyTo,post:s.activePost});
else s.posts.push(text+(attachment?'\n📎 '+attachment:''));
s.drafts[mode]=''; s.attachments[mode]=''; s.editor=null; s.screen=mode==='reply'?'thread':'feed'; render(s);
const view=s.phone.querySelector('.view'); view.scrollTop=view.scrollHeight;
s.phone.querySelector('.composer-trigger')?.focus({preventScroll:true}); toast(s,'Отправлено в макете. Данные на сервер не передаются.');
}
else if (action==='like') {
const key=el.dataset.post; const liked=s.likes.has(key); liked?s.likes.delete(key):s.likes.add(key);
el.setAttribute('aria-pressed',String(!liked)); el.querySelector('span').textContent=String(Number(el.querySelector('span').textContent)+(liked?-1:1));
}
else if (action==='share') menu(s,'Поделиться', '<p>В приложении здесь появится системное меню отправки ссылки. В макете внешние ссылки не создаются.</p>'+menuAction('close-menu','Понятно','check'),el);
else if (action==='subscribe') { s.subscribed=!s.subscribed; el.textContent=s.subscribed?'Вы подписаны':'Подписаться'; }
else if (action==='notifications') { s.muted=!s.muted; el.setAttribute('aria-label',s.muted?'Включить уведомления':'Настроить уведомления'); toast(s,s.muted?'Уведомления канала выключены в макете':'Уведомления канала включены в макете'); }
else if (action==='post-menu') menu(s,'Сообщение',menuAction('reactions','Кто поставил лайк','heart')+menuAction('details','Данные записи','file')+(role==='owner'?menuAction('edit-demo','Редактировать','edit'):'')+'<p>Редкие действия собраны здесь, чтобы оставлять больше места самому сообщению.</p>',el);
else if (action==='channel-menu') menu(s,s.channel.title,menuAction('about','О канале','channels')+menuAction('contents','Оглавление','book')+menuAction('notifications','Уведомления','bell'),el);
else if (action==='contents') menu(s,'Оглавление','<p>С чего начать знакомство с каналом</p>'+menuAction('jump-post','01 · Маленькие открытия','book')+menuAction('jump-file','02 · Наш список любимых мест','file'),el);
else if (action==='jump-post'||action==='jump-file') { removeOverlay(s); s.screen='feed'; render(s); s.phone.querySelectorAll('.post')[action==='jump-file'?1:0]?.scrollIntoView({block:'nearest'}); }
else if (action==='reactions') menu(s,'Лайки','<p>Все: 24 · основные: 18 · сияющие: 6</p><p>Названия категорий сохранены из текущего приложения. Их объяснение пользователю нужно согласовать отдельно.</p>',el);
else if (action==='details') menu(s,'Данные записи','<p>Здесь доступны номер записи, цепочка автора, хэш и подпись. В мокапе технические данные не генерируются.</p>',el);
else if (action==='about') menu(s,'О канале','<p>Замечаем красоту рядом. Делимся местами, историями и маленькими открытиями.</p><p>Автор: @'+escapeText(s.channel.owner)+'</p>',el);
else if (action==='close-menu') { removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); }
else if (action==='new-channel') toast(s,'Создание канала — следующий этап проектирования');
else if (action==='edit-demo') toast(s,'Редактор существующего сообщения — следующий этап');
else if (action==='outside') toast(s,'Сейчас проектируем каналы. Этот раздел пока вне макета.');
}
function attachEvents(s) {
s.phone.addEventListener('click',event=>handle(s,event));
s.phone.addEventListener('input',event=>{
if(event.target.matches('[data-search]')) { s.query=event.target.value; s.phone.querySelector('.channel-rows').innerHTML=rows(s); }
if(event.target.matches('.editor-input')) { s.drafts[s.editor]=event.target.value; updateEditor(s); }
});
s.phone.addEventListener('change',event=>{
if(event.target.matches('[data-file]')) { s.attachments[s.editor]=event.target.files[0]?.name||''; updateEditor(s); }
});
s.phone.addEventListener('keydown',event=>{
const dialog=s.phone.querySelector('[role="dialog"]');
if(!dialog) return;
if(event.key==='Escape') { event.preventDefault(); s.editor?closeEditor(s):removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); }
if(event.key==='Tab') {
const focusable=[...dialog.querySelectorAll('button:not(:disabled), textarea, input:not([hidden])')];
const first=focusable[0], last=focusable.at(-1);
if(event.shiftKey&&(document.activeElement===first||document.activeElement===dialog)) { event.preventDefault(); last?.focus(); }
else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===dialog)) { event.preventDefault(); first?.focus(); }
}
});
}
function init() {
const query=new URLSearchParams(location.search);
const chosen=concepts.find(c=>c.id===query.get('variant'));
const gallery=document.getElementById('gallery');
if(chosen) {gallery.classList.add('single');document.body.classList.add('focused');}
for(const c of chosen?[chosen]:concepts) {
const article=document.createElement('section');article.className='concept';article.setAttribute('aria-label',c.title);
article.innerHTML=`<div class="concept-heading"><span class="concept-number">${c.number}</span><h2>${c.title}</h2><a class="open-concept" href="?variant=${c.id}" aria-label="Открыть отдельно: ${c.title}">↗</a></div><p class="concept-description">${c.description}</p><div class="phone" data-theme="${c.id}" aria-label="Макет: ${c.title}"></div><div class="concept-notes"><div class="swatches" aria-hidden="true">${c.colors.map(color=>`<span style="background:${color}"></span>`).join('')}</div><span class="concept-tag">${c.tag}</span><div>${c.note}</div></div>`;
gallery.append(article);
const s={id:c.id,phone:article.querySelector('.phone'),screen:'list',activePost:'first',channel:channels[0],query:'',filter:'all',likes:new Set(),subscribed:true,muted:false,replies:[],posts:[],drafts:{reply:'',post:''},attachments:{reply:'',post:''},editor:null};
states.set(c.id,s);render(s);attachEvents(s);
}
document.querySelectorAll('[data-screen]').forEach(el=>el.addEventListener('click',()=>{
document.querySelectorAll('[data-screen]').forEach(b=>{b.classList.toggle('selected',b===el);b.setAttribute('aria-pressed',String(b===el));});
for(const s of states.values()) { s.editor=null;s.screen=el.dataset.screen==='reply'?'thread':el.dataset.screen;render(s);if(el.dataset.screen==='reply')openEditor(s); }
el.focus({preventScroll:true});
}));
document.getElementById('role').addEventListener('change',event=>{role=event.target.value;for(const s of states.values())render(s);});
document.getElementById('keyboard').addEventListener('change',event=>{showKeyboard=event.target.checked;for(const s of states.values())if(s.editor){removeOverlay(s);mountEditor(s);}event.target.focus({preventScroll:true});});
}
init();
+314
View File
@@ -0,0 +1,314 @@
// Запуск: SHINE_UI_TEST_DEPS=/путь/к/node_modules node --experimental-vm-modules shine-UI/channel-design-check.mjs
// Зависимости проверки (не приложения): jsdom, postcss.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
const require = createRequire(path.join(process.env.SHINE_UI_TEST_DEPS || process.cwd() + '/node_modules', '_check.cjs'));
const { JSDOM } = require('jsdom');
const postcss = require('postcss');
const ui = path.dirname(fileURLToPath(import.meta.url));
const dom = new JSDOM('<button id="opener">Ответить</button><main id="app-screen"></main><div id="modal-root"></div>', { url: 'https://shine.test/#channel', pretendToBeVisual: true, runScripts: 'outside-only' });
const context = dom.getInternalVMContext();
const { window } = dom;
const { document } = window;
const state = { session: { login: 'alice', isAuthorized: true }, entrySettings: {} };
let placed = 0;
let authPrompts = 0;
const mocks = {
'arweave-attachment-manager.js': { markArweaveAttachmentPlaced: () => placed++, openArweaveAttachmentManager: async () => ({ name: 'photo.jpg', size: 1024, ar: 'test' }) },
'attachment-format.js': { MAX_MESSAGE_ATTACHMENTS: 10, composeMessageWithAttachments: (text) => text },
'state.js': { state },
'ui-error-texts.js': { toUserMessage: (error) => error.message },
'avatar-image.js': { renderUserAvatar: () => document.createElement('span') },
'auth-required-modal.js': { openAuthRequiredModal: () => authPrompts++ },
};
async function moduleAt(relative, mockImports = false) {
const module = new vm.SourceTextModule(fs.readFileSync(path.join(ui, relative), 'utf8'), { context });
await module.link(async (specifier) => {
const values = mockImports && mocks[path.basename(specifier)];
assert.ok(values, `Неожиданный импорт ${specifier}`);
return new vm.SyntheticModule(Object.keys(values), function () {
for (const [key, value] of Object.entries(values)) this.setExport(key, value);
}, { context });
});
await module.evaluate();
return module.namespace;
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 35));
const query = (selector) => document.querySelector(selector);
function input(value) {
const field = query('textarea');
field.value = value;
field.dispatchEvent(new window.Event('input', { bubbles: true }));
}
function key(key, options = {}) {
const event = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options });
document.activeElement.dispatchEvent(event);
return event;
}
const { openChannelEditor } = await moduleAt('js/components/channel-editor.js', true);
let sent = [];
const options = { key: 'channel:one:message:1', onSubmit: async (value) => sent.push(value) };
query('#opener').focus();
let editor = openChannelEditor(options);
await tick();
assert.equal(document.activeElement.tagName, 'TEXTAREA');
assert.equal(query('.channel-editor__submit').disabled, true);
input('Первая строка\nВторая строка');
assert.equal(key('Enter').defaultPrevented, false);
assert.equal(sent.length, 0);
editor.close();
await tick();
assert.equal(document.activeElement.id, 'opener');
editor = openChannelEditor(options);
await tick();
assert.equal(query('textarea').value, 'Первая строка\nВторая строка');
key('Enter', { ctrlKey: true, isComposing: true });
assert.equal(sent.length, 0);
key('Enter', { ctrlKey: true });
await tick();
assert.equal(sent.length, 1);
assert.equal(query('.channel-editor-overlay'), null);
editor = openChannelEditor(options);
assert.equal(query('textarea').value, '');
editor.close();
await tick();
editor = openChannelEditor({ ...options, onSubmit: async () => { throw new Error('Нет соединения'); } });
await tick();
input('Не потерять');
query('.channel-editor__submit').click();
await tick();
assert.equal(query('[role="alert"]').textContent, 'Нет соединения');
assert.equal(query('textarea').value, 'Не потерять');
assert.equal(query('.channel-editor__submit').disabled, false);
query('.channel-editor__close').focus();
key('Tab', { shiftKey: true });
assert.equal(document.activeElement, query('.channel-editor__submit'));
editor.close();
await tick();
state.session.login = 'bob';
editor = openChannelEditor(options);
assert.equal(query('textarea').value, '');
editor.close();
await tick();
state.session.login = 'alice';
editor = openChannelEditor(options);
assert.equal(query('textarea').value, 'Не потерять');
query('.channel-editor__clear').click();
assert.equal(query('textarea').value, '');
query('.channel-editor__attach').click();
await tick();
assert.equal(document.querySelectorAll('.channel-editor__attachment').length, 1);
assert.equal(query('.channel-editor__submit').disabled, false);
query('.channel-editor__submit').click();
await tick();
assert.equal(placed, 1);
editor = openChannelEditor(options);
await tick();
window.history.back();
await tick();
assert.equal(query('.channel-editor-overlay'), null);
editor.close();
state.session.isAuthorized = false;
assert.equal(openChannelEditor(options), null);
assert.equal(authPrompts, 1);
const theme = await moduleAt('js/services/theme-service.js');
assert.equal(theme.applyThemeMode('system').resolved, 'dark');
theme.setThemeMode('light');
assert.equal(document.documentElement.dataset.theme, 'light');
theme.setThemeMode('dark');
assert.equal(document.documentElement.dataset.theme, 'dark');
const scroll = await moduleAt('js/services/channel-view-state.js');
query('#app-screen').scrollTop = 123;
scroll.rememberChannelPosition('alice:channel:one');
query('#app-screen').scrollTop = 0;
scroll.restoreChannelPosition(scroll.readChannelPosition('alice:channel:one'));
assert.equal(query('#app-screen').scrollTop, 123);
assert.equal(scroll.readChannelPosition('bob:channel:one'), undefined);
const { createDropdownMenu } = await moduleAt('js/components/dropdown-menu.js');
const menu = createDropdownMenu({ anchorEl: query('#opener'), items: [{ label: 'Первый' }, { label: 'Второй' }] });
menu.open();
assert.equal(document.activeElement.textContent, 'Первый');
key('ArrowDown');
assert.equal(document.activeElement.textContent, 'Второй');
key('Escape');
assert.equal(document.activeElement.id, 'opener');
menu.destroy();
menu.destroy();
assert.equal(query('.dropdown-portal'), null);
console.log('PASS: редактор — клавиши, фокус, черновики, аккаунты, ошибка, вложение, отправка, Назад; темы, позиция чтения, меню.');
// DOM producer страниц исполняется с изолированным API: тест не пишет в блокчейн.
let replyOptions;
const reactionState = new Map();
const iconModule = await moduleAt('js/components/ui-icon.js');
const pageValues = {
state, authService: {},
channels: [],
readChannelNotificationsState: () => ({}),
createSkeletonCard: () => document.createElement('div'),
createTopBar: ({ center }) => {
const header = document.createElement('header');
if (center) header.append(center);
return header;
},
iconHtml: iconModule.iconHtml,
parseMessageAttachments: (text) => ({ text: text || '', attachments: [] }),
parseDmTechBlocks: (text) => ({ displayText: text, visibleText: text }),
loadProfileSnapshot: async () => null,
renderAvatar: () => document.createElement('span'),
renderUserAvatar: () => document.createElement('span'),
formatRelativeTime: () => 'сейчас',
escapeHtml: (text) => String(text || ''),
openChannelEditor: (options) => { replyOptions = options; },
getMessageReactionState: (target) => reactionState.get(target.blockHash) || 'unliked',
setMessageReactionState: (target, value) => reactionState.set(target.blockHash, value),
makeShineMessageRoute: () => 'thread:test',
attachMessageMenu: (card, head, items) => {
const button = document.createElement('button');
head.append(button);
card.testMenu = items;
const menu = createDropdownMenu({ anchorEl: button, items });
card.cleanup = () => menu.destroy();
},
};
async function pageProducer(file, exported) {
const source = fs.readFileSync(path.join(ui, 'js/pages', file), 'utf8');
const imports = new Map();
for (const match of source.matchAll(/import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) {
imports.set(match[2], [...new Set([...(imports.get(match[2]) || []), ...match[1].split(',').map((name) => name.trim().split(/\s+as\s+/)[0]).filter(Boolean)])]);
}
const module = new vm.SourceTextModule(source + (exported === 'render' ? '' : `\nexport { ${exported} };`), { context });
await module.link(async (specifier) => {
const names = imports.get(specifier);
assert.ok(names, specifier);
return new vm.SyntheticModule(names, function () {
names.forEach((name) => this.setExport(name, pageValues[name] || (() => {})));
}, { context });
});
await module.evaluate();
return module.namespace[exported];
}
state.session.isAuthorized = true;
state.session.login = 'alice';
const ref = { blockchainName: 'alice-1', blockNumber: 2, blockHash: 'a'.repeat(64) };
let likes = 0;
let navigations = 0;
const handlers = { selector: { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, navigate: () => navigations++, onToggleLike: async () => likes++, onReply: async () => {}, onEdit: async () => {}, isActive: () => true };
const renderPost = await pageProducer('channel-view.js', 'renderPostCard');
const post = renderPost({ body: 'Публикация', authorLogin: 'alice', localNumber: 1, messageRef: ref, isOwnMessage: true, msgSubType: 10, likesCount: 7, repliesCount: 2 }, handlers);
document.body.append(post);
assert.equal(post.querySelector('.channel-message-body').textContent, 'Публикация');
assert.equal(post.querySelectorAll('.channel-action-counter').length, 3);
assert.ok(post.testMenu.some((item) => item.label === 'Редактировать'));
assert.ok(post.testMenu.some((item) => item.label === 'Удалить'));
post.querySelector('.channel-action-like').click();
await tick();
assert.equal(likes, 1);
assert.equal(post.querySelector('.channel-action-like').disabled, false);
post.querySelector('.channel-action-reply').click();
assert.equal(replyOptions.context.author, 'alice');
assert.equal(replyOptions.context.text, 'Публикация');
assert.equal(navigations, 0);
post.cleanup(); post.cleanup(); post.remove();
const renderNode = await pageProducer('channel-thread-view.js', 'renderNodeCard');
const node = renderNode({ authorBlockchainName: ref.blockchainName, messageRef: ref, authorLogin: 'bob', text: 'Ответ', msgSubType: 10, likesCount: 3 }, '', handlers, 2);
document.body.append(node);
assert.equal(node.querySelector('.channel-message-body').textContent, 'Ответ');
assert.ok(!node.testMenu.some((item) => item.label === 'Удалить'));
node.querySelector('.thread-reply-btn').click();
assert.equal(replyOptions.context.author, 'bob');
node.cleanup(); node.cleanup(); node.remove();
console.log('PASS: карточки канала/ветки — текст, общий лайк без диалога, контекст ответа, меню по авторству, cleanup.');
pageValues.authService.onEvent = () => () => {};
pageValues.toUserMessage = (error) => error.message;
pageValues.rememberChannelPosition = scroll.rememberChannelPosition;
pageValues.readChannelPosition = scroll.readChannelPosition;
pageValues.restoreChannelPosition = scroll.restoreChannelPosition;
state.channelsFeed = {};
state.channelIndex = {};
const chrome = { setTopbar() {}, setComposer() {} };
for (const [file, method, params, payload] of [
['channels-list.js', 'listSubscriptionsFeed', {}, { ownedChannels: [], followedUsersChannels: [], followedChannels: [] }],
['channel-view.js', 'getChannelMessages', { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, { channel: { ownerLogin: 'alice', channelName: 'news' }, messages: [] }],
['channel-thread-view.js', 'getMessageThread', { messageBlockchainName: 'alice-1', messageBlockNumber: 2, messageBlockHash: ref.blockHash }, { focus: null, descendants: [], ancestors: [] }],
]) {
let finish;
pageValues.authService[method] = () => new Promise((resolve) => { finish = resolve; });
const render = await pageProducer(file, 'render');
const screen = render({ route: { params }, navigate() {}, chrome });
query('#app-screen').append(screen);
await tick();
assert.ok(finish, `${file}: запрос начат`);
screen.cleanup();
screen.cleanup();
const markup = screen.innerHTML;
finish(payload);
await tick();
assert.equal(screen.innerHTML, markup, `${file}: async после dispose`);
assert.equal(query('#app-screen').firstElementChild, screen, `${file}: root identity`);
screen.remove();
pageValues.authService[method] = async () => payload;
const loaded = render({ route: { params }, navigate() {}, chrome });
query('#app-screen').append(loaded);
await tick();
const expected = file === 'channels-list.js' ? '.channels-empty-state' : file === 'channel-view.js' ? '.channel-feed' : '.thread-block';
assert.ok(loaded.querySelector(expected), `${file}: успешная загрузка ${loaded.textContent}`);
if (loaded.refresh) await loaded.refresh();
assert.equal(query('#app-screen').firstElementChild, loaded, `${file}: refresh сохраняет root`);
loaded.cleanup(); loaded.remove();
}
console.log('PASS: список/канал/ветка — стабильный root, идемпотентный cleanup, поздний API-ответ после dispose.');
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)]);
}
const scripts = walk(path.join(ui, 'js')).filter((file) => file.endsWith('.js'));
for (const file of scripts) {
const source = fs.readFileSync(file, 'utf8');
for (const match of source.matchAll(/(?:from\s*|import\s*\()(['"])(\.[^'"]+)\1/g)) {
assert.ok(fs.existsSync(path.resolve(path.dirname(file), match[2].split(/[?#]/)[0])), `${file}: ${match[2]}`);
}
}
const repo = path.dirname(ui);
const changed = execFileSync('git', ['ls-files', '--modified', '--others', '--exclude-standard'], { cwd: repo, encoding: 'utf8' }).trim().split('\n');
let cssCount = 0;
for (const relative of changed) {
const file = path.join(repo, relative);
if (relative.endsWith('.js')) execFileSync('node', ['--input-type=module', '--check'], { input: fs.readFileSync(file) });
if (!relative.startsWith('shine-UI/styles/') || !relative.endsWith('.css')) continue;
cssCount++;
const source = fs.readFileSync(file, 'utf8');
const tree = postcss.parse(source, { from: file });
let baseline = ''; try { baseline = execFileSync('git', ['show', `HEAD:${relative}`], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch {}
const baselineRules = new Map();
postcss.parse(baseline).walkRules((rule) => baselineRules.set(rule.selector, (baselineRules.get(rule.selector) || 0) + 1));
const rules = new Map();
const duplicates = [];
tree.walkRules((rule) => {
const parents = []; for (let node = rule.parent; node; node = node.parent) if (node.type === 'atrule') parents.push(node.name + ':' + node.params);
const key = parents.join('/') + ':' + rule.selector;
const count = (rules.get(key) || 0) + 1;
rules.set(key, count);
if (count > Math.max(1, baselineRules.get(rule.selector) || 0)) duplicates.push(key);
});
assert.deepEqual(duplicates, [], `Новые повторы selectors: ${relative}`);
assert.ok((source.match(/!important/g) || []).length <= (baseline.match(/!important/g) || []).length, `Вырос !important: ${relative}`);
}
const html = fs.readFileSync(path.join(ui, 'index.html'), 'utf8');
const cssManifest = [...html.matchAll(/['"]\.\/(styles\/[^'"]+\.css)['"]/g)].map((match) => match[1]);
assert.equal(new Set(cssManifest).size, cssManifest.length);
for (const file of cssManifest) assert.ok(fs.existsSync(path.join(ui, file)), `CSS manifest: ${file}`);
assert.ok(cssManifest.includes('styles/components/channel-editor.css'));
console.log(`PASS: импорты ${scripts.length} JS; синтаксис изменённых JS; ${cssCount} CSS — parser, дубликаты selectors, !important; CSS manifest.`);
dom.window.close();
+29 -2
View File
@@ -4,21 +4,48 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-visual" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual"
/> />
<meta name="theme-color" content="#12141f" />
<base href="/" /> <base href="/" />
<link rel="manifest" href="./manifest.webmanifest" /> <link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" /> <link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
<link rel="apple-touch-icon" href="./img/logo.jpg" /> <link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title> <title>СИЯНИЕ</title>
<script> <script>
(function applySavedThemeBeforePaint() {
let mode = 'system';
try {
const saved = localStorage.getItem('shine-ui-theme-mode-v1');
if (saved === 'light' || saved === 'dark' || saved === 'system') mode = saved;
} catch {}
const resolved = mode === 'system'
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
: mode;
document.documentElement.dataset.themeMode = mode;
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
// Своя палитра (см. js/services/theme-service.js) применяется до отрисовки, без вспышки.
try {
const applied = JSON.parse(localStorage.getItem('shine-ui-palette-applied-v1') || 'null');
const colors = applied && applied[resolved];
if (colors) {
Object.keys(colors).forEach(function (role) {
if (/^#[0-9a-f]{6}$/i.test(colors[role])) document.documentElement.style.setProperty('--' + role, colors[role]);
});
if (colors.accent) document.documentElement.style.setProperty('--focus-ring', colors.accent);
}
} catch {}
}());
</script>
<script>
window.__SHINE_BUILD_HASH__ = '20260901134200'; window.__SHINE_BUILD_HASH__ = '20260901134200';
window.__SHINE_CLIENT_VERSION__ = '1.2.10'; window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script> </script>
<script> <script>
(function attachStylesWithBuildHash() { (function attachStylesWithBuildHash() {
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev'); const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css']; const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/chips.css', './styles/components/nav-list.css', './styles/components/palette-editor.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/channel-editor.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/profile-page.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
cssFiles.forEach((file) => { cssFiles.forEach((file) => {
const link = document.createElement('link'); const link = document.createElement('link');
link.rel = 'stylesheet'; link.rel = 'stylesheet';
+24 -18
View File
@@ -52,37 +52,38 @@ import {
setContacts, setContacts,
} from './state.js'; } from './state.js';
import * as startView from './pages/start-view.js?v=202606142105'; import * as startView from './pages/start-view.js?v=202609260900';
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240'; import * as entrySettingsView from './pages/entry-settings-view.js?v=202609260900';
import * as registerView from './pages/register-view.js?v=202606201650'; import * as registerView from './pages/register-view.js?v=202609260900';
import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650'; import * as registrationFaqView from './pages/registration-faq-view.js?v=202609260900';
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940'; import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
import * as registrationKeysView from './pages/registration-keys-view.js'; import * as registrationKeysView from './pages/registration-keys-view.js';
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js'; import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
import * as topupView from './pages/topup-view.js'; import * as topupView from './pages/topup-view.js';
import * as devnetTopupView from './pages/devnet-topup-view.js'; import * as devnetTopupView from './pages/devnet-topup-view.js';
import * as loginView from './pages/login-view.js?v=202606150110'; import * as loginView from './pages/login-view.js?v=202609260900';
import * as loginCameraView from './pages/login-camera-view.js'; import * as loginCameraView from './pages/login-camera-view.js';
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940'; import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650'; import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
import * as keyStorageView from './pages/key-storage-view.js'; import * as keyStorageView from './pages/key-storage-view.js';
import * as publicSupportQueueView from './pages/public-support-queue-view.js'; import * as publicSupportQueueView from './pages/public-support-queue-view.js';
import * as profileView from './pages/profile-view.js?v=202607150910'; import * as profileView from './pages/profile-view.js?v=202609260900';
import * as profileEditView from './pages/profile-edit-view.js'; import * as profileEditView from './pages/profile-edit-view.js';
import * as profilesView from './pages/profiles-view.js'; import * as profilesView from './pages/profiles-view.js';
import * as walletView from './pages/wallet-view.js?v=202606281930'; import * as walletView from './pages/wallet-view.js?v=202609260900';
import * as settingsView from './pages/settings-view.js'; import * as settingsView from './pages/settings-view.js';
import * as accessServersView from './pages/access-servers-view.js'; import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js';
import * as advancedSettingsView from './pages/advanced-settings-view.js'; import * as advancedSettingsView from './pages/advanced-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240'; import * as serverSettingsView from './pages/server-settings-view.js?v=202609260900';
import * as arweaveUploadsView from './pages/arweave-uploads-view.js'; import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300'; import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
import * as deviceView from './pages/device-view.js?v=202606131435'; import * as deviceView from './pages/device-view.js?v=202609260900';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940'; import * as clientPairingView from './pages/device-pairing-view.js?v=202609260900';
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930'; import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202609260900';
import { applyThemeMode, watchSystemTheme } from './services/theme-service.js';
import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceQrView from './pages/device-qr-view.js';
import * as deviceCameraView from './pages/device-camera-view.js'; import * as deviceCameraView from './pages/device-camera-view.js';
import * as showKeysView from './pages/show-keys-view.js'; import * as showKeysView from './pages/show-keys-view.js';
@@ -92,21 +93,21 @@ import * as appLogView from './pages/app-log-view.js';
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js'; import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
import * as solanaUsersInitView from './pages/solana-users-init-view.js'; import * as solanaUsersInitView from './pages/solana-users-init-view.js';
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js'; import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
import * as messagesList from './pages/messages-list.js?v=202608221218'; import * as messagesList from './pages/messages-list.js?v=202609260900';
import * as contactSearchView from './pages/contact-search-view.js'; import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js?v=202608221218'; import * as chatView from './pages/chat-view.js?v=202609260900';
import * as userProfileView from './pages/user-profile-view.js'; import * as userProfileView from './pages/user-profile-view.js';
import * as userProfileListView from './pages/user-profile-list-view.js'; import * as userProfileListView from './pages/user-profile-list-view.js';
import * as userRelationManageView from './pages/user-relation-manage-view.js'; import * as userRelationManageView from './pages/user-relation-manage-view.js';
import * as channelsList from './pages/channels-list.js?v=202608221218'; import * as channelsList from './pages/channels-list.js?v=202609260900';
import * as channelView from './pages/channel-view.js'; import * as channelView from './pages/channel-view.js';
import * as channelAboutView from './pages/channel-about-view.js'; import * as channelAboutView from './pages/channel-about-view.js';
import * as channelDonateView from './pages/channel-donate-view.js'; import * as channelDonateView from './pages/channel-donate-view.js';
import * as channelThreadView from './pages/channel-thread-view.js'; import * as channelThreadView from './pages/channel-thread-view.js';
import * as addChannelView from './pages/add-channel-view.js'; import * as addChannelView from './pages/add-channel-view.js';
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js'; import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
import * as networkView from './pages/network-view.js?v=202608221226'; import * as networkView from './pages/network-view.js?v=202609260900';
import * as notificationsView from './pages/notifications-view.js?v=202608221354'; import * as notificationsView from './pages/notifications-view.js?v=202609260900';
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается'; const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
@@ -404,6 +405,7 @@ if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
} }
const MANAGED_SHELL_CLASSES = [ const MANAGED_SHELL_CLASSES = [
'app-shell--wide',
'app-shell--top-fade', 'app-shell--top-fade',
'app-shell--bottom-fade', 'app-shell--bottom-fade',
'app-shell--bottom-fade-composer', 'app-shell--bottom-fade-composer',
@@ -455,6 +457,7 @@ function normalizeShellMode(mode = {}, showAppChrome = true) {
function applyShellMode(mode, showAppChrome = true) { function applyShellMode(mode, showAppChrome = true) {
if (!appShellEl) return normalizeShellMode(mode, showAppChrome); if (!appShellEl) return normalizeShellMode(mode, showAppChrome);
const normalized = normalizeShellMode(mode, showAppChrome); const normalized = normalizeShellMode(mode, showAppChrome);
appShellEl.classList.toggle('app-shell--wide', normalized.contentWidth === 'wide');
appShellEl.classList.toggle('app-shell--top-fade', Boolean(normalized.topFade)); appShellEl.classList.toggle('app-shell--top-fade', Boolean(normalized.topFade));
appShellEl.classList.toggle('app-shell--bottom-fade', Boolean(normalized.bottomFade)); appShellEl.classList.toggle('app-shell--bottom-fade', Boolean(normalized.bottomFade));
appShellEl.classList.toggle('app-shell--bottom-fade-composer', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'composer'); appShellEl.classList.toggle('app-shell--bottom-fade-composer', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'composer');
@@ -1414,7 +1417,8 @@ function renderApp() {
try { try {
const chrome = createChromeController(showAppChrome, page.pageMeta?.shellMode); const chrome = createChromeController(showAppChrome, page.pageMeta?.shellMode);
currentChromeCleanup = () => chrome.dispose(); currentChromeCleanup = () => chrome.dispose();
if (showAppChrome) { // В переписке нижняя панель прячется (pageMeta.hideToolbar), чтобы не отнимать место у сообщений.
if (showAppChrome && !page.pageMeta?.hideToolbar) {
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate)); mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
toolbarHeightObserver?.sync?.(); toolbarHeightObserver?.sync?.();
} }
@@ -1446,7 +1450,7 @@ function refreshToolbarOnly() {
&& !(pageId === 'language-view' && !state.session.isAuthorized); && !(pageId === 'language-view' && !state.session.isAuthorized);
clearSlot(toolbarEl, '--toolbar-height'); clearSlot(toolbarEl, '--toolbar-height');
if (showAppChrome) { if (showAppChrome && !page.pageMeta?.hideToolbar) {
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate)); mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
} }
toolbarHeightObserver?.sync?.(); toolbarHeightObserver?.sync?.();
@@ -1532,6 +1536,8 @@ async function ensureSessionRuntimeStarted() {
} }
async function init() { async function init() {
applyThemeMode();
watchSystemTheme();
consumeCallPushActionFromUrlIfAny(); consumeCallPushActionFromUrlIfAny();
const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny(); const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny();
void tryLockPortraitOrientation(); void tryLockPortraitOrientation();
@@ -300,6 +300,7 @@ export function openArweaveAttachmentManager({
autoOpenFileDialog = true, autoOpenFileDialog = true,
shineType = '', shineType = '',
extraUploadTags = [], extraUploadTags = [],
signal = null,
} = {}) { } = {}) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim(); const cleanStoragePwd = String(storagePwd || '').trim();
@@ -359,6 +360,7 @@ export function openArweaveAttachmentManager({
} }
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) { function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
if (closed) return;
const item = persistToHistory const item = persistToHistory
? addArweaveAttachmentToHistory(cleanLogin, attachment, { ? addArweaveAttachmentToHistory(cleanLogin, attachment, {
pendingPlacement, pendingPlacement,
@@ -373,6 +375,9 @@ export function openArweaveAttachmentManager({
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const onAbort = () => close(resolve, null);
if (signal?.aborted) { onAbort(); return; }
signal?.addEventListener('abort', onAbort, { once: true });
const bindBackdrop = () => { const bindBackdrop = () => {
const modal = root.querySelector('[data-ar-attach-modal="true"]'); const modal = root.querySelector('[data-ar-attach-modal="true"]');
modal?.addEventListener('click', (event) => { modal?.addEventListener('click', (event) => {
@@ -429,20 +434,23 @@ export function openArweaveAttachmentManager({
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—'; const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—';
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—'; const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—';
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—'; const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—';
const freeHint = selectedFile const isFreeSize = selectedFile && combinedUploadSize() <= getFreeTurboUploadBytesLimit();
? (combinedUploadSize() <= getFreeTurboUploadBytesLimit() ? 'Да, бесплатно через Turbo' : 'Нет, нужен Turbo balance') const summary = selectedFile
: `До ${formatBytes(getFreeTurboUploadBytesLimit())} бесплатно`; ? `Размер ${escapeHtml(fileSize)} · ${isFreeSize
const freeBadge = priceInfo?.isFree ? '<span class="ar-attachment-ok">бесплатно через Turbo</span>'
? ' <span style="color:#15803d;">(пока файлы до 100 KB бесплатно)</span>' : `нужен баланс Turbo: ${escapeHtml(uploadPrice)}`}`
: ''; : '';
mainMetaEl.innerHTML = ` mainMetaEl.innerHTML = `
${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''}
<details class="ar-attachment-details">
<summary>Подробнее</summary>
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div> <div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
<div>Turbo balance: ${escapeHtml(turboCredits)}</div> <div>Баланс Turbo: ${escapeHtml(turboCredits)}</div>
<div>SOL на этом ключе: ${solBalance}</div> <div>SOL на этом ключе: ${solBalance}</div>
<div>Размер файла: ${escapeHtml(fileSize)}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div> <div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
<div>Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}</div> <div>Цена Turbo: ${escapeHtml(uploadPrice)}</div>
<div>До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}</div> <div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div>
</details>
`; `;
previewMetaEl.innerHTML = ''; previewMetaEl.innerHTML = '';
if (selectedPreviewEnabled && selectedPreviewFile) { if (selectedPreviewEnabled && selectedPreviewFile) {
@@ -623,28 +631,40 @@ export function openArweaveAttachmentManager({
root.innerHTML = ` root.innerHTML = `
<div class="modal" data-ar-attach-modal="true"> <div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card"> <div class="modal-card stack ar-attachment-manager-card">
<div class="ar-attachment-manager-head">
<h3 class="modal-title">${escapeHtml(titleText)}</h3> <h3 class="modal-title">${escapeHtml(titleText)}</h3>
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p> <button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">✕</button>
<div class="form-actions-grid"> </div>
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button> <div class="tabs tabs--auto" role="radiogroup" aria-label="Способ загрузки">
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button> <button class="tab-btn" role="radio" aria-checked="${turboMode}" type="button" data-action="switch-turbo">Turbo</button>
${canShowHistory ? `<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>` : ''} <button class="tab-btn" role="radio" aria-checked="${!turboMode}" type="button" data-action="switch-arweave">Свой кошелёк Arweave</button>
${canShowExisting ? `<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>` : ''}
</div> </div>
${turboMode ${turboMode
? ` ? `
<label class="meta-muted" for="turbo-key-source">Подписывать и пополнять через</label> <p class="ar-attachment-note">Маленькие файлы и аватары через Turbo пока загружаются бесплатно. Для больших файлов пополните Turbo со своего SOL-кошелька.</p>
<select class="input ar-attachment-wallet-select" id="turbo-key-source"></select> <div class="form-field">
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Маленькие файлы и аватары пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p> <label for="turbo-key-source">Подписывать и пополнять через</label>
<select class="select ar-attachment-wallet-select" id="turbo-key-source"></select>
</div>
` `
: ` : `
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label> <div class="form-field">
<select class="input ar-attachment-wallet-select" id="ar-attach-wallet"></select> <label for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button> <select class="select ar-attachment-wallet-select" id="ar-attach-wallet"></select>
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')} <button class="text-btn ar-attachment-link" type="button" data-action="add-wallet">+ Добавить кошелёк</button>
</div>
${isAvatarMode ? '<p class="ar-attachment-note">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="ar-attachment-note">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
`} `}
${fixedFile ? '' : '<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>'} ${canShowHistory || canShowExisting ? `
${fixedFile ? '' : `<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />`} <div class="ar-attachment-alt-sources">
${canShowHistory ? `<button class="text-btn ar-attachment-link" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Из журнала загрузок'}</button>` : ''}
${canShowExisting ? `<button class="text-btn ar-attachment-link" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Уже есть в Arweave (tx id)'}</button>` : ''}
</div>` : ''}
${fixedFile ? '' : `<label class="ar-attachment-file-picker">
<input class="ar-attachment-file-input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
<span class="ar-attachment-file-btn">Выбрать файл</span>
<span class="ar-attachment-file-name" data-file-name="true">Файл не выбран</span>
</label>`}
<div class="ar-attachment-meta" data-meta-main="true"></div> <div class="ar-attachment-meta" data-meta-main="true"></div>
<label class="meta-muted" data-preview-option="true" hidden> <label class="meta-muted" data-preview-option="true" hidden>
<input type="checkbox" data-preview-toggle="true" /> <input type="checkbox" data-preview-toggle="true" />
@@ -653,10 +673,9 @@ export function openArweaveAttachmentManager({
<div class="ar-attachment-meta" data-meta-preview="true"></div> <div class="ar-attachment-meta" data-meta-preview="true"></div>
<p class="meta-muted inline-error" data-error="true"></p> <p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid"> <div class="form-actions-grid">
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''} ${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : '<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>'}
<button class="primary-btn" type="button" data-action="upload" disabled>${escapeHtml(uploadText)}</button> <button class="primary-btn" type="button" data-action="upload" disabled>${escapeHtml(uploadText)}</button>
</div> </div>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div> </div>
</div> </div>
`; `;
@@ -729,7 +748,7 @@ export function openArweaveAttachmentManager({
} }
} }
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null)); root.querySelectorAll('[data-action="cancel"]').forEach((btn) => btn.addEventListener('click', () => close(resolve, null)));
root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => { root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => {
await promptTurboTopUp(mainMetaEl, previewMetaEl, errorEl); await promptTurboTopUp(mainMetaEl, previewMetaEl, errorEl);
if (turboMode && selectedFile && selectedSha256) { if (turboMode && selectedFile && selectedSha256) {
@@ -740,6 +759,10 @@ export function openArweaveAttachmentManager({
root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet); root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet);
} }
fileEl?.addEventListener('change', () => {
const nameEl = root.querySelector('[data-file-name="true"]');
if (nameEl) nameEl.textContent = fileEl.files?.[0]?.name || 'Файл не выбран';
});
fileEl?.addEventListener('change', async () => { fileEl?.addEventListener('change', async () => {
selectedFile = fileEl.files?.[0] || null; selectedFile = fileEl.files?.[0] || null;
selectedSha256 = ''; selectedSha256 = '';
@@ -1036,7 +1059,7 @@ export function openArweaveAttachmentManager({
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span> <span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
${hasAttachmentPreview(item) ? '<span class="ar-attachment-placement-flag">С превью</span>' : ''} ${hasAttachmentPreview(item) ? '<span class="ar-attachment-placement-flag">С превью</span>' : ''}
<span class="ar-attachment-status ar-attachment-status--pending" data-status="${index}">Проверяем...</span> <span class="ar-attachment-status ar-attachment-status--pending" data-status="${index}">Проверяем...</span>
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Не добавлен в SHiNE</span>' : ''} ${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Ещё не прикреплён</span>' : ''}
</div> </div>
<div class="mono-cell ar-attachment-history-txid">${escapeHtml(item.ar)}</div> <div class="mono-cell ar-attachment-history-txid">${escapeHtml(item.ar)}</div>
<button class="${isSelected ? 'secondary-btn' : 'ghost-btn'}" type="button" data-pick="${index}" ${isSelected ? 'disabled' : ''}>${isSelected ? 'Уже добавлен ✓' : 'Выбрать'}</button> <button class="${isSelected ? 'secondary-btn' : 'ghost-btn'}" type="button" data-pick="${index}" ${isSelected ? 'disabled' : ''}>${isSelected ? 'Уже добавлен ✓' : 'Выбрать'}</button>
+368
View File
@@ -0,0 +1,368 @@
import { iconHtml } from './ui-icon.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from './arweave-attachment-manager.js';
import { composeMessageWithAttachments, MAX_MESSAGE_ATTACHMENTS } from '../services/attachment-format.js';
import { state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { renderUserAvatar } from './avatar-image.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
const drafts = new Map();
function draftKey(key) {
const login = String(state.session.login || 'guest').trim().toLowerCase();
return `${login}:${String(key || 'editor')}`;
}
function attachmentLabel(item) {
const name = String(item?.name || 'Файл');
const size = Number(item?.size || 0);
if (!size) return name;
if (size < 1024) return `${name} · ${size} Б`;
if (size < 1024 * 1024) return `${name} · ${Math.ceil(size / 1024)} КБ`;
return `${name} · ${(size / 1024 / 1024).toFixed(1)} МБ`;
}
export function openChannelEditor({
id = 'channel-editor',
title = 'Ответ',
submitLabel = 'Ответить',
placeholder = 'Напишите ответ',
context = null,
key = 'reply',
extraControl = null,
initialText = '',
allowEmptyText = false,
allowAttachments = true,
rawText = false,
onSubmit,
isActive = () => true,
} = {}) {
if (!state.session.isAuthorized) {
openAuthRequiredModal({ title: 'Войдите, чтобы написать', text: 'Для публикации и ответа нужен активный профиль.' });
return null;
}
const root = document.getElementById('modal-root');
if (!root || typeof onSubmit !== 'function') return null;
if (root.querySelector('.channel-editor-overlay')) return null;
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const storageKey = draftKey(key);
const login = state.session.login;
const saved = drafts.get(storageKey) || { text: initialText, attachments: [] };
const attachments = Array.isArray(saved.attachments) ? [...saved.attachments] : [];
const extraFields = extraControl ? [...extraControl.querySelectorAll('select,input')] : [];
extraFields.forEach((field, index) => {
if (saved.controls?.[index] !== undefined) field.value = saved.controls[index];
});
let inFlight = false;
let composing = false;
let closed = false;
let picking = false;
let completed = false;
const attachmentController = new AbortController();
const overlay = document.createElement('div');
overlay.className = 'channel-editor-overlay';
overlay.id = id;
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', `${id}-title`);
const dialog = document.createElement('div');
dialog.className = 'channel-editor';
const header = document.createElement('header');
header.className = 'channel-editor__header';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'icon-btn channel-editor__close';
closeButton.setAttribute('aria-label', 'Закрыть редактор');
closeButton.textContent = '×';
const heading = document.createElement('h2');
heading.id = `${id}-title`;
heading.textContent = title;
header.append(closeButton, heading, document.createElement('span'));
const body = document.createElement('div');
body.className = 'channel-editor__body';
if (context && (context.author || context.text || context.attachmentLabel)) {
const contextBox = document.createElement('div');
contextBox.className = 'channel-editor__context';
const contextTitle = document.createElement('strong');
contextTitle.textContent = context.author ? `В ответ ${context.author}` : 'Контекст сообщения';
const quote = document.createElement('p');
quote.textContent = String(context.text || context.attachmentLabel || 'Сообщение без текста');
const expand = document.createElement('button');
expand.type = 'button';
expand.className = 'text-btn channel-editor__context-toggle';
expand.textContent = 'Показать целиком';
expand.setAttribute('aria-expanded', 'false');
expand.addEventListener('click', () => {
const expanded = contextBox.classList.toggle('is-expanded');
expand.textContent = expanded ? 'Свернуть' : 'Показать целиком';
expand.setAttribute('aria-expanded', String(expanded));
});
contextBox.append(contextTitle, quote, expand);
body.append(contextBox);
}
const textarea = document.createElement('textarea');
textarea.className = 'channel-editor__input';
textarea.maxLength = 2000;
textarea.placeholder = placeholder;
textarea.setAttribute('aria-label', placeholder);
textarea.value = String(saved.text || '');
const attachmentsBox = document.createElement('div');
attachmentsBox.className = 'channel-editor__attachments';
attachmentsBox.setAttribute('aria-live', 'polite');
const error = document.createElement('p');
error.className = 'channel-editor__error';
error.setAttribute('role', 'alert');
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'text-btn channel-editor__clear';
clearButton.textContent = 'Очистить черновик';
const author = document.createElement('div');
author.className = 'channel-editor__author';
author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' }));
const authorName = document.createElement('strong');
authorName.textContent = login || 'Гость';
author.append(authorName);
body.append(author);
body.append(textarea, attachmentsBox, error, clearButton);
const footer = document.createElement('footer');
footer.className = 'channel-editor__footer';
const attachButton = document.createElement('button');
attachButton.type = 'button';
attachButton.className = 'secondary-btn channel-editor__attach';
attachButton.innerHTML = `${iconHtml('clip')}<span class="sr-only">Прикрепить</span>`;
attachButton.title = 'Прикрепить файл';
attachButton.hidden = !allowAttachments;
const counter = document.createElement('span');
counter.className = 'channel-editor__counter';
const submitButton = document.createElement('button');
submitButton.type = 'button';
submitButton.className = 'primary-btn channel-editor__submit';
submitButton.textContent = submitLabel;
submitButton.title = 'Отправить · Ctrl+Enter / ⌘+Enter';
footer.append(attachButton);
if (extraControl instanceof Node) {
footer.classList.add('has-extra');
footer.append(extraControl);
}
footer.append(counter, submitButton);
dialog.append(header, body, footer);
overlay.append(dialog);
root.replaceChildren(overlay);
const saveDraft = () => {
const value = { text: textarea.value, attachments: [...attachments], controls: extraFields.map((field) => field.value) };
if (completed) { drafts.delete(storageKey); return; }
if (value.text || value.attachments.length) drafts.set(storageKey, value);
else drafts.delete(storageKey);
};
const sync = () => {
textarea.style.height = 'auto';
textarea.style.height = `${Math.max(160, textarea.scrollHeight)}px`;
const length = textarea.value.length;
counter.textContent = `${length} / 2000`;
counter.classList.toggle('is-near-limit', length >= 1800);
submitButton.disabled = inFlight || picking || length > textarea.maxLength || (!allowEmptyText && !textarea.value.trim() && attachments.length === 0);
dialog.setAttribute('aria-busy', String(inFlight || picking));
clearButton.hidden = !textarea.value && attachments.length === 0;
attachmentsBox.replaceChildren();
attachments.forEach((item, index) => {
const card = document.createElement('div');
card.className = 'channel-editor__attachment';
const label = document.createElement('span');
label.textContent = attachmentLabel(item);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'icon-btn';
remove.setAttribute('aria-label', `Убрать вложение ${item?.name || ''}`);
remove.textContent = '×';
remove.disabled = inFlight;
remove.addEventListener('click', () => {
attachments.splice(index, 1);
saveDraft();
sync();
});
card.append(label, remove);
attachmentsBox.append(card);
});
};
const openedUrl = location.href;
const historyId = `${Date.now()}:${Math.random()}`;
history.pushState({ ...history.state, channelEditor: historyId }, '', openedUrl);
const viewport = window.visualViewport;
const updateViewport = () => {
overlay.style.setProperty('--editor-height', `${viewport?.height || window.innerHeight}px`);
overlay.style.setProperty('--editor-top', `${viewport?.offsetTop || 0}px`);
};
const onBack = (event) => {
if (closed) return;
event.stopImmediatePropagation();
close({ fromHistory: true });
};
const observer = new MutationObserver(() => {
if (!overlay.isConnected) close({ restoreFocus: false });
});
const close = ({ fromHistory = false, restoreFocus = true } = {}) => {
if (closed) return;
closed = true;
saveDraft();
observer.disconnect();
attachmentController.abort();
document.removeEventListener('keydown', onDocumentKeydown);
viewport?.removeEventListener('resize', updateViewport);
viewport?.removeEventListener('scroll', updateViewport);
window.removeEventListener('resize', updateViewport);
window.removeEventListener('popstate', onBack, true);
overlay.remove();
if (!fromHistory && location.href === openedUrl && history.state?.channelEditor === historyId) {
window.addEventListener('popstate', (event) => {
if (location.href === openedUrl) event.stopImmediatePropagation();
}, { capture: true, once: true });
history.back();
}
if (restoreFocus && opener?.isConnected) opener.focus({ preventScroll: true });
};
overlay.cleanup = () => close({ restoreFocus: false });
const submit = async () => {
if (inFlight || submitButton.disabled) return;
inFlight = true;
error.textContent = '';
textarea.disabled = true;
attachButton.disabled = true;
clearButton.disabled = true;
if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = true; });
submitButton.textContent = 'Отправляем…';
sync();
try {
await onSubmit({
text: rawText ? textarea.value.trim() : composeMessageWithAttachments(textarea.value.trim(), attachments),
attachments: [...attachments],
});
completed = true;
attachments.forEach((item) => markArweaveAttachmentPlaced(login, item));
// Не удаляем новый черновик, открытый после закрытия отправляющего редактора.
if (!closed || drafts.get(storageKey)?.text === textarea.value) drafts.delete(storageKey);
if (!isActive() || closed) return;
close();
} catch (submitError) {
if (!isActive() || closed) return;
inFlight = false;
textarea.disabled = false;
attachButton.disabled = false;
clearButton.disabled = false;
if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = false; });
submitButton.textContent = submitLabel;
error.textContent = toUserMessage(submitError, 'Не удалось отправить. Текст сохранён.');
saveDraft();
sync();
}
};
const onDocumentKeydown = (event) => {
if (!overlay.isConnected || document.querySelector('.ar-attachment-manager-root')) return;
if (event.key === 'Escape') {
event.preventDefault();
close();
return;
}
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey) && !composing && !event.isComposing) {
event.preventDefault();
void submit();
return;
}
if (event.key !== 'Tab') return;
const focusable = [...overlay.querySelectorAll('button:not(:disabled), textarea:not(:disabled), select:not(:disabled)')].filter((el) => !el.hidden);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
closeButton.addEventListener('click', () => close());
clearButton.addEventListener('click', () => {
textarea.value = '';
attachments.splice(0);
drafts.delete(storageKey);
error.textContent = '';
sync();
textarea.focus();
});
textarea.addEventListener('input', () => {
error.textContent = '';
saveDraft();
sync();
});
textarea.addEventListener('compositionstart', () => { composing = true; });
extraFields.forEach((field) => field.addEventListener('change', saveDraft));
textarea.addEventListener('compositionend', () => { composing = false; });
attachButton.addEventListener('click', async () => {
if (picking || inFlight) return;
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
error.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
try {
picking = true;
dialog.inert = true;
sync();
const item = await openArweaveAttachmentManager({
login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
signal: attachmentController.signal,
});
if (!isActive() || !overlay.isConnected || !item) return;
attachments.push(item);
saveDraft();
sync();
} catch (attachError) {
if (isActive() && overlay.isConnected) {
error.textContent = toUserMessage(attachError, 'Не удалось добавить вложение.');
}
} finally {
picking = false;
dialog.inert = false;
if (!closed) { sync(); attachButton.focus(); }
}
});
submitButton.addEventListener('click', () => void submit());
document.addEventListener('keydown', onDocumentKeydown);
window.addEventListener('popstate', onBack, true);
viewport?.addEventListener('resize', updateViewport);
viewport?.addEventListener('scroll', updateViewport);
window.addEventListener('resize', updateViewport);
observer.observe(root, { childList: true });
updateViewport();
sync();
requestAnimationFrame(() => {
if (closed || !isActive()) return;
const quote = overlay.querySelector('.channel-editor__context p');
const expand = overlay.querySelector('.channel-editor__context-toggle');
if (quote && expand && quote.clientHeight > 0) expand.hidden = quote.scrollHeight <= quote.clientHeight;
sync();
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
});
return { close };
}
+48
View File
@@ -0,0 +1,48 @@
// Возвращает Promise<boolean>: true — подтвердили, false — отмена, Escape или тап мимо.
export function confirmDialog({
title = 'Подтвердите действие',
text = '',
confirmLabel = 'Да',
cancelLabel = 'Отмена',
danger = false,
} = {}) {
return new Promise((resolve) => {
const opener = document.activeElement;
const modal = document.createElement('div');
modal.className = 'modal confirm-dialog';
modal.innerHTML = `
<div class="modal-card confirm-dialog__card" role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
<h2 class="modal-title" id="confirm-dialog-title"></h2>
<p class="confirm-dialog__text"></p>
<div class="form-actions-grid">
<button type="button" class="secondary-btn" data-answer="no"></button>
<button type="button" class="${danger ? 'destructive-btn confirm-dialog__danger' : 'primary-btn'}" data-answer="yes"></button>
</div>
</div>
`;
modal.querySelector('.modal-title').textContent = title;
const textEl = modal.querySelector('.confirm-dialog__text');
textEl.textContent = text;
textEl.hidden = !text;
modal.querySelector('[data-answer="no"]').textContent = cancelLabel;
modal.querySelector('[data-answer="yes"]').textContent = confirmLabel;
const finish = (answer) => {
document.removeEventListener('keydown', onKeydown, true);
modal.remove();
opener?.focus?.();
resolve(answer);
};
const onKeydown = (event) => {
if (event.key === 'Escape') { event.stopPropagation(); finish(false); }
};
modal.addEventListener('click', (event) => {
if (event.target === modal) { finish(false); return; }
const answer = event.target.closest('[data-answer]')?.dataset.answer;
if (answer) finish(answer === 'yes');
});
document.addEventListener('keydown', onKeydown, true);
document.body.append(modal);
modal.querySelector('[data-answer="no"]').focus();
});
}
+14 -2
View File
@@ -110,7 +110,7 @@ export function createDropdownMenu({
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (button.disabled) return; if (button.disabled) return;
close(); close({ focusAnchor: true });
await item.action?.(); await item.action?.();
}); });
menuEl.append(button); menuEl.append(button);
@@ -154,6 +154,7 @@ export function createDropdownMenu({
setAnchorOpen(true); setAnchorOpen(true);
onOpen?.(); onOpen?.();
position(); position();
menuEl.querySelector('button:not(:disabled)')?.focus();
}; };
const toggle = () => { const toggle = () => {
@@ -172,8 +173,19 @@ export function createDropdownMenu({
close(); close();
}; };
const onKeydown = (event) => { const onKeydown = (event) => {
if (event.key !== 'Escape' || !portal) return; if (!portal) return;
if (event.key === 'Escape' || event.key === 'Tab') {
if (event.key === 'Escape') event.preventDefault();
close({ focusAnchor: true }); close({ focusAnchor: true });
return;
}
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const buttons = [...menuEl.querySelectorAll('button:not(:disabled)')];
if (!buttons.length) return;
event.preventDefault();
const index = buttons.indexOf(document.activeElement);
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + buttons.length) % buttons.length;
buttons[next].focus();
}; };
const onNavigation = () => close(); const onNavigation = () => close();
const onViewportChange = () => position(); const onViewportChange = () => position();
+12
View File
@@ -0,0 +1,12 @@
import { createDropdownMenu } from './dropdown-menu.js';
export function attachMessageMenu(card, head, items) {
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'icon-btn channel-message-more';
trigger.textContent = '⋯';
trigger.setAttribute('aria-label', 'Действия сообщения');
head.append(trigger);
const menu = createDropdownMenu({ anchorEl: trigger, items });
card.cleanup = () => menu.destroy();
}
+152
View File
@@ -0,0 +1,152 @@
// Скрытый редактор палитры: открывается долгим нажатием на «День» или «Ночь» в настройках.
import {
PALETTE_PRESETS,
PALETTE_ROLES,
exportPalette,
getPaletteSettings,
importPalette,
resolvePalette,
resolveThemeMode,
setPaletteSettings,
} from '../services/theme-service.js';
export function openPaletteEditor({ theme = resolveThemeMode(), onClose } = {}) {
let editTheme = theme === 'light' ? 'light' : 'dark';
const opener = document.activeElement;
const modal = document.createElement('div');
modal.className = 'modal palette-editor';
modal.innerHTML = `
<div class="modal-card palette-editor__card" role="dialog" aria-modal="true" aria-labelledby="palette-editor-title">
<div class="palette-editor__head">
<h2 class="modal-title" id="palette-editor-title">Цвета оформления</h2>
<button class="icon-btn palette-editor__close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="palette-editor__section">
<span class="palette-editor__label">Основа</span>
<div class="tabs tabs--auto palette-editor__presets" role="radiogroup" aria-label="Готовая палитра"></div>
</div>
<div class="palette-editor__section">
<span class="palette-editor__label">Настраиваемая тема</span>
<div class="tabs tabs--auto" role="radiogroup" aria-label="Тема для настройки">
<button type="button" class="tab-btn" role="radio" data-edit-theme="light">День</button>
<button type="button" class="tab-btn" role="radio" data-edit-theme="dark">Ночь</button>
</div>
<p class="palette-editor__hint">Правки видны сразу. Меняется только выбранная тема.</p>
</div>
<div class="palette-editor__roles"></div>
<details class="palette-editor__share">
<summary>Поделиться палитрой</summary>
<p class="palette-editor__hint">Скопируйте текст и отправьте команде или вставьте чужую палитру и нажмите «Применить».</p>
<textarea class="input palette-editor__json" rows="8" spellcheck="false"></textarea>
<p class="palette-editor__error" role="alert" hidden></p>
<div class="palette-editor__actions">
<button type="button" class="secondary-btn" data-action="copy">Скопировать</button>
<button type="button" class="secondary-btn" data-action="import">Применить</button>
</div>
</details>
<div class="palette-editor__actions">
<button type="button" class="secondary-btn" data-action="reset-theme">Сбросить эту тему</button>
<button type="button" class="primary-btn" data-action="done">Готово</button>
</div>
</div>
`;
const card = modal.querySelector('.palette-editor__card');
const presetsEl = modal.querySelector('.palette-editor__presets');
const rolesEl = modal.querySelector('.palette-editor__roles');
const jsonEl = modal.querySelector('.palette-editor__json');
const errorEl = modal.querySelector('.palette-editor__error');
for (const [id, preset] of Object.entries(PALETTE_PRESETS)) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'tab-btn';
btn.setAttribute('role', 'radio');
btn.dataset.preset = id;
btn.textContent = preset.label;
presetsEl.append(btn);
}
const update = (mutate) => {
const settings = getPaletteSettings();
const next = { preset: settings.preset, custom: { dark: { ...settings.custom.dark }, light: { ...settings.custom.light } } };
mutate(next);
setPaletteSettings(next);
render();
};
function render() {
const settings = getPaletteSettings();
const colors = resolvePalette(editTheme, settings);
const custom = settings.custom[editTheme];
modal.querySelectorAll('[data-preset]').forEach((btn) => {
btn.setAttribute('aria-checked', String(btn.dataset.preset === settings.preset));
});
modal.querySelectorAll('[data-edit-theme]').forEach((btn) => {
btn.setAttribute('aria-checked', String(btn.dataset.editTheme === editTheme));
});
rolesEl.replaceChildren(...PALETTE_ROLES.map((role) => {
const row = document.createElement('label');
row.className = 'palette-editor__role';
const changed = Boolean(custom[role.id]);
row.innerHTML = `
<input type="color" value="${colors[role.id]}" aria-label="${role.label}">
<span class="palette-editor__role-name">${role.label}${changed ? ' <span class="palette-editor__changed">изменён</span>' : ''}</span>
<code class="palette-editor__role-value">${colors[role.id]}</code>
`;
const input = row.querySelector('input');
input.addEventListener('input', () => {
document.documentElement.style.setProperty(`--${role.id}`, input.value);
row.querySelector('code').textContent = input.value;
});
input.addEventListener('change', () => update((next) => { next.custom[editTheme][role.id] = input.value; }));
return row;
}));
jsonEl.value = exportPalette();
errorEl.hidden = true;
}
const close = () => {
document.removeEventListener('keydown', onKeydown);
modal.remove();
opener?.focus?.();
onClose?.();
};
const onKeydown = (event) => { if (event.key === 'Escape') close(); };
modal.addEventListener('click', async (event) => {
if (event.target === modal) { close(); return; }
const presetBtn = event.target.closest('[data-preset]');
if (presetBtn) { update((next) => { next.preset = presetBtn.dataset.preset; }); return; }
// data-edit-theme, а не data-theme: data-theme стоит на <html>, и closest() находил бы его при любом клике.
const themeBtn = event.target.closest('[data-edit-theme]');
if (themeBtn) { editTheme = themeBtn.dataset.editTheme; render(); return; }
if (event.target.closest('.palette-editor__close')) { close(); return; }
const action = event.target.closest('[data-action]')?.dataset.action;
if (action === 'done') close();
if (action === 'reset-theme') update((next) => { next.custom[editTheme] = {}; });
if (action === 'copy') {
try {
await navigator.clipboard.writeText(jsonEl.value);
} catch {
jsonEl.select();
}
}
if (action === 'import') {
try {
importPalette(jsonEl.value);
render();
} catch {
errorEl.textContent = 'Не удалось прочитать палитру: проверьте, что текст скопирован целиком.';
errorEl.hidden = false;
}
}
});
document.addEventListener('keydown', onKeydown);
render();
document.body.append(modal);
card.querySelector('.palette-editor__close').focus();
return close;
}
+110
View File
@@ -0,0 +1,110 @@
// data-profile-list / data-self-profile-action / data-profile-action обрабатывают profile-view.js и user-profile-view.js.
import { iconHtml } from './ui-icon.js';
export function escapeProfileHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
const esc = escapeProfileHtml;
function statusChip({ kind, label, value, active }) {
const n = Number(value || 0);
return `
<button type="button" class="pf-status${active ? ' is-active' : ''}" data-profile-list="${esc(kind)}"
aria-label="${esc(label)}: подтверждений ${n}" title="Статус «${esc(label)}». Число — сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
<span class="pf-status-dot" aria-hidden="true"></span>${esc(label)}${n > 0 ? `<b>${n}</b>` : ''}
</button>`;
}
function statHtml({ kind, label, valueHtml, ariaLabel }) {
return `
<button type="button" class="pf-stat" data-profile-list="${esc(kind)}" aria-label="${esc(ariaLabel)}">
<span class="pf-stat-value">${valueHtml}</span>
<span class="pf-stat-label">${esc(label)}</span>
</button>`;
}
function contactRows(card) {
return [
['Ссылки', card?.web],
['Телефон', card?.phone],
['Адрес', card?.address],
]
.filter(([, value]) => String(value || '').trim())
.map(([label, value]) => `
<div class="pf-row">
<span class="pf-row-label">${esc(label)}</span>
<span class="pf-row-value">${esc(value)}</span>
</div>`)
.join('');
}
/**
* @param {object} opts
* @param {object} opts.card карточка профиля (loadUserProfileCard)
* @param {string} opts.login логин на случай, если в карточке его нет
* @param {string} opts.tilesHtml плитки действий (свои/чужие)
* @param {string} [opts.tilesWrapClass] доп. класс обёртки плиток (для меню «Добавить»)
* @param {string} [opts.beforeTilesHtml] разметка внутри обёртки перед плитками
*/
export function profileCardHtml({ card, login = '', tilesHtml = '', tilesWrapClass = '', beforeTilesHtml = '', isSelf = true }) {
const stats = card?.stats || {};
const official = card?.accountRole === 'primary';
const shining = card?.shineStatus === 'shining';
const cardLogin = card?.login || login;
const fullName = [card?.firstName, card?.lastName].map((v) => String(v || '').trim()).filter(Boolean).join(' ');
const displayName = fullName || cardLogin || 'Профиль';
const about = String(card?.about || '').trim();
const spiritualPath = String(card?.spiritualPath || '').trim();
const contacts = contactRows(card);
const aboutRow = (about || isSelf) ? `
<div class="pf-row pf-row--block">
<span class="pf-row-label">О себе</span>
<p class="pf-row-text${about ? '' : ' is-empty'}">${esc(about || 'Не заполнено')}</p>
</div>` : '';
const pathRow = spiritualPath ? `
<div class="pf-row pf-row--block">
<span class="pf-row-label">Духовный путь</span>
<p class="pf-row-text">${esc(spiritualPath)}</p>
</div>` : '';
const listInner = aboutRow + contacts + pathRow;
const listHtml = listInner.trim() ? `<div class="pf-list">${listInner}</div>` : '';
const friends = Number(stats.friendsCount || 0);
const statusChips = [
(official || Number(stats.primaryReceivedCount || 0) > 0) && statusChip({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, active: official }),
(shining || Number(stats.shineReceivedCount || 0) > 0) && statusChip({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, active: shining }),
].filter(Boolean);
const statusesHtml = statusChips.length ? `<div class="pf-statuses">${statusChips.join('')}</div>` : '';
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<div class="pf-hero${shining ? ' is-shining' : ''}" aria-label="Профиль ${esc(cardLogin)}">
<div class="pf-avatar user-profile-avatar-slot"></div>
<h2 class="pf-name">${esc(displayName)}</h2>
<div class="pf-login">@${esc(cardLogin)}</div>
${statusesHtml}
</div>
<div class="pf-stats">
${statHtml({ kind: 'friends', label: 'Друзья', valueHtml: closeFriends ? `${friends}<small> · ${closeFriends} близк.</small>` : String(friends), ariaLabel: `Друзья: ${friends}, близкие: ${closeFriends}` })}
${statHtml({ kind: 'channels_owned', label: 'Каналы', valueHtml: String(Number(stats.ownedPublicChannelsCount || 0)), ariaLabel: `Каналы: ${Number(stats.ownedPublicChannelsCount || 0)}` })}
${statHtml({ kind: 'channels_following', label: 'Подписки', valueHtml: String(Number(stats.followingChannelsCount || 0)), ariaLabel: `Подписки: ${Number(stats.followingChannelsCount || 0)}` })}
</div>
${tilesHtml ? `
<div class="pf-tiles-wrap user-profile-actions-wrap ${esc(tilesWrapClass)}">
${beforeTilesHtml}
<div class="pf-tiles user-profile-actions">${tilesHtml}</div>
</div>` : ''}
${listHtml}`;
}
export function profileTileHtml({ icon, label, attrs = '', iconMarkup = '' }) {
return `<button type="button" class="pf-tile user-profile-action-btn" ${attrs}>${iconMarkup || iconHtml(icon)}<span>${esc(label)}</span></button>`;
}
@@ -1,3 +1,4 @@
import { iconHtml } from './ui-icon.js';
function resolveElement(value) { function resolveElement(value) {
return typeof value === 'function' ? value() : value; return typeof value === 'function' ? value() : value;
} }
@@ -6,7 +7,7 @@ function buildArrowIcon() {
const icon = document.createElement('span'); const icon = document.createElement('span');
icon.className = 'scroll-to-bottom-btn__icon'; icon.className = 'scroll-to-bottom-btn__icon';
icon.setAttribute('aria-hidden', 'true'); icon.setAttribute('aria-hidden', 'true');
icon.textContent = '↓'; icon.innerHTML = iconHtml('down');
return icon; return icon;
} }
+26 -23
View File
@@ -3,21 +3,26 @@ import { state, authService } from '../state.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js'; import { openAuthRequiredModal } from '../services/auth-required-modal.js';
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js'; import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения const TOOLBAR_ICONS = {
// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится). 'messages-list': '<g transform="translate(12 12) scale(.88) translate(-11.5 -13)"><path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2v-9.5A8.5 8.5 0 0 1 10.5 4H13a8 8 0 0 1 8 7.5Z"/><circle cx="8" cy="12" r="1.05" class="toolbar-svg-dot"/><circle cx="11.7" cy="12" r="1.05" class="toolbar-svg-dot"/><circle cx="15.4" cy="12" r="1.05" class="toolbar-svg-dot"/></g>',
// Пока подключена только «Связи»; остальные 4 — эмодзи до подготовки ассетов (имена подставлю). 'channels-list': '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
'notifications-view': '<path d="M6.2 16.8V11a5.8 5.8 0 0 1 11.6 0v5.8l1.7 1.7h-15Z"/><path d="M10.2 21a2 2 0 0 0 3.6 0"/>',
'profile-view': '<circle cx="12" cy="6.8" r="3.6"/><path d="M4.4 21v-2.1a5.6 5.6 0 0 1 5.6-5.6h4a5.6 5.6 0 0 1 5.6 5.6V21Z"/>',
};
const ITEMS = [ const ITEMS = [
{ pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' }, { pageId: 'messages-list', label: 'Личные' },
{ pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' }, { pageId: 'channels-list', label: 'Каналы' },
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: SHINE_CONNECTIONS_LOGO_SRC, glow: 'rgba(0, 229, 255, .6)', hero: true }, { pageId: 'network-view', label: 'Связи', mandala: true },
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' }, { pageId: 'notifications-view', label: 'Уведомления' },
{ pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' }, { pageId: 'profile-view', label: 'Профиль' },
]; ];
function iconHtml(item) { function iconHtml(item, extra = '') {
return item.iconImg const glyph = item.mandala
? `<img class="toolbar-icon-img" src="${item.iconImg}" alt="" aria-hidden="true" style="--tab-glow:${item.glow}" />` ? `<img class="toolbar-mandala" src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true" />`
: `<span>${item.icon}</span>`; : `<svg class="toolbar-svg" viewBox="0 0 24 24" aria-hidden="true">${TOOLBAR_ICONS[item.pageId]}</svg>`;
return `<span class="toolbar-icon${item.mandala ? ' toolbar-icon--mandala' : ''}">${glyph}${extra}</span>`;
} }
function normalizeCounters(payload = {}) { function normalizeCounters(payload = {}) {
@@ -49,7 +54,7 @@ function renderBadge(btn, count, ariaLabel, extraClass = '') {
if (!badge) { if (!badge) {
badge = document.createElement('span'); badge = document.createElement('span');
badge.className = `toolbar-unread-badge${extraClass ? ` ${extraClass}` : ''}`; badge.className = `toolbar-unread-badge${extraClass ? ` ${extraClass}` : ''}`;
btn.append(badge); (btn.querySelector('.toolbar-icon') || btn).append(badge);
} }
badge.textContent = count > 99 ? '99+' : String(count); badge.textContent = count > 99 ? '99+' : String(count);
badge.setAttribute('aria-label', `${ariaLabel}: ${count}`); badge.setAttribute('aria-label', `${ariaLabel}: ${count}`);
@@ -126,25 +131,23 @@ export function renderToolbar(currentPageId, navigate) {
const isMessages = item.pageId === 'messages-list'; const isMessages = item.pageId === 'messages-list';
const isNetwork = item.pageId === 'network-view'; const isNetwork = item.pageId === 'network-view';
const isNotifications = item.pageId === 'notifications-view'; const isNotifications = item.pageId === 'notifications-view';
btn.type = 'button';
if (item.pageId === active) btn.setAttribute('aria-current', 'page');
btn.dataset.toolbarPage = item.pageId; btn.dataset.toolbarPage = item.pageId;
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`; btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
if (isProfile) { if (isProfile) {
btn.innerHTML = ` btn.innerHTML = `
${iconHtml(item)} ${iconHtml(item, `<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
<span class="toolbar-label-wrap">
<span>${item.label}</span>
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
<span class="toolbar-connection-dot" aria-hidden="true"></span> <span class="toolbar-connection-dot" aria-hidden="true"></span>
</span> </span>`)}
</span> <span class="sr-only">${item.label}</span>
`; `;
} else if (isNetwork) { } else if (isNetwork) {
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`; btn.innerHTML = `${iconHtml(item)}<span class="sr-only">${item.label}</span>`;
btn.setAttribute('aria-label', item.label);
btn.title = item.label;
} else { } else {
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`; btn.innerHTML = `${iconHtml(item)}<span class="sr-only">${item.label}</span>`;
} }
btn.title = item.label;
if (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений'); if (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений');
if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах'); if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge'); if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
+4 -1
View File
@@ -1,3 +1,4 @@
import { iconHtml } from './ui-icon.js';
import { createDropdownMenu } from './dropdown-menu.js'; import { createDropdownMenu } from './dropdown-menu.js';
function appendNode(target, node) { function appendNode(target, node) {
@@ -15,6 +16,8 @@ function createActionButton(action = {}, cleanupFns) {
if (action.iconNode instanceof Node) { if (action.iconNode instanceof Node) {
button.append(action.iconNode); button.append(action.iconNode);
} else if (action.icon) {
button.innerHTML = iconHtml(action.icon);
} else { } else {
button.textContent = String(action.label ?? ''); button.textContent = String(action.label ?? '');
} }
@@ -56,7 +59,7 @@ export function createTopBar({
const button = document.createElement('button'); const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.className = `icon-btn topbar__back${backAction.className ? ` ${backAction.className}` : ''}`; button.className = `icon-btn topbar__back${backAction.className ? ` ${backAction.className}` : ''}`;
button.textContent = '←'; button.innerHTML = iconHtml('back');
button.setAttribute('aria-label', backAction.ariaLabel || 'Назад'); button.setAttribute('aria-label', backAction.ariaLabel || 'Назад');
button.title = backAction.title || 'Назад'; button.title = backAction.title || 'Назад';
button.addEventListener('click', backAction.onClick); button.addEventListener('click', backAction.onClick);
+28
View File
@@ -0,0 +1,28 @@
const paths = {
heart: '<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.6a5.5 5.5 0 0 0 0-7.8Z"/>',
message: '<path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2v-9.5A8.5 8.5 0 0 1 10.5 4H13a8 8 0 0 1 8 7.5Z"/>',
channels: '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
network: '<circle cx="12" cy="5" r="3"/><circle cx="5" cy="18" r="3"/><circle cx="19" cy="18" r="3"/><path d="m10 8-4 7m8-7 4 7M8 18h8"/>',
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',
profile: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
share: '<path d="m8 12 8-8M9 4h7v7M20 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 0 0-6.4-6.4L12 5.6"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/>',
gift: '<rect x="3" y="8" width="18" height="5" rx="1"/><path d="M5 13v7a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7M12 8v13M12 8S10.5 3 8 3.5 7 8 12 8Zm0 0s1.5-5 4-4.5S17 8 12 8Z"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
open: '<path d="M5 12h14M13 6l6 6-6 6"/>',
chevron: '<path d="m9 6 6 6-6 6"/>',
back: '<path d="M19 12H5M11 5l-7 7 7 7"/>',
down: '<path d="M12 5v14M5 12l7 7 7-7"/>',
edit: '<path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>',
wallet: '<path d="M4 7.5A2.5 2.5 0 0 1 6.5 5H18v3"/><rect x="4" y="8" width="16" height="11" rx="2.5"/><circle cx="16" cy="13.5" r="1.2" fill="currentColor" stroke="none"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
'user-plus': '<circle cx="10" cy="8" r="3.5"/><path d="M3.5 20a6.5 6.5 0 0 1 13 0M19 8v6M16 11h6"/>',
refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v4.4h-4.4"/>',
clip: '<path d="m20.5 11.5-8.2 8.2a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/>',
};
export function iconHtml(name, filled = false) {
return `<svg viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`;
}
+19 -25
View File
@@ -177,11 +177,11 @@ function createPasswordModal() {
<div class="stack" style="gap:0.45rem;"> <div class="stack" style="gap:0.45rem;">
<label class="checkbox-row"> <label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="once" checked /> <input type="radio" name="access-servers-key-mode" value="once" checked />
<span>Использовать root key только сейчас</span> <span>Использовать главный ключ только сейчас</span>
</label> </label>
<label class="checkbox-row"> <label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="save" /> <input type="radio" name="access-servers-key-mode" value="save" />
<span>Сохранить root key на этом устройстве</span> <span>Сохранить главный ключ на этом устройстве</span>
</label> </label>
</div> </div>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p> <p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
@@ -277,15 +277,9 @@ export function render({navigate, chrome}) {
const passwordModal = createPasswordModal(); const passwordModal = createPasswordModal();
const introCard = document.createElement('div'); const introCard = document.createElement('div');
introCard.className = 'card stack'; introCard.className = 'settings-intro';
introCard.innerHTML = ` introCard.innerHTML = `
<p class="field-label">Где хранятся личные данные</p> <p class="meta-muted">Сервер доступа хранит вашу зашифрованную переписку и настройки и помогает со звонками. Публичные записи доступны через любой сервер. При смене сервера переписка сама не переносится.</p>
<p class="meta-muted">
Единственный сервер доступа хранит зашифрованную личную переписку пользователя и участвует в звонках.
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
а настройки и приватная переписка хранятся только на выбранном сервере.
При смене сервера прежняя переписка и настройки автоматически не переносятся.
</p>
`; `;
const listCard = document.createElement('div'); const listCard = document.createElement('div');
@@ -302,7 +296,7 @@ export function render({navigate, chrome}) {
listBody.className = 'stack'; listBody.className = 'stack';
const listStatus = document.createElement('p'); const listStatus = document.createElement('p');
listStatus.className = 'meta-muted'; listStatus.className = 'meta-muted';
listStatus.textContent = 'Загрузка данных из PDA...'; listStatus.textContent = 'Загрузка данных аккаунта...';
listCard.append(listTitle, listHint, listBody, listStatus); listCard.append(listTitle, listHint, listBody, listStatus);
const addCard = document.createElement('div'); const addCard = document.createElement('div');
@@ -323,7 +317,7 @@ export function render({navigate, chrome}) {
suggestEl.hidden = true; suggestEl.hidden = true;
const addStatus = document.createElement('p'); const addStatus = document.createElement('p');
addStatus.className = 'meta-muted'; addStatus.className = 'meta-muted';
addStatus.textContent = 'Для изменения списка понадобится подпись root key.'; addStatus.textContent = 'Для изменения списка понадобится подтверждение главным ключом.';
const addButton = document.createElement('button'); const addButton = document.createElement('button');
addButton.className = 'primary-btn'; addButton.className = 'primary-btn';
addButton.type = 'button'; addButton.type = 'button';
@@ -352,7 +346,7 @@ export function render({navigate, chrome}) {
addInput.value = candidate.login; addInput.value = candidate.login;
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`; addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
} else { } else {
addStatus.textContent = 'Для смены сервера понадобится подпись root key.'; addStatus.textContent = 'Для смены сервера понадобится подтверждение главным ключом.';
} }
}; };
@@ -392,7 +386,7 @@ export function render({navigate, chrome}) {
return; return;
} }
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...'; listStatus.textContent = 'Читаем серверы доступа из записи аккаунта в Solana...';
try { try {
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint }); const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
const logins = uniqueLogins(parsed?.accessServers); const logins = uniqueLogins(parsed?.accessServers);
@@ -409,8 +403,8 @@ export function render({navigate, chrome}) {
renderServerList(); renderServerList();
refreshAddButton(); refreshAddButton();
listStatus.textContent = rows.length listStatus.textContent = rows.length
? 'Сервер доступа загружен из PDA.' ? 'Сервер доступа загружен из записи аккаунта.'
: 'В PDA пользователя пока нет серверов доступа.'; : 'В записи аккаунта пока нет серверов доступа.';
} catch (error) { } catch (error) {
currentAccessServers = []; currentAccessServers = [];
renderServerList(); renderServerList();
@@ -439,7 +433,7 @@ export function render({navigate, chrome}) {
const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup'; const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup';
target.innerHTML = ` target.innerHTML = `
<span style="display:block; margin-bottom:0.55rem;"> <span style="display:block; margin-bottom:0.55rem;">
Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA. Не хватает SOL на ключе устройства для оплаты комиссии Solana при обновлении записи аккаунта в Solana.
</span> </span>
${address ? `<span style="display:block; overflow-wrap:anywhere; margin-bottom:0.55rem;">Кошелёк: ${escapeHtml(address)}</span>` : ''} ${address ? `<span style="display:block; overflow-wrap:anywhere; margin-bottom:0.55rem;">Кошелёк: ${escapeHtml(address)}</span>` : ''}
<a class="primary-btn" href="${escapeHtml(topupUrl)}" target="_blank" rel="noopener" style="display:inline-flex; text-decoration:none;">Пополнить DEVNET кошелёк</a> <a class="primary-btn" href="${escapeHtml(topupUrl)}" target="_blank" rel="noopener" style="display:inline-flex; text-decoration:none;">Пополнить DEVNET кошелёк</a>
@@ -500,10 +494,10 @@ export function render({navigate, chrome}) {
const passwordResult = await passwordModal?.open({ const passwordResult = await passwordModal?.open({
title: 'Нужен пароль для обновления серверов доступа', title: 'Нужен пароль для обновления серверов доступа',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.', text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
note: savedClient note: savedClient
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.' ? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.'
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.', : 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
}); });
if (!passwordResult) { if (!passwordResult) {
throw new Error('Операция отменена пользователем.'); throw new Error('Операция отменена пользователем.');
@@ -513,10 +507,10 @@ export function render({navigate, chrome}) {
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64); const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64); const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) { if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
throw new Error('Пароль не подходит: root key не совпал с user PDA.'); throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.');
} }
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) { if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
throw new Error('Пароль не подходит: client key не совпал с user PDA.'); throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
} }
if (passwordResult.saveRoot) { if (passwordResult.saveRoot) {
@@ -613,15 +607,15 @@ export function render({navigate, chrome}) {
title: 'Сменить сервер доступа?', title: 'Сменить сервер доступа?',
text: `Заменить текущий сервер доступа на @${resolved.serverLogin}?`, text: `Заменить текущий сервер доступа на @${resolved.serverLogin}?`,
note: resolved.httpBase note: resolved.httpBase
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.` ? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.`
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.', : 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.',
onConfirm: async () => { onConfirm: async () => {
const nextList = [resolved.serverLogin]; const nextList = [resolved.serverLogin];
try { try {
await updateAccessServers(nextList, { await updateAccessServers(nextList, {
statusTarget: addStatus, statusTarget: addStatus,
successText: `Сервер доступа заменён на @${resolved.serverLogin}.`, successText: `Сервер доступа заменён на @${resolved.serverLogin}.`,
inFlightText: `Обновляем PDA и меняем сервер на @${resolved.serverLogin}...`, inFlightText: `Обновляем запись аккаунта и меняем сервер на @${resolved.serverLogin}...`,
}); });
} catch { } catch {
// Сообщение уже показано в статусе. // Сообщение уже показано в статусе.
+49 -23
View File
@@ -17,7 +17,7 @@ export const pageMeta = { id: 'add-channel-view', title: 'Создание ка
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PUBLIC = 1; const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_LOGIN_HINT = 'Разрешены латинские буквы, цифры, _ и -. Длина: от 3 до 32 символов. Название не должно состоять только из цифр.'; const CHANNEL_LOGIN_HINT = 'Латиница, цифры, _ и -, от 3 до 32 символов, не только цифры.';
const CHANNEL_LOGIN_DIGITS_ONLY_HINT = 'Имя канала не должно состоять только из цифр.'; const CHANNEL_LOGIN_DIGITS_ONLY_HINT = 'Имя канала не должно состоять только из цифр.';
function persistCreateSuccessFlash(message) { function persistCreateSuccessFlash(message) {
@@ -78,6 +78,17 @@ function shortAvatarBlockchainAddress(value) {
return raw.slice(-24); return raw.slice(-24);
} }
const TRANSLIT = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm',
н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch',
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
};
function suggestChannelLogin(title) {
const latin = Array.from(String(title || '').toLowerCase()).map((ch) => TRANSLIT[ch] ?? ch).join('');
return latin.replace(/[^a-z0-9_-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 32);
}
export function render({navigate, chrome}) { export function render({navigate, chrome}) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add'; screen.className = 'stack channels-screen channels-screen--add';
@@ -101,21 +112,31 @@ export function render({navigate, chrome}) {
</div> </div>
</div> </div>
<label for="channel-name">Технический логин канала</label> <div class="form-field">
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required /> <label for="channel-title">Название канала</label>
<div class="meta-muted channel-create-login-hint">${CHANNEL_LOGIN_HINT}</div> <input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой канал" autocomplete="off" />
<div class="meta-muted channel-link-preview" id="channel-link-preview">&nbsp;</div> <div class="form-field__meta">
<div id="channel-name-error" class="meta-muted inline-error"></div> <div id="channel-title-error" class="inline-error"></div>
<div class="form-field__counter" id="channel-title-counter">0 / 50</div>
</div>
</div>
<label for="channel-title">Как канал будет виден пользователям</label> <div class="form-field">
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" /> <label for="channel-name">Адрес канала</label>
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div> <input id="channel-name" class="input" maxlength="32" placeholder="my-channel" required autocomplete="off" autocapitalize="off" spellcheck="false" />
<div id="channel-title-error" class="meta-muted inline-error"></div> <div class="form-field__hint channel-create-login-hint">${CHANNEL_LOGIN_HINT} Заполняется из названия, можно поправить.</div>
<div class="form-field__hint channel-link-preview" id="channel-link-preview" hidden></div>
<div id="channel-name-error" class="inline-error"></div>
</div>
<label for="channel-description">Описание канала (необязательно)</label> <div class="form-field">
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea> <label for="channel-description">Описание <span class="form-field__optional">необязательно</span></label>
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div> <textarea id="channel-description" class="input" rows="3" maxlength="250" placeholder="Коротко о канале"></textarea>
<div id="channel-description-error" class="meta-muted inline-error"></div> <div class="form-field__meta">
<div id="channel-description-error" class="inline-error"></div>
<div class="form-field__counter" id="channel-description-counter">0 / 250</div>
</div>
</div>
<div id="channel-create-error" class="meta-muted inline-error"></div> <div id="channel-create-error" class="meta-muted inline-error"></div>
<div class="form-actions-grid"> <div class="form-actions-grid">
@@ -166,9 +187,11 @@ export function render({navigate, chrome}) {
const renderChannelLinkPreview = (nameValue) => { const renderChannelLinkPreview = (nameValue) => {
const normalizedChannelName = normalizeChannelDisplayName(nameValue); const normalizedChannelName = normalizeChannelDisplayName(nameValue);
if (!ownerBlockchainName || !normalizedChannelName) { if (!ownerBlockchainName || !normalizedChannelName) {
linkPreviewEl.innerHTML = '&nbsp;'; linkPreviewEl.hidden = true;
linkPreviewEl.textContent = '';
return; return;
} }
linkPreviewEl.hidden = false;
const directUrl = buildAbsoluteChannelUrl({ const directUrl = buildAbsoluteChannelUrl({
ownerBlockchainName, ownerBlockchainName,
channelName: normalizedChannelName, channelName: normalizedChannelName,
@@ -216,8 +239,8 @@ export function render({navigate, chrome}) {
titleErrorEl.textContent = titleCheck.error; titleErrorEl.textContent = titleCheck.error;
descriptionErrorEl.textContent = descriptionCheck.error; descriptionErrorEl.textContent = descriptionCheck.error;
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`; titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50`;
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`; descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250`;
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok; const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
submitEl.disabled = submitInFlight || !ok; submitEl.disabled = submitInFlight || !ok;
@@ -232,16 +255,19 @@ export function render({navigate, chrome}) {
}; };
}; };
let nameEditedByHand = false;
nameEl.addEventListener('input', () => { nameEl.addEventListener('input', () => {
nameEditedByHand = nameEl.value.trim() !== '';
const raw = String(nameEl.value || ''); const raw = String(nameEl.value || '');
const sanitized = sanitizeChannelLoginInput(raw); const sanitized = sanitizeChannelLoginInput(raw);
if (raw !== sanitized) { if (raw !== sanitized) nameEl.value = sanitized;
nameEl.value = sanitized; updateValidation();
window.alert(CHANNEL_LOGIN_HINT); if (raw !== sanitized) nameErrorEl.textContent = 'Недопустимые символы убраны: можно латиницу, цифры, _ и -.';
} });
titleEl.addEventListener('input', () => {
if (!nameEditedByHand) nameEl.value = suggestChannelLogin(titleEl.value);
updateValidation(); updateValidation();
}); });
titleEl.addEventListener('input', updateValidation);
descriptionEl.addEventListener('input', updateValidation); descriptionEl.addEventListener('input', updateValidation);
avatarBtn.addEventListener('click', async () => { avatarBtn.addEventListener('click', async () => {
try { try {
@@ -285,7 +311,7 @@ export function render({navigate, chrome}) {
const check = updateValidation(); const check = updateValidation();
if (check.name && /^[0-9]+$/.test(check.name)) { if (check.name && /^[0-9]+$/.test(check.name)) {
window.alert(CHANNEL_LOGIN_DIGITS_ONLY_HINT); nameErrorEl.textContent = CHANNEL_LOGIN_DIGITS_ONLY_HINT;
nameEl.focus(); nameEl.focus();
return; return;
} }
@@ -3,7 +3,7 @@ import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
import { normalizeChannelDescription } from '../services/channel-name-rules.js'; import { normalizeChannelDescription } from '../services/channel-name-rules.js';
export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый персональный публичный чат' }; export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый публичный чат' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PERSONAL = 100; const CHANNEL_TYPE_PERSONAL = 100;
@@ -52,7 +52,7 @@ export function render({navigate, chrome}) {
screen.className = 'stack channels-screen channels-screen--add'; screen.className = 'stack channels-screen channels-screen--add';
chrome?.setTopbar(createTopBar({ chrome?.setTopbar(createTopBar({
title: 'Новый персональный публичный чат', title: 'Новый публичный чат',
back: { label: '<', onClick: () => navigate('channels-list/dialogs') }, back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
})); }));
+2 -3
View File
@@ -32,10 +32,9 @@ export function render({ navigate, chrome }) {
})); }));
const intro = document.createElement('div'); const intro = document.createElement('div');
intro.className = 'card stack advanced-settings-intro'; intro.className = 'settings-intro';
intro.innerHTML = ` intro.innerHTML = `
<p class="field-label">Скрытые возможности SHiNE</p> <p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по логотипу в настройках.</p>
<p class="meta-muted">Этот экран открывается пятью быстрыми нажатиями по логотипу в обычных настройках и имеет постоянный адрес <code>/settings/advanced</code>.</p>
`; `;
const developerRow = createToggleRow({ const developerRow = createToggleRow({
+5 -9
View File
@@ -18,17 +18,14 @@ export function render({navigate, chrome}) {
chrome?.setTopbar(createTopBar({ chrome?.setTopbar(createTopBar({
title: 'Лог приложения', title: 'Лог приложения',
back: { label: '←', onClick: () => navigate('developer-settings-view') }, back: { label: '←', onClick: () => navigate('developer-settings-view') },
actions: [{ icon: 'refresh', title: 'Обновить лог', ariaLabel: 'Обновить лог', onClick: () => renderEntries() }],
})); }));
const controls = document.createElement('div'); const controls = document.createElement('div');
controls.className = 'card row'; controls.className = 'action-pair';
controls.style.justifyContent = 'flex-start';
controls.style.gap = '8px';
controls.style.flexWrap = 'wrap';
controls.innerHTML = ` controls.innerHTML = `
<button class="ghost-btn" type="button" data-action="refresh">Обновить</button> <button class="secondary-btn" type="button" data-action="copy-all">Скопировать всё</button>
<button class="ghost-btn" type="button" data-action="copy-all">Скопировать всё</button> <button class="destructive-btn" type="button" data-action="clear">Очистить</button>
<button class="ghost-btn" type="button" data-action="clear">Очистить</button>
`; `;
const status = document.createElement('div'); const status = document.createElement('div');
@@ -77,7 +74,7 @@ export function render({navigate, chrome}) {
if (!entries.length) { if (!entries.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'empty-note';
empty.textContent = 'Лог пока пуст.'; empty.textContent = 'Лог пока пуст.';
list.append(empty); list.append(empty);
status.className = 'status-line is-available'; status.className = 'status-line is-available';
@@ -117,7 +114,6 @@ export function render({navigate, chrome}) {
status.textContent = `Записей: ${entries.length}`; status.textContent = `Записей: ${entries.length}`;
} }
controls.querySelector('[data-action="refresh"]').addEventListener('click', renderEntries);
controls.querySelector('[data-action="copy-all"]').addEventListener('click', async () => { controls.querySelector('[data-action="copy-all"]').addEventListener('click', async () => {
const entries = getAppLogEntries(); const entries = getAppLogEntries();
if (!entries.length) { if (!entries.length) {
+4 -4
View File
@@ -48,7 +48,7 @@ function renderTile(item, index) {
if (item.pendingPlacement) { if (item.pendingPlacement) {
const flag = document.createElement('span'); const flag = document.createElement('span');
flag.className = 'ar-attachment-placement-flag'; flag.className = 'ar-attachment-placement-flag';
flag.textContent = 'Не добавлен в SHiNE'; flag.textContent = 'Ещё не прикреплён к записи';
meta.append(flag); meta.append(flag);
} }
@@ -92,8 +92,8 @@ export function render({navigate, chrome}) {
if (!rows.length) { if (!rows.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'empty-note';
empty.textContent = 'Загруженные в этой сессии файлы появятся здесь.'; empty.textContent = 'Файлов пока нет. Нажмите «+», чтобы загрузить файл заранее и прикрепить его к записи позже.';
list.append(empty); list.append(empty);
return; return;
} }
@@ -115,7 +115,7 @@ export function render({navigate, chrome}) {
} }
const topbar = createTopBar({ const topbar = createTopBar({
title: 'Загрузка файлов в блокчейн', title: 'Файлы в блокчейне',
back: { onClick: () => navigate('settings-view') }, back: { onClick: () => navigate('settings-view') },
actions: [ actions: [
{ {
+57 -41
View File
@@ -1,7 +1,8 @@
import { iconHtml } from '../components/ui-icon.js';
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { renderAvatar } from '../components/avatar-image.js'; import { renderAvatar } from '../components/avatar-image.js';
import { authService, setChannelsFeed, state } from '../state.js'; import { authService, setChannelsFeed, state } from '../state.js';
import { navigateBack } from '../router.js'; import { navigateBack, getPreviousTrackedPath } from '../router.js';
import { import {
extractLoginFromBlockchainName, extractLoginFromBlockchainName,
makeProfileRoute, makeProfileRoute,
@@ -251,7 +252,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
const login = String(state.session.login || '').trim(); const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim(); const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.'); if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
let wallet; let wallet;
if (keyId === 'root-key') { if (keyId === 'root-key') {
@@ -410,12 +411,21 @@ function confirmUnsubscribeModal({ channelTitle }) {
}); });
} }
function subscribersWord(count) {
const n = Math.abs(Number(count) || 0) % 100;
const last = n % 10;
if (n > 10 && n < 20) return 'подписчиков';
if (last === 1) return 'подписчик';
if (last >= 2 && last <= 4) return 'подписчика';
return 'подписчиков';
}
export function render({ navigate, route, chrome }) { export function render({ navigate, route, chrome }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel-about'; screen.className = 'stack channels-screen channels-screen--channel-about';
const topbar = createTopBar({ const topbar = createTopBar({
title: '', title: 'О канале',
back: { back: {
label: '←', label: '←',
onClick: () => { onClick: () => {
@@ -471,61 +481,66 @@ export function render({ navigate, route, chrome }) {
channelRootBlockHash: selector?.channelRootBlockHash, channelRootBlockHash: selector?.channelRootBlockHash,
}); });
const serverLink = buildChannelLink(shortRoute); const serverLink = buildChannelLink(shortRoute);
const shortLink = String(serverLink || '').replace(/^https?:\/\//, '');
const cameFromChannel = !!shortRoute && (getPreviousTrackedPath() || '').replace(/^\//, '') === String(shortRoute).replace(/^\//, '');
const ownerName = ownerDisplayName(ownerProfile, ownerLogin); const ownerName = ownerDisplayName(ownerProfile, ownerLogin);
const subscriptionTile = isOwnChannel ? '' : `
<button class="channel-about-tile${isSubscribed ? ' is-active' : ''}" id="channel-about-subscription" type="button">
${iconHtml(isSubscribed ? 'check' : 'plus')}<span>${isSubscribed ? 'Вы подписаны' : 'Подписаться'}</span>
</button>`;
content.innerHTML = ` content.innerHTML = `
<div class="channel-about-hero"> <div class="channel-about-hero">
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div> <div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2> <h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
<div class="channel-about-technical">${escapeHtml(ownerLogin)} / ${escapeHtml(channelName)}</div> <div class="channel-about-subline">@${escapeHtml(ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div>
<div class="channel-about-subscribers">${escapeHtml(statsText(subscribersCount))} подписчиков</div>
</div> </div>
<section class="channel-about-section"> <div class="channel-about-tiles">
<h3>О канале</h3> ${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`}
${subscriptionTile}
<button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Донат</span></button>
</div>
<div class="channel-about-list">
<div class="channel-about-row channel-about-row--static">
<span class="channel-about-row-label">Описание</span>
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p> <p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
</section>
<section class="channel-about-section channel-about-owner-section">
<h3>Владелец канала</h3>
<button class="channel-about-owner-link" id="channel-about-owner" type="button">
<strong>${escapeHtml(ownerName)}</strong>
<span>@${escapeHtml(ownerLogin)}</span>
</button>
<button class="secondary-btn channel-about-support-btn" id="channel-about-support" type="button">Донат автору</button>
</section>
<section class="channel-about-section">
<h3>Ссылка на этом сервере</h3>
<div class="channel-about-link-box">
<a href="${escapeHtml(serverLink)}">${escapeHtml(serverLink)}</a>
<button class="icon-btn channel-about-copy-btn" id="channel-about-copy" type="button" aria-label="Скопировать ссылку" title="Скопировать ссылку">⧉</button>
</div> </div>
</section> <button class="channel-about-row" id="channel-about-owner" type="button">
<span class="channel-about-owner-avatar" id="channel-about-owner-avatar"></span>
<button class="primary-btn channel-about-open-btn" id="channel-about-open" type="button">Открыть канал</button> <span class="channel-about-row-main">
${isOwnChannel ? '' : ` <strong>${escapeHtml(ownerName)}</strong>
<button class="${isSubscribed ? 'destructive-btn is-unsubscribe' : 'secondary-btn'} channel-about-subscription-btn" id="channel-about-subscription" type="button"> <span class="channel-about-row-label">Владелец · @${escapeHtml(ownerLogin)}</span>
${isSubscribed ? 'Отписаться от канала' : 'Подписаться на канал'} </span>
<span class="channel-about-row-chevron">${iconHtml('chevron')}</span>
</button> </button>
`} <button class="channel-about-row" id="channel-about-copy-row" type="button">
<span class="channel-about-row-main">
<span class="channel-about-row-label">Ссылка</span>
<span class="channel-about-link">${escapeHtml(shortLink)}</span>
</span>
<span class="channel-about-row-chevron" aria-hidden="true">${iconHtml('link')}</span>
</button>
</div>
`; `;
if (String(channel?.avaAr || '').trim()) { const channelAvatar = renderAvatar({
const avatar = renderAvatar({
initials: cleanName.slice(0, 1).toUpperCase() || 'К', initials: cleanName.slice(0, 1).toUpperCase() || 'К',
avatar: { ar: String(channel.avaAr || '').trim() }, avatar: String(channel?.avaAr || '').trim() ? { ar: String(channel.avaAr || '').trim() } : null,
size: 'xl', size: 'xl',
className: 'channel-about-avatar channel-profile-avatar', className: 'channel-about-avatar channel-profile-avatar',
title: cleanName, title: cleanName,
alt: 'Аватар канала', alt: 'Аватар канала',
}); });
avatar.style.setProperty('--channel-avatar-size', '112px'); content.querySelector('#channel-about-avatar-slot')?.append(channelAvatar);
content.querySelector('#channel-about-avatar-slot')?.append(avatar); content.querySelector('#channel-about-owner-avatar')?.append(renderAvatar({
} else { initials: ownerName.slice(0, 1).toUpperCase() || '?',
content.querySelector('#channel-about-avatar-slot')?.remove(); size: 'md',
} className: 'channel-about-owner-avatar-img',
alt: '',
}));
content.querySelector('#channel-about-owner')?.addEventListener('click', () => { content.querySelector('#channel-about-owner')?.addEventListener('click', () => {
const profileRoute = makeProfileRoute(ownerLogin); const profileRoute = makeProfileRoute(ownerLogin);
@@ -537,7 +552,7 @@ export function render({ navigate, route, chrome }) {
content.querySelector('#channel-about-open')?.addEventListener('click', () => { content.querySelector('#channel-about-open')?.addEventListener('click', () => {
if (shortRoute) navigate(shortRoute); if (shortRoute) navigate(shortRoute);
}); });
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => { const copyLink = async () => {
if (!serverLink) return; if (!serverLink) return;
try { try {
await navigator.clipboard.writeText(serverLink); await navigator.clipboard.writeText(serverLink);
@@ -545,7 +560,8 @@ export function render({ navigate, route, chrome }) {
} catch (error) { } catch (error) {
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' }); showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
} }
}); };
content.querySelector('#channel-about-copy-row')?.addEventListener('click', copyLink);
const subscriptionButton = content.querySelector('#channel-about-subscription'); const subscriptionButton = content.querySelector('#channel-about-subscription');
subscriptionButton?.addEventListener('click', async () => { subscriptionButton?.addEventListener('click', async () => {
+1 -1
View File
@@ -238,7 +238,7 @@ export function render({ navigate, route, chrome }) {
const login = String(state.session.login || '').trim(); const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim(); const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.'); if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
let wallet; let wallet;
if (keyId === 'root-key') { if (keyId === 'root-key') {
+151 -227
View File
@@ -1,3 +1,7 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { iconHtml } from '../components/ui-icon.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
import { attachMessageMenu } from '../components/message-menu.js';
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js'; import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
import { captureClientError } from '../services/client-error-reporter.js'; import { captureClientError } from '../services/client-error-reporter.js';
@@ -5,6 +9,7 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import { import {
animatePress, animatePress,
createSkeletonCard, createSkeletonCard,
formatRelativeTime,
longPressFeel, longPressFeel,
shareOrCopyLink, shareOrCopyLink,
showToast, showToast,
@@ -12,18 +17,17 @@ import {
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js'; import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; import { openChannelEditor } from '../components/channel-editor.js';
import { import {
composeMessageWithAttachments, composeMessageWithAttachments,
createAttachmentCarouselElement, createAttachmentCarouselElement,
escapeHtml, escapeHtml,
MAX_MESSAGE_ATTACHMENTS,
parseMessageAttachments, parseMessageAttachments,
} from '../services/attachment-format.js'; } from '../services/attachment-format.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js'; import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden' } }; export const pageMeta = { id: 'channel-thread-view', title: 'Обсуждение', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const MSG_SUBTYPE_TEXT_POST = 10; const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_RATING = 30; const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100; const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
@@ -63,7 +67,7 @@ function createThreadAvatar(login) {
const avatarEl = renderUserAvatar({ const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown', login: cleanLogin || 'unknown',
size: 'sm', size: 'sm',
className: 'channel-message-avatar', className: 'channel-message-avatar avatar-plain',
title, title,
}); });
if (!cleanLogin) return avatarEl; if (!cleanLogin) return avatarEl;
@@ -78,7 +82,7 @@ function createThreadAvatar(login) {
} }
: null, : null,
size: 'sm', size: 'sm',
className: 'channel-message-avatar', className: 'channel-message-avatar avatar-plain',
title, title,
}); });
avatarEl.replaceWith(upgraded); avatarEl.replaceWith(upgraded);
@@ -198,6 +202,13 @@ function allFeedSummaries() {
}); });
} }
const channelTitleByLabel = new Map();
function rememberChannelTitle(label, channel) {
const title = String(channel?.displayName || channel?.displayTitle || '').trim();
if (label && title) channelTitleByLabel.set(label, title);
}
function resolveChannelDisplayName(channelSelector) { function resolveChannelDisplayName(channelSelector) {
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber; const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber;
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash; const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
@@ -212,7 +223,9 @@ function resolveChannelDisplayName(channelSelector) {
&& normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash && normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash
)); ));
if (!found) return ''; if (!found) return '';
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`; const label = `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
rememberChannelTitle(label, found.channel);
return label;
} }
function resolveChannelHeadingFromNode(node) { function resolveChannelHeadingFromNode(node) {
@@ -297,7 +310,9 @@ async function resolveChannelDisplayNameFromServer(channelSelector) {
if (!row?.channel?.channelName) return ''; if (!row?.channel?.channelName) return '';
channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash); channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash);
return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`; const label = `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
rememberChannelTitle(label, row.channel);
return label;
} catch { } catch {
return ''; return '';
} }
@@ -444,7 +459,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<div class="modal" id="thread-blockchain-details-modal"> <div class="modal" id="thread-blockchain-details-modal">
<div class="modal-card stack blockchain-details-card"> <div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3> <h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p> <p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid"> <div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong> <span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code> <span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
@@ -490,120 +505,18 @@ function resolveNodeText(node) {
); );
} }
function renderDraftAttachments(container, attachments) { function openReplyModal({ onSubmit, mode = 'reply', isActive = () => true, context = null, draftKey = 'thread-reply' }) {
if (!container) return;
container.innerHTML = '';
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ui-button draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
const ok = window.confirm('Отменить вложение?');
if (!ok) return;
attachments.splice(index, 1);
renderDraftAttachments(container, attachments);
});
container.append(button);
});
}
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
const isRating = mode === 'rating'; const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ'; return openChannelEditor({
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа'; id: 'thread-reply-modal',
const emptyError = isRating title: isRating ? 'Оценка' : 'Ответ',
? 'Введите текст оценки или добавьте вложение.' submitLabel: isRating ? 'Отправить' : 'Ответить',
: 'Введите текст ответа или добавьте вложение.'; placeholder: isRating ? 'Напишите оценку' : 'Напишите ответ',
const submitError = isRating context,
? 'Не удалось отправить оценку.' key: `${draftKey}:${mode}`,
: 'Не удалось отправить ответ.'; isActive,
const root = document.getElementById('modal-root'); onSubmit: ({ text }) => onSubmit(text),
root.innerHTML = `
<div class="modal" id="thread-reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">${title}</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
<div class="draft-attachments" id="thread-reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
<div class="meta-muted inline-error" id="thread-reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
<button class="primary-btn" id="thread-reply-submit" type="button">Отправить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#thread-reply-text');
const attachmentsEl = root.querySelector('#thread-reply-attachments');
const errorEl = root.querySelector('#thread-reply-error');
const submitEl = root.querySelector('#thread-reply-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#thread-reply-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text && attachments.length === 0) {
errorEl.textContent = emptyError;
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(composeMessageWithAttachments(text, attachments));
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, submitError);
}
}); });
root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-reply-submit')?.click());
if (textEl) textEl.focus();
} }
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) { function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
@@ -722,58 +635,13 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
}); });
} }
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) { function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, isActive = () => true, draftKey }) {
const root = document.getElementById('modal-root'); return openChannelEditor({
root.innerHTML = ` id: 'thread-edit-modal', title: 'Редактирование', submitLabel: 'Сохранить',
<div class="modal" id="thread-edit-modal"> placeholder: 'Текст сообщения', initialText, allowEmptyText,
<div class="modal-card stack"> key: draftKey, allowAttachments: false, rawText: true, isActive,
<h3 class="modal-title">Редактировать сообщение</h3> onSubmit: ({ text }) => onSave(text),
<textarea id="thread-edit-text" class="input" rows="6" maxlength="2000"></textarea>
<div class="meta-muted inline-error" id="thread-edit-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-edit-cancel" type="button">Отмена</button>
<button class="primary-btn" id="thread-edit-save" type="button">ОК</button>
</div>
<button class="destructive-btn modal-danger-action" id="thread-edit-delete" type="button">Удалить</button>
</div>
</div>
`;
const textEl = root.querySelector('#thread-edit-text');
const errorEl = root.querySelector('#thread-edit-error');
if (textEl) textEl.value = String(initialText || '');
const close = () => {
root.innerHTML = '';
};
root.querySelector('#thread-edit-cancel')?.addEventListener('click', close);
root.querySelector('#thread-edit-save')?.addEventListener('click', async () => {
const value = String(textEl?.value || '').trim();
if (!value && !allowEmptyText) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
try {
await onSave(value);
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
}
}); });
root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => {
try {
await onDelete();
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-edit-save')?.click());
if (textEl) textEl.focus();
} }
function renderNodeCard(node, heading, handlers, localNumber) { function renderNodeCard(node, heading, handlers, localNumber) {
@@ -789,7 +657,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const shiningLikes = Number(node?.shiningLikesCount || 0); const shiningLikes = Number(node?.shiningLikesCount || 0);
const replies = Number(node?.repliesCount || 0); const replies = Number(node?.repliesCount || 0);
const ratings = Number(node?.ratingsCount || 0); const ratings = Number(node?.ratingsCount || 0);
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(); const isOwnMessage = Boolean(state.session.isAuthorized && state.session.login) && String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login).trim().toLowerCase();
const msgSubType = Number(node?.msgSubType || 0); const msgSubType = Number(node?.msgSubType || 0);
const isChannelPost = isEditableAsChannelPostSubType(msgSubType); const isChannelPost = isEditableAsChannelPostSubType(msgSubType);
const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING; const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING;
@@ -809,6 +677,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const authorTile = document.createElement('button'); const authorTile = document.createElement('button');
authorTile.type = 'button'; authorTile.type = 'button';
authorTile.className = 'ui-button channel-message-author-tile'; authorTile.className = 'ui-button channel-message-author-tile';
const menuItems = [];
const headRow = document.createElement('div'); const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row'; headRow.className = 'channel-message-head-row';
@@ -829,7 +698,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
titleMain.append(loginEl, numberEl); titleMain.append(loginEl, numberEl);
title.append(titleMain); title.append(titleMain);
if (versionsTotal > 1) { if (versionsTotal > 1) {
const editedMarker = document.createElement('button'); const editedMarker = document.createElement('span');
editedMarker.type = 'button'; editedMarker.type = 'button';
editedMarker.className = 'ui-button message-edited-marker'; editedMarker.className = 'ui-button message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`; editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
@@ -846,7 +715,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
} }
const timestamp = document.createElement('div'); const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time'; timestamp.className = 'channel-message-time';
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—'; timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '—';
if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
authorBlock.append(title, timestamp); authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock); authorTile.append(avatar, authorBlock);
headRow.append(authorTile); headRow.append(authorTile);
@@ -928,21 +798,17 @@ function renderNodeCard(node, heading, handlers, localNumber) {
likeButton.className = 'ui-button channel-action-item thread-like-btn'; likeButton.className = 'ui-button channel-action-item thread-like-btn';
if (isLiked) likeButton.classList.add('is-liked'); if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = ` likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span> <span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span> <span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter" title="Все / основные / сияющие">${likes}/${primaryLikes}/${shiningLikes}</span> <span class="channel-action-counter">${Number(likes) > 0 ? likes : ''}</span>
`; `;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк'); likeButton.setAttribute('aria-pressed', String(isLiked));
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
likeButton.disabled = isPending; likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => { likeButton.addEventListener('click', async (event) => {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
if (isPending) return; if (isPending) return;
if (!isLiked) {
const ok = window.confirm('Поставить лайк?');
if (!ok) return;
}
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true; likeButton.disabled = true;
setActionTitle(likeButton, 'Лайк...'); setActionTitle(likeButton, 'Лайк...');
try { try {
@@ -954,6 +820,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
targetBlockNumber: target?.blockNumber, targetBlockNumber: target?.blockNumber,
}); });
handlers?.onActionError?.(error, isLiked ? 'unlike' : 'like'); handlers?.onActionError?.(error, isLiked ? 'unlike' : 'like');
} finally {
if (likeButton.isConnected) likeButton.disabled = false;
} }
}); });
@@ -961,18 +829,23 @@ function renderNodeCard(node, heading, handlers, localNumber) {
replyButton.type = 'button'; replyButton.type = 'button';
replyButton.className = 'ui-button channel-action-item thread-reply-btn'; replyButton.className = 'ui-button channel-action-item thread-reply-btn';
replyButton.innerHTML = ` replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span> <span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Ответить</span> <span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span> <span class="channel-action-counter">${Number(replies) > 0 ? replies : ''}</span>
`; `;
setActionTitle(replyButton, 'Ответить'); setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => { replyButton.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
openReplyModal({ openReplyModal({
navigate: handlers.navigate,
onSubmit: async (textValue) => handlers.onReply(target, textValue), onSubmit: async (textValue) => handlers.onReply(target, textValue),
isActive: handlers.isActive, isActive: handlers.isActive,
draftKey: `message:${refKey}`,
context: {
author,
text: parsedText.text,
attachmentLabel: parsedText.attachments[0]?.name || '',
},
}); });
}); });
// Rating/opinion action is intentionally hidden from UI for now. // Rating/opinion action is intentionally hidden from UI for now.
@@ -982,7 +855,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
shareButton.type = 'button'; shareButton.type = 'button';
shareButton.className = 'ui-button channel-action-item thread-share-btn'; shareButton.className = 'ui-button channel-action-item thread-share-btn';
shareButton.innerHTML = ` shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↗</span> <span class="channel-action-icon" aria-hidden="true">${iconHtml('share')}</span>
<span class="channel-action-label">Отправить</span> <span class="channel-action-label">Отправить</span>
`; `;
setActionTitle(shareButton, 'Отправить'); setActionTitle(shareButton, 'Отправить');
@@ -994,7 +867,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
// Репосты временно отключены до будущей реализации. // Репосты временно отключены до будущей реализации.
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md // Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton, shareButton); const discussionButton = document.createElement('button');
discussionButton.type = 'button';
discussionButton.className = 'ui-button channel-action-item';
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span>${Number(replies) > 0 ? `<span>${replies}</span>` : ''}`;
discussionButton.setAttribute('aria-label', `Открыть обсуждение, ответов: ${replies}`);
discussionButton.addEventListener('click', () => handlers.onOpenThread(target));
actions.append(likeButton, discussionButton, shareButton, replyButton);
if (repostTarget) { if (repostTarget) {
const originalButton = document.createElement('button'); const originalButton = document.createElement('button');
originalButton.type = 'button'; originalButton.type = 'button';
@@ -1006,8 +885,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
setActionTitle(originalButton, 'Оригинал'); setActionTitle(originalButton, 'Оригинал');
originalButton.addEventListener('click', (event) => { originalButton.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
const ok = window.confirm('Перейти к оригинальному сообщению?');
if (!ok) return;
const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName); const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName);
if (!ownerLogin) return; if (!ownerLogin) return;
handlers.navigate(makeShineMessageRoute({ handlers.navigate(makeShineMessageRoute({
@@ -1016,7 +893,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
messageBlockNumber: repostTarget.blockNumber, messageBlockNumber: repostTarget.blockNumber,
})); }));
}); });
actions.append(originalButton); menuItems.push({ label: 'Оригинал', action: () => originalButton.click() });
} }
const detailsButton = document.createElement('button'); const detailsButton = document.createElement('button');
detailsButton.type = 'button'; detailsButton.type = 'button';
@@ -1038,7 +915,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
msgSubType, msgSubType,
}), { isActive: handlers.isActive }); }), { isActive: handlers.isActive });
}); });
actions.append(detailsButton); menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
if (isOwnMessage) { if (isOwnMessage) {
const editButton = document.createElement('button'); const editButton = document.createElement('button');
editButton.type = 'button'; editButton.type = 'button';
@@ -1052,14 +929,28 @@ function renderNodeCard(node, heading, handlers, localNumber) {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
openEditMessageModal({ openEditMessageModal({
isActive: handlers.isActive,
draftKey: `edit:${messageRefKey(target)}`,
initialText: String(text || '').trim() === 'удалено' ? '' : parsedText.text, initialText: String(text || '').trim() === 'удалено' ? '' : parsedText.text,
allowEmptyText: parsedText.attachments.length > 0, allowEmptyText: parsedText.attachments.length > 0,
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }), onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }),
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }), onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
}); });
}); });
actions.append(editButton); menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
if (!await confirmDialog({
title: 'Удалить сообщение?',
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
confirmLabel: 'Удалить',
danger: true,
})) return;
try { await handlers.onEdit(target, '', { isChannelPost, isDelete: true }); }
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
} });
} }
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: versions }) });
attachMessageMenu(card, headRow, menuItems);
card.append(actions); card.append(actions);
authorTile.addEventListener('click', (event) => { authorTile.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
@@ -1067,13 +958,10 @@ function renderNodeCard(node, heading, handlers, localNumber) {
if (!login) return; if (!login) return;
handlers.navigate(makeProfileRoute(login)); handlers.navigate(makeProfileRoute(login));
}); });
card.addEventListener('click', () => {
handlers.onOpenThread(target);
});
return card; return card;
} }
function renderDescendants(items, handlers, nextNumber, depth = 0) { function renderDescendants(items, handlers, nextNumber, depth = 0, parent = null) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'stack'; wrap.className = 'stack';
@@ -1083,11 +971,17 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
const nodeNumber = nextNumber(); const nodeNumber = nextNumber();
const row = renderNodeCard(branch?.node, '', handlers, nodeNumber); const row = renderNodeCard(branch?.node, '', handlers, nodeNumber);
row.classList.add('thread-node-level'); row.classList.add('thread-node-level');
row.style.setProperty('--depth', String(Math.min(depth, 4))); if (parent) {
const context = document.createElement('p');
context.className = 'thread-reply-context';
const excerpt = parseMessageAttachments(resolveNodeText(parent)).text;
context.textContent = `В ответ ${parent.authorLogin || 'автору'} · ${excerpt.slice(0, 100) || 'Вложение'}`;
row.prepend(context);
}
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, depth + 1, branch.node));
} }
} catch (error) { } catch (error) {
logThreadRuntimeError('render_descendants_branch', error, { depth, index }); logThreadRuntimeError('render_descendants_branch', error, { depth, index });
@@ -1139,6 +1033,7 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--thread'; screen.className = 'stack channels-screen channels-screen--thread';
const positionKey = `${state.session.login}:thread:${routeKey}`;
let disposed = false; let disposed = false;
let refreshSeq = 0; let refreshSeq = 0;
@@ -1148,21 +1043,18 @@ export function render({ navigate, route, chrome }) {
const threadHeaderButton = document.createElement('button'); const threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button'; threadHeaderButton.type = 'button';
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action'; threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
threadHeaderButton.textContent = 'Тред в канале: ...'; const threadHeaderTitle = document.createElement('span');
threadHeaderTitle.className = 'channel-header-title';
threadHeaderTitle.textContent = 'Обсуждение';
const threadHeaderChannel = document.createElement('span');
threadHeaderChannel.className = 'channel-header-owner';
threadHeaderChannel.textContent = '…';
threadHeaderButton.append(threadHeaderTitle, threadHeaderChannel);
threadHeaderButton.disabled = true; threadHeaderButton.disabled = true;
const header = createTopBar({ const header = createTopBar({
center: threadHeaderButton, center: threadHeaderButton,
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) }, back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
actions: [
{
label: '↑',
title: 'К списку каналов',
ariaLabel: 'К списку каналов',
className: 'channel-thread-list-btn',
onClick: () => navigate('channels-list'),
},
],
}); });
header.classList.add('channel-thread-topbar'); header.classList.add('channel-thread-topbar');
chrome?.setTopbar(header); chrome?.setTopbar(header);
@@ -1172,7 +1064,7 @@ export function render({ navigate, route, chrome }) {
statusBox.style.display = 'none'; statusBox.style.display = 'none';
const ensureActive = () => { const ensureActive = () => {
if (disposed) throw new Error('Экран треда уже закрыт.'); if (disposed) throw new Error('Экран обсуждения уже закрыт.');
}; };
const showStatus = (message) => { const showStatus = (message) => {
@@ -1297,10 +1189,10 @@ export function render({ navigate, route, chrome }) {
onShare: async (target) => { onShare: async (target) => {
try { try {
const routePath = buildThreadRouteFromTarget(target, selector); const routePath = buildThreadRouteFromTarget(target, selector);
if (!routePath) throw new Error('Не удалось подготовить ссылку на тред.'); if (!routePath) throw new Error('Не удалось подготовить ссылку на обсуждение.');
const result = await shareOrCopyLink({ const result = await shareOrCopyLink({
title: 'SHiNE · Тред', title: 'Сияние · Обсуждение',
text: 'Сообщение из треда SHiNE', text: 'Сообщение из обсуждения в Сиянии',
url: buildAbsoluteRouteUrl(routePath), url: buildAbsoluteRouteUrl(routePath),
}); });
if (disposed) return; if (disposed) return;
@@ -1314,7 +1206,7 @@ export function render({ navigate, route, chrome }) {
onOpenThread: (target) => { onOpenThread: (target) => {
const routePath = buildThreadRouteFromTarget(target, selector); const routePath = buildThreadRouteFromTarget(target, selector);
if (!routePath) { if (!routePath) {
showStatus('Не удалось определить путь до треда.'); showStatus('Не удалось определить ссылку на обсуждение.');
return; return;
} }
navigate(routePath); navigate(routePath);
@@ -1348,6 +1240,8 @@ export function render({ navigate, route, chrome }) {
screen.append(statusBox); screen.append(statusBox);
const clearContent = () => { const clearContent = () => {
chrome?.setComposer(null);
screen.querySelectorAll('.channel-message-card').forEach((card) => card.cleanup?.());
for (const timerId of refreshTimers) window.clearTimeout(timerId); for (const timerId of refreshTimers) window.clearTimeout(timerId);
refreshTimers.clear(); refreshTimers.clear();
Array.from(screen.children).forEach((child) => { Array.from(screen.children).forEach((child) => {
@@ -1365,6 +1259,7 @@ export function render({ navigate, route, chrome }) {
'#thread-reply-modal', '#thread-reply-modal',
'#thread-repost-modal', '#thread-repost-modal',
].join(','))) { ].join(','))) {
modalRoot.querySelectorAll('.channel-editor-overlay').forEach((editor) => editor.cleanup?.());
modalRoot.innerHTML = ''; modalRoot.innerHTML = '';
} }
}; };
@@ -1377,23 +1272,25 @@ export function render({ navigate, route, chrome }) {
refresh = async () => { refresh = async () => {
if (disposed) return; if (disposed) return;
const seq = ++refreshSeq; const seq = ++refreshSeq;
clearContent(); const hadContent = !!screen.querySelector('.thread-block');
const restorePosition = hadContent ? document.getElementById('app-screen')?.scrollTop : readChannelPosition(positionKey);
if (!hadContent) clearContent();
showStatus(''); showStatus('');
selector = parseThreadSelector(route); selector = parseThreadSelector(route);
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel); activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
threadHeaderButton.textContent = 'Тред в канале: ...'; threadHeaderChannel.textContent = '…';
threadHeaderButton.disabled = true; threadHeaderButton.disabled = true;
threadHeaderButton.onclick = null; threadHeaderButton.onclick = null;
if (!selector) { if (!selector) {
const invalid = document.createElement('div'); const invalid = document.createElement('div');
invalid.className = 'card meta-muted'; invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.'; invalid.textContent = 'Обсуждение не найдено: неверная ссылка.';
screen.append(invalid); screen.append(invalid);
return; return;
} }
const skeleton = renderSkeleton(screen); const skeleton = hadContent ? null : renderSkeleton(screen);
try { try {
let resolvedMessage = selector.message; let resolvedMessage = selector.message;
@@ -1468,7 +1365,7 @@ export function render({ navigate, route, chrome }) {
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login); const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
if (disposed || seq !== refreshSeq) return; if (disposed || seq !== refreshSeq) return;
skeleton.remove(); skeleton?.remove();
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : []; const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
const focus = payload?.focus || null; const focus = payload?.focus || null;
@@ -1499,7 +1396,8 @@ export function render({ navigate, route, chrome }) {
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно'; const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel; const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
if (threadHeaderButton) { if (threadHeaderButton) {
threadHeaderButton.textContent = `Тред в канале: ${resolvedChannelTitle}`; threadHeaderChannel.textContent = `в канале «${channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle}»`;
threadHeaderButton.title = 'Открыть канал';
threadHeaderButton.disabled = false; threadHeaderButton.disabled = false;
threadHeaderButton.onclick = (event) => { threadHeaderButton.onclick = (event) => {
event.preventDefault(); event.preventDefault();
@@ -1510,6 +1408,7 @@ export function render({ navigate, route, chrome }) {
}; };
} }
clearContent();
let localSeq = 0; let localSeq = 0;
const nextNumber = () => { const nextNumber = () => {
localSeq += 1; localSeq += 1;
@@ -1532,24 +1431,40 @@ export function render({ navigate, route, chrome }) {
focusWrap.className = 'stack thread-block thread-block--focus'; focusWrap.className = 'stack thread-block thread-block--focus';
const focusTitle = document.createElement('h3'); const focusTitle = document.createElement('h3');
focusTitle.className = 'section-title'; focusTitle.className = 'section-title';
focusTitle.textContent = 'Текущее сообщение'; focusTitle.textContent = 'Исходное сообщение';
focusWrap.append(focusTitle);
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber())); focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
const composer = document.createElement('div');
composer.className = 'channel-composer';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'channel-compose-bar';
reply.innerHTML = `<span class="channel-compose-bar__text">${state.session.isAuthorized ? 'Написать ответ…' : 'Войти и ответить'}</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
reply.addEventListener('click', () => {
const parsed = parseMessageAttachments(resolveNodeText(focus));
openReplyModal({
draftKey: `message:${messageRefKey(buildTargetFromNode(focus))}`,
context: { author: focus.authorLogin, text: parsed.text, attachmentLabel: parsed.attachments[0]?.name },
isActive: () => !disposed,
onSubmit: (text) => handlers.onReply(buildTargetFromNode(focus), text),
});
});
composer.append(reply);
chrome?.setComposer(composer);
} }
const descendantsWrap = document.createElement('div'); const descendantsWrap = document.createElement('div');
descendantsWrap.className = 'stack thread-block thread-block--replies'; descendantsWrap.className = 'stack thread-block thread-block--replies';
const descendantsTitle = document.createElement('h3'); const descendantsTitle = document.createElement('h3');
descendantsTitle.className = 'section-title'; descendantsTitle.className = 'section-title';
descendantsTitle.textContent = 'Ответы и оценки'; descendantsTitle.textContent = `Ответы · ${Math.max(0, Number(focus?.repliesCount || descendants.length))}`;
descendantsWrap.append(descendantsTitle); descendantsWrap.append(descendantsTitle);
if (descendants.length) { if (descendants.length) {
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber)); descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
} else { } else {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'empty-note';
empty.textContent = 'Ответов и оценок пока нет.'; empty.textContent = 'Пока нет ответов. Начните обсуждение.';
descendantsWrap.append(empty); descendantsWrap.append(empty);
} }
@@ -1565,7 +1480,8 @@ export function render({ navigate, route, chrome }) {
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq)); trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
const hasPendingScroll = pendingThreadScroll.has(routeKey); const hasPendingScroll = pendingThreadScroll.has(routeKey);
if (!hasPendingScroll && focusWrap) { if (!hasPendingScroll && Number.isFinite(restorePosition)) restoreChannelPosition(restorePosition);
if (!hasPendingScroll && !Number.isFinite(restorePosition) && focusWrap) {
trackTimer(window.setTimeout(() => { trackTimer(window.setTimeout(() => {
if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return; if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return;
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' }); focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
@@ -1573,10 +1489,17 @@ export function render({ navigate, route, chrome }) {
} }
} catch (error) { } catch (error) {
if (disposed || seq !== refreshSeq) return; if (disposed || seq !== refreshSeq) return;
skeleton.remove(); skeleton?.remove();
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить обсуждение.')); return; }
const failed = document.createElement('div'); const failed = document.createElement('div');
failed.className = 'card meta-muted'; failed.className = 'card meta-muted';
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`; failed.textContent = `Не удалось загрузить обсуждение: ${toUserMessage(error, 'неизвестная ошибка')}`;
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'primary-btn';
retry.textContent = 'Повторить';
retry.addEventListener('click', () => void refresh());
failed.append(retry);
screen.append(failed); screen.append(failed);
} }
}; };
@@ -1584,6 +1507,7 @@ export function render({ navigate, route, chrome }) {
screen.refresh = refresh; screen.refresh = refresh;
screen.cleanup = () => { screen.cleanup = () => {
if (disposed) return; if (disposed) return;
rememberChannelPosition(positionKey);
disposed = true; disposed = true;
refreshSeq += 1; refreshSeq += 1;
clearContent(); clearContent();
+211 -312
View File
@@ -1,3 +1,8 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { iconHtml } from '../components/ui-icon.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
import { getPreviousTrackedPath } from '../router.js';
import { attachMessageMenu } from '../components/message-menu.js';
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { import {
authService, authService,
@@ -10,6 +15,9 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import { import {
animatePress, animatePress,
createSkeletonCard, createSkeletonCard,
dayKey,
formatClockTime,
formatDayLabel,
formatRelativeTime, formatRelativeTime,
longPressFeel, longPressFeel,
shareOrCopyLink, shareOrCopyLink,
@@ -17,6 +25,7 @@ import {
softHaptic, softHaptic,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js'; import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
import { openChannelEditor } from '../components/channel-editor.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import { import {
composeMessageWithAttachments, composeMessageWithAttachments,
@@ -35,7 +44,7 @@ import {
} from '../services/shine-routes.js'; } from '../services/shine-routes.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden' } }; export const pageMeta = { id: 'channel-view', title: 'Канал', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100; const CHANNEL_TYPE_PERSONAL = 100;
const MSG_SUBTYPE_TEXT_RATING = 30; const MSG_SUBTYPE_TEXT_RATING = 30;
@@ -88,7 +97,7 @@ function createMessageAvatar(login) {
const avatarEl = renderUserAvatar({ const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown', login: cleanLogin || 'unknown',
size: 'sm', size: 'sm',
className: 'channel-message-avatar', className: 'channel-message-avatar avatar-plain',
title, title,
}); });
if (!cleanLogin) return avatarEl; if (!cleanLogin) return avatarEl;
@@ -103,7 +112,7 @@ function createMessageAvatar(login) {
} }
: null, : null,
size: 'sm', size: 'sm',
className: 'channel-message-avatar', className: 'channel-message-avatar avatar-plain',
title, title,
}); });
avatarEl.replaceWith(upgraded); avatarEl.replaceWith(upgraded);
@@ -769,7 +778,7 @@ function bindSubmitOnPlainEnter(textarea, submit) {
if (!(textarea instanceof HTMLTextAreaElement) || typeof submit !== 'function') return; if (!(textarea instanceof HTMLTextAreaElement) || typeof submit !== 'function') return;
textarea.addEventListener('keydown', (event) => { textarea.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return; if (event.key !== 'Enter') return;
if (event.shiftKey || event.ctrlKey) return; if (!(event.ctrlKey || event.metaKey) || event.isComposing) return;
event.preventDefault(); event.preventDefault();
submit(); submit();
}); });
@@ -824,7 +833,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<div class="modal" id="blockchain-details-modal"> <div class="modal" id="blockchain-details-modal">
<div class="modal-card stack blockchain-details-card"> <div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3> <h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p> <p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid"> <div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong> <span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code> <span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
@@ -870,8 +879,8 @@ function renderDraftAttachments(container, attachments) {
button.className = 'ui-button draft-attachment-chip'; button.className = 'ui-button draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`; button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение'; button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => { button.addEventListener('click', async () => {
const ok = window.confirm('Отменить вложение?'); const ok = await confirmDialog({ title: 'Убрать вложение?', text: item.name, confirmLabel: 'Убрать', danger: true });
if (!ok) return; if (!ok) return;
attachments.splice(index, 1); attachments.splice(index, 1);
renderDraftAttachments(container, attachments); renderDraftAttachments(container, attachments);
@@ -880,101 +889,18 @@ function renderDraftAttachments(container, attachments) {
}); });
} }
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) { function openReplyModal({ onSubmit, mode = 'reply', isActive = () => true, context = null, draftKey = 'reply' }) {
const isRating = mode === 'rating'; const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ'; return openChannelEditor({
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа'; id: 'reply-modal',
const emptyError = isRating title: isRating ? 'Оценка' : 'Ответ',
? 'Введите текст оценки или добавьте вложение.' submitLabel: isRating ? 'Отправить' : 'Ответить',
: 'Введите текст ответа или добавьте вложение.'; placeholder: isRating ? 'Напишите оценку' : 'Напишите ответ',
const submitError = isRating context,
? 'Не удалось отправить оценку.' key: `${draftKey}:${mode}`,
: 'Не удалось отправить ответ.'; isActive,
const root = document.getElementById('modal-root'); onSubmit: ({ text }) => onSubmit(text),
root.innerHTML = `
<div class="modal" id="reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">${title}</h3>
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
<div class="draft-attachments" id="reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
<div class="meta-muted inline-error" id="reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
<button class="primary-btn" id="reply-submit" type="button">Отправить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#reply-text');
const attachmentsEl = root.querySelector('#reply-attachments');
const errorEl = root.querySelector('#reply-error');
const submitEl = root.querySelector('#reply-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#reply-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#reply-cancel')?.addEventListener('click', close);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text && attachments.length === 0) {
errorEl.textContent = emptyError;
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(composeMessageWithAttachments(text, attachments));
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, submitError);
}
}); });
root.querySelector('#reply-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
} }
function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive = () => true }) { function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive = () => true }) {
@@ -1142,7 +1068,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
if (list) { if (list) {
if (!items.length) { if (!items.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'empty-note';
empty.textContent = 'Оглавление в этом канале пока не добавлялось.'; empty.textContent = 'Оглавление в этом канале пока не добавлялось.';
list.append(empty); list.append(empty);
} else { } else {
@@ -1246,104 +1172,31 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
} }
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) { function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
const root = document.getElementById('modal-root'); const typeWrap = document.createElement('label');
root.innerHTML = ` typeWrap.className = 'channel-editor__type';
<div class="modal" id="channel-message-modal"> typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
<div class="modal-card stack"> const typeSelect = document.createElement('select');
<h3 class="modal-title">Новое сообщение в канале</h3> typeSelect.className = 'select channel-editor__type-select';
<p class="meta-muted">${channelName}</p> typeSelect.title = 'Тип сообщения';
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea> typeSelect.innerHTML = `
<div class="draft-attachments" id="channel-message-attachments"></div> <option value="10">Публикация</option>
<div class="channel-message-tools"> <option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление</option>
<select id="channel-message-type" class="input channel-message-type-select">
<option value="${10}">Пост</option>
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
</select>
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
</div>
<div class="meta-muted inline-error" id="channel-message-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
<button class="primary-btn" id="channel-message-submit" type="button">Отправить</button>
</div>
</div>
</div>
`; `;
typeWrap.append(typeSelect);
const textEl = root.querySelector('#channel-message-text'); return openChannelEditor({
const typeEl = root.querySelector('#channel-message-type'); id: 'channel-message-modal',
const attachmentsEl = root.querySelector('#channel-message-attachments'); title: 'Новая запись',
const errorEl = root.querySelector('#channel-message-error'); submitLabel: 'Опубликовать',
const submitEl = root.querySelector('#channel-message-submit'); placeholder: 'Напишите сообщение',
const attachments = []; key: `channel-post:${channelName}`,
let inFlight = false; extraControl: typeWrap,
isActive,
const setBusy = (busy) => { onSubmit: ({ text }) => onSubmit({
inFlight = !!busy; text,
submitEl.disabled = inFlight; msgSubType: Number(typeSelect.value || 10),
if (textEl) textEl.disabled = inFlight; }),
if (typeEl) typeEl.disabled = inFlight;
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#channel-message-cancel')?.addEventListener('click', close);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const body = String(textEl?.value || '').trim();
const msgSubType = Number(typeEl?.value || 10);
if (!body && attachments.length === 0) {
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit({
text: composeMessageWithAttachments(body, attachments),
msgSubType,
}); });
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
}
});
root.querySelector('#channel-message-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
} }
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) { function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
@@ -1382,59 +1235,13 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
}); });
} }
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) { function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, isActive = () => true, draftKey }) {
const root = document.getElementById('modal-root'); return openChannelEditor({
root.innerHTML = ` id: 'edit-message-modal', title: 'Редактирование', submitLabel: 'Сохранить',
<div class="modal" id="edit-message-modal"> placeholder: 'Текст сообщения', initialText, allowEmptyText,
<div class="modal-card stack"> key: draftKey, allowAttachments: false, rawText: true, isActive,
<h3 class="modal-title">Редактировать сообщение</h3> onSubmit: ({ text }) => onSave(text),
<textarea id="edit-message-text" class="input" rows="6" maxlength="2000"></textarea>
<div class="meta-muted inline-error" id="edit-message-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="edit-message-cancel" type="button">Отмена</button>
<button class="primary-btn" id="edit-message-save" type="button">ОК</button>
</div>
<button class="destructive-btn modal-danger-action" id="edit-message-delete" type="button">Удалить</button>
</div>
</div>
`;
const textEl = root.querySelector('#edit-message-text');
const errorEl = root.querySelector('#edit-message-error');
if (textEl) textEl.value = String(initialText || '');
const close = () => {
root.innerHTML = '';
};
root.querySelector('#edit-message-cancel')?.addEventListener('click', close);
root.querySelector('#edit-message-save')?.addEventListener('click', async () => {
const value = String(textEl?.value || '').trim();
if (!value && !allowEmptyText) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
try {
await onSave(value);
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
}
}); });
root.querySelector('#edit-message-delete')?.addEventListener('click', async () => {
try {
await onDelete();
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#edit-message-save')?.click());
if (textEl) textEl.focus();
} }
function mapApiMessageToPost(message, selector, localNumber) { function mapApiMessageToPost(message, selector, localNumber) {
@@ -1488,7 +1295,7 @@ function mapApiMessageToPost(message, selector, localNumber) {
targetAuthorBlockchainName: String(message?.targetAuthorBlockchainName || '').trim(), targetAuthorBlockchainName: String(message?.targetAuthorBlockchainName || '').trim(),
targetCreatedAtMs: Number(message?.targetCreatedAtMs || 0), targetCreatedAtMs: Number(message?.targetCreatedAtMs || 0),
reactionState: messageRef ? getMessageReactionState(messageRef) : '', reactionState: messageRef ? getMessageReactionState(messageRef) : '',
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(), isOwnMessage: Boolean(state.session.isAuthorized && state.session.login) && String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login).trim().toLowerCase(),
}; };
} }
@@ -1810,14 +1617,15 @@ function mapChannelMetaEvent(event, fallbackChannel) {
}; };
} }
function renderChannelMetaEventCard(event) { function renderChannelMetaEventCard(event, dayLabel = '') {
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'card channel-system-event-card'; card.className = 'card channel-system-event-card';
const label = event.kind === 'created' const label = event.kind === 'created'
? 'Создан канал' ? 'Канал создан'
: 'Изменено описание канала'; : 'Изменено описание канала';
const shownLabel = dayLabel ? `${label} · ${dayLabel.toLocaleLowerCase('ru-RU')}` : label;
card.innerHTML = ` card.innerHTML = `
<span class="channel-system-event-card__label">${escapeHtml(label)}</span> <span class="channel-system-event-card__label">${escapeHtml(shownLabel)}</span>
`; `;
card.addEventListener('click', () => { card.addEventListener('click', () => {
openChannelMetaDetailsModal({ openChannelMetaDetailsModal({
@@ -2034,6 +1842,7 @@ function renderPostCard(post, {
const authorBlock = document.createElement('div'); const authorBlock = document.createElement('div');
authorBlock.className = 'channel-message-author'; authorBlock.className = 'channel-message-author';
const menuItems = [];
const headRow = document.createElement('div'); const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row'; headRow.className = 'channel-message-head-row';
@@ -2054,8 +1863,9 @@ function renderPostCard(post, {
const timestamp = document.createElement('div'); const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time'; timestamp.className = 'channel-message-time';
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—'; timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
if (post.timestampMs) timestamp.title = new Date(post.timestampMs).toLocaleString('ru-RU');
if (versionsTotal > 1) { if (versionsTotal > 1) {
const editedMarker = document.createElement('button'); const editedMarker = document.createElement('span');
editedMarker.type = 'button'; editedMarker.type = 'button';
editedMarker.className = 'ui-button message-edited-marker'; editedMarker.className = 'ui-button message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`; editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
@@ -2104,6 +1914,15 @@ function renderPostCard(post, {
navigate(makeProfileRoute(cleanLogin)); navigate(makeProfileRoute(cleanLogin));
}); });
const isOwnerPost = !!selector
&& !isDiarySelector(selector)
&& !!post.messageRef?.blockchainName
&& String(post.messageRef.blockchainName).trim().toLowerCase() === String(selector.ownerBlockchainName || '').trim().toLowerCase();
if (isOwnerPost) {
card.classList.add('channel-message-card--owner');
authorTile.hidden = true;
}
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено'; const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
const parsedBody = parseMessageAttachments(post.body); const parsedBody = parseMessageAttachments(post.body);
@@ -2186,47 +2005,75 @@ function renderPostCard(post, {
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.innerHTML = ` likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span> <span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span> <span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter" title="Все / основные / сияющие">${post.likesCount || 0}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0}</span> <span class="channel-action-counter">${post.likesCount || ''}</span>
`; `;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк'); likeButton.setAttribute('aria-pressed', String(isLiked));
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${post.likesCount || 0}`);
likeButton.disabled = isPending; likeButton.disabled = isPending;
likeButton.addEventListener('click', (event) => { likeButton.addEventListener('click', async (event) => {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
if (isPending) return; if (isPending) return;
openMessageLikePopup({ anchor: event.currentTarget, post, navigate, onToggleLike }); likeButton.disabled = true;
try {
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like');
} catch (error) {
showToast(toUserMessage(error, 'Не удалось изменить лайк.'));
} finally {
if (likeButton.isConnected) likeButton.disabled = false;
}
});
const discussionButton = document.createElement('button');
discussionButton.type = 'button';
discussionButton.className = 'ui-button channel-action-item channel-action-discussion';
discussionButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Обсуждение</span>
<span class="channel-action-counter">${post.repliesCount || ''}</span>
`;
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
discussionButton.addEventListener('click', (event) => {
event.stopPropagation();
const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route);
}); });
const replyButton = document.createElement('button'); const replyButton = document.createElement('button');
replyButton.type = 'button'; replyButton.type = 'button';
replyButton.className = 'ui-button channel-action-item channel-action-reply'; replyButton.className = 'ui-button channel-action-item channel-action-reply';
replyButton.innerHTML = ` replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span> <span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Ответить</span> <span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${post.repliesCount || 0}</span> <span class="channel-action-counter">${post.repliesCount || ''}</span>
`; `;
setActionTitle(replyButton, 'Ответить'); setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => { replyButton.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
openReplyModal({ openReplyModal({
navigate,
onSubmit: async (text) => onReply(post.messageRef, text), onSubmit: async (text) => onReply(post.messageRef, text),
isActive, isActive,
draftKey: `message:${refKey}`,
context: {
author: post.authorLogin,
text: parsedBody.text,
attachmentLabel: parsedBody.attachments[0]?.name || '',
},
}); });
}); });
// Rating/opinion action is intentionally hidden from UI for now. // Rating/opinion action is intentionally hidden from UI for now.
// Backend/subtype/counters remain supported so the feature can be restored later. // Backend/subtype/counters remain supported so the feature can be restored later.
actions.append(likeButton, replyButton); actions.append(likeButton, discussionButton);
const shareButton = document.createElement('button'); const shareButton = document.createElement('button');
shareButton.type = 'button'; shareButton.type = 'button';
shareButton.className = 'ui-button channel-action-item channel-action-share'; shareButton.className = 'ui-button channel-action-item channel-action-share';
shareButton.innerHTML = ` shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↗</span> <span class="channel-action-icon" aria-hidden="true">${iconHtml('share')}</span>
<span class="channel-action-label">Отправить</span> <span class="channel-action-label">Отправить</span>
`; `;
setActionTitle(shareButton, 'Отправить'); setActionTitle(shareButton, 'Отправить');
@@ -2238,6 +2085,7 @@ function renderPostCard(post, {
}); });
actions.append(shareButton); actions.append(shareButton);
menuItems.unshift({ label: 'Ответить', action: () => replyButton.click() });
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) { if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
const originalBtn = document.createElement('button'); const originalBtn = document.createElement('button');
originalBtn.type = 'button'; originalBtn.type = 'button';
@@ -2251,15 +2099,13 @@ function renderPostCard(post, {
event.stopPropagation(); event.stopPropagation();
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName); const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
if (!ownerLogin) return; if (!ownerLogin) return;
const ok = window.confirm('Перейти к оригинальному сообщению?');
if (!ok) return;
navigate(makeShineMessageRoute({ navigate(makeShineMessageRoute({
ownerLogin, ownerLogin,
messageBlockchainName: post.targetRef.blockchainName, messageBlockchainName: post.targetRef.blockchainName,
messageBlockNumber: post.targetRef.blockNumber, messageBlockNumber: post.targetRef.blockNumber,
})); }));
}); });
actions.append(originalBtn); menuItems.push({ label: 'Оригинал', action: () => originalBtn.click() });
} }
const detailsButton = document.createElement('button'); const detailsButton = document.createElement('button');
detailsButton.type = 'button'; detailsButton.type = 'button';
@@ -2281,7 +2127,7 @@ function renderPostCard(post, {
msgSubType: post.msgSubType, msgSubType: post.msgSubType,
}), { isActive }); }), { isActive });
}); });
actions.append(detailsButton); menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
if (post.isOwnMessage) { if (post.isOwnMessage) {
const editButton = document.createElement('button'); const editButton = document.createElement('button');
editButton.type = 'button'; editButton.type = 'button';
@@ -2295,19 +2141,39 @@ function renderPostCard(post, {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
openEditMessageModal({ openEditMessageModal({
isActive,
draftKey: `edit:${messageRefKey(post.messageRef)}`,
initialText: String(post.body || '').trim() === 'удалено' ? '' : parsedBody.text, initialText: String(post.body || '').trim() === 'удалено' ? '' : parsedBody.text,
allowEmptyText: parsedBody.attachments.length > 0, allowEmptyText: parsedBody.attachments.length > 0,
onSave: async (nextText) => onEdit(post.messageRef, composeMessageWithAttachments(nextText, parsedBody.attachments), { isDelete: false }), onSave: async (nextText) => onEdit(post.messageRef, composeMessageWithAttachments(nextText, parsedBody.attachments), { isDelete: false }),
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }), onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
}); });
}); });
actions.append(editButton); menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
if (!await confirmDialog({
title: 'Удалить сообщение?',
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
confirmLabel: 'Удалить',
danger: true,
})) return;
try { await onEdit(post.messageRef, '', { isDelete: true }); }
catch (error) { if (isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
} });
}
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
attachMessageMenu(card, headRow, menuItems);
if (isOwnerPost) {
if (post.timestampMs) timestamp.textContent = formatClockTime(post.timestampMs);
const meta = document.createElement('div');
meta.className = 'channel-message-meta';
const moreButton = headRow.querySelector('.channel-message-more');
meta.append(timestamp);
if (moreButton) meta.append(moreButton);
actions.append(meta);
if (!headRow.querySelector('.channel-message-type-button')) headRow.hidden = true;
} }
card.append(actions); card.append(actions);
card.addEventListener('click', () => {
const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route);
});
return card; return card;
} }
@@ -2325,13 +2191,15 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
} }
const actionButton = document.createElement('button'); const actionButton = document.createElement('button');
actionButton.className = 'destructive-btn channel-main-action'; actionButton.type = 'button';
actionButton.textContent = 'Подписаться на канал'; actionButton.className = 'primary-btn channel-main-action';
actionButton.textContent = state.session.isAuthorized ? 'Подписаться на канал' : 'Войти и подписаться';
const addMessageButton = document.createElement('button'); const addMessageButton = document.createElement('button');
addMessageButton.type = 'button'; addMessageButton.type = 'button';
addMessageButton.className = 'primary-btn channel-main-action channel-main-action--compose'; addMessageButton.className = 'channel-compose-bar';
addMessageButton.textContent = 'Добавить сообщение'; addMessageButton.setAttribute('aria-label', 'Написать в канал');
addMessageButton.innerHTML = `<span class="channel-compose-bar__text">Написать в канал…</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
addMessageButton.addEventListener('click', (event) => { addMessageButton.addEventListener('click', (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
handlers.onAddMessage(); handlers.onAddMessage();
@@ -2363,7 +2231,17 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
}); });
if (feedItems.length) { if (feedItems.length) {
let lastDayKey = '';
feedItems.forEach((item) => { feedItems.forEach((item) => {
const itemDayKey = dayKey(item.timestampMs);
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
if (startsNewDay) lastDayKey = itemDayKey;
if (startsNewDay && item.type !== 'meta') {
const separator = document.createElement('div');
separator.className = 'channel-day-separator';
separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`;
feed.append(separator);
}
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) { if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
const unreadLine = document.createElement('div'); const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line'; unreadLine.className = 'card channel-unread-line';
@@ -2372,7 +2250,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
unreadLineInserted = true; unreadLineInserted = true;
} }
if (item.type === 'meta') { if (item.type === 'meta') {
feed.append(renderChannelMetaEventCard(item.event)); feed.append(renderChannelMetaEventCard(item.event, startsNewDay ? formatDayLabel(item.timestampMs) : ''));
return; return;
} }
const row = renderPostCard(item.post, { const row = renderPostCard(item.post, {
@@ -2396,7 +2274,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
}); });
} else { } else {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'empty-note';
empty.textContent = channelData.isDiary empty.textContent = channelData.isDiary
? 'К сожалению, у вас пока еще ничего нет в Дневнике.' ? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
: 'Ждем ваших начинаний'; : 'Ждем ваших начинаний';
@@ -2416,16 +2294,22 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
if (channelData.isDiary) { if (channelData.isDiary) {
screen.append(feed, backButton); screen.append(feed, backButton);
} else if (channelData.isOwnChannel) { } else if (channelData.isOwnChannel) {
screen.append(feed, addMessageButton); screen.append(feed);
const composer = document.createElement('div');
composer.className = 'channel-composer';
composer.append(addMessageButton);
handlers.chrome?.setComposer(composer);
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) { } else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
screen.append(feed, actionButton); screen.append(actionButton, feed);
} else { } else {
screen.append(feed); screen.append(feed);
} }
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey); const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
const pendingScrollTimer = applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0); const restorePosition = handlers.restorePosition;
const unreadScrollTimer = !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary const pendingScrollTimer = Number.isFinite(restorePosition) && !hasPendingScrollTarget ? 0 : applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
if (Number.isFinite(restorePosition) && !hasPendingScrollTarget) restoreChannelPosition(restorePosition);
const unreadScrollTimer = !Number.isFinite(restorePosition) && !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary
? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40) ? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40)
: 0; : 0;
@@ -2500,6 +2384,7 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel'; screen.className = 'stack channels-screen channels-screen--channel';
const positionKey = `${state.session.login}:channel:${routeKey}`;
let disposed = false; let disposed = false;
let refreshSeq = 0; let refreshSeq = 0;
@@ -2526,11 +2411,12 @@ export function render({ navigate, route, chrome }) {
}; };
let activeChannelData = null; let activeChannelData = null;
let activeOpenEntrypointHistory = null;
const channelHeaderButton = document.createElement('button'); const channelHeaderButton = document.createElement('button');
channelHeaderButton.type = 'button'; channelHeaderButton.type = 'button';
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action'; channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
channelHeaderButton.textContent = 'Канал: ...'; channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
channelHeaderButton.disabled = true; channelHeaderButton.disabled = true;
const header = createTopBar({ const header = createTopBar({
@@ -2538,7 +2424,6 @@ export function render({ navigate, route, chrome }) {
back: { onClick: () => navigate('channels-list') }, back: { onClick: () => navigate('channels-list') },
className: 'channel-view-topbar', className: 'channel-view-topbar',
actions: [ actions: [
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
{ {
label: '⋮', label: '⋮',
title: 'Действия канала', title: 'Действия канала',
@@ -2557,27 +2442,14 @@ export function render({ navigate, route, chrome }) {
const aboutRoute = makeShineChannelAboutRoute(routeArgs); const aboutRoute = makeShineChannelAboutRoute(routeArgs);
const donateRoute = makeShineChannelDonateRoute(routeArgs); const donateRoute = makeShineChannelDonateRoute(routeArgs);
const items = []; const items = [];
if (apiData?.isOwnChannel && !apiData?.isDiary) { items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
items.push({ items.push({
label: 'Добавить сообщение', label: 'Оглавление',
action: () => { action: () => {
openAddMessageModal({ if (activeOpenEntrypointHistory) activeOpenEntrypointHistory();
channelName: apiData?.channel?.name || '', else showToast('В этом канале пока нет оглавления');
navigate,
isActive: () => !disposed,
onSubmit: async ({ text: bodyText, msgSubType }) => {
try {
await onAddPost(bodyText, msgSubType);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
}
}, },
}); });
},
});
}
items.push({ label: 'Описание канала', action: () => { if (aboutRoute) navigate(aboutRoute); } });
if (!apiData?.isOwnChannel) { if (!apiData?.isOwnChannel) {
items.push({ label: 'Поддержать автора', action: () => { if (donateRoute) navigate(donateRoute); } }); items.push({ label: 'Поддержать автора', action: () => { if (donateRoute) navigate(donateRoute); } });
} }
@@ -2595,6 +2467,8 @@ export function render({ navigate, route, chrome }) {
}); });
} }
} }
const aboutIndex = items.findIndex((item) => item.label === 'О канале');
if (aboutIndex > 0) items.unshift(...items.splice(aboutIndex, 2));
return items; return items;
}, },
}, },
@@ -2627,9 +2501,6 @@ export function render({ navigate, route, chrome }) {
try { try {
const { login, storagePwd } = requireSigningSession(); const { login, storagePwd } = requireSigningSession();
if (!apiData?.selector) throw new Error('Не удалось определить канал для подписки.'); if (!apiData?.selector) throw new Error('Не удалось определить канал для подписки.');
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
if (!ok) return;
await authService.addBlockFollowChannel({ await authService.addBlockFollowChannel({
login, login,
@@ -2842,7 +2713,7 @@ export function render({ navigate, route, chrome }) {
if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.'); if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.');
const result = await shareOrCopyLink({ const result = await shareOrCopyLink({
title: 'SHiNE · Каналы', title: 'SHiNE · Каналы',
text: 'Тред из канала SHiNE', text: 'Обсуждение в Сиянии',
url: buildAbsoluteRouteUrl(routeToShare), url: buildAbsoluteRouteUrl(routeToShare),
}); });
if (disposed) return; if (disposed) return;
@@ -2918,6 +2789,8 @@ export function render({ navigate, route, chrome }) {
screen.append(statusBox); screen.append(statusBox);
const clearContent = () => { const clearContent = () => {
chrome?.setComposer(null);
screen.querySelectorAll('.channel-message-card').forEach((card) => card.cleanup?.());
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking(); if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
cleanupSeenTracking = null; cleanupSeenTracking = null;
Array.from(screen.children).forEach((child) => { Array.from(screen.children).forEach((child) => {
@@ -2942,17 +2815,29 @@ export function render({ navigate, route, chrome }) {
'#reply-modal', '#reply-modal',
'#repost-modal', '#repost-modal',
].join(','); ].join(',');
if (modalRoot.querySelector(ownedSelector)) modalRoot.innerHTML = ''; if (modalRoot.querySelector(ownedSelector)) {
modalRoot.querySelectorAll('.channel-editor-overlay').forEach((editor) => editor.cleanup?.());
modalRoot.innerHTML = '';
}
}; };
refresh = async () => { refresh = async () => {
if (disposed) return; if (disposed) return;
const seq = ++refreshSeq; const seq = ++refreshSeq;
clearContent(); const hadContent = !!screen.querySelector('.channel-feed');
// Прежнее место в ленте восстанавливаем только при возврате из обсуждения / «О канале».
// Открытие канала из списка или другого раздела ведёт к свежим постам (или к непрочитанным).
const previousPath = getPreviousTrackedPath() || '';
const returnedFromChannelPage = /^\/(SHiNE|thread)\//.test(previousPath) || /\/(about|donate)$/.test(previousPath);
const restorePosition = hadContent
? getChannelScrollRoot()?.scrollTop
: (returnedFromChannelPage ? readChannelPosition(positionKey) : undefined);
if (!hadContent) clearContent();
activeChannelData = null; activeChannelData = null;
activeOpenEntrypointHistory = null;
activeSelector = null; activeSelector = null;
showStatus(''); showStatus('');
channelHeaderButton.textContent = 'Канал: ...'; channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
channelHeaderButton.disabled = true; channelHeaderButton.disabled = true;
channelHeaderButton.onclick = null; channelHeaderButton.onclick = null;
if (channelMoreButton) channelMoreButton.disabled = true; if (channelMoreButton) channelMoreButton.disabled = true;
@@ -2962,7 +2847,7 @@ export function render({ navigate, route, chrome }) {
channelEntrypointButton.onclick = null; channelEntrypointButton.onclick = null;
} }
const skeleton = renderSkeleton(screen); const skeleton = hadContent ? null : renderSkeleton(screen);
try { try {
const apiData = await loadFromApi(route, channelId); const apiData = await loadFromApi(route, channelId);
@@ -2981,8 +2866,17 @@ export function render({ navigate, route, chrome }) {
}, },
}); });
}; };
activeOpenEntrypointHistory = openEntrypointHistory;
if (channelHeaderButton) { if (channelHeaderButton) {
channelHeaderButton.textContent = titleLabel; const ownerLabel = String(apiData?.channel?.ownerName || '').trim();
channelHeaderButton.replaceChildren();
const titleNode = document.createElement('span');
titleNode.className = 'channel-header-title';
titleNode.textContent = titleLabel;
const ownerNode = document.createElement('span');
ownerNode.className = 'channel-header-owner';
ownerNode.textContent = ownerLabel ? `@${ownerLabel}` : 'О канале';
channelHeaderButton.append(titleNode, ownerNode);
channelHeaderButton.disabled = false; channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => { channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
@@ -3009,8 +2903,11 @@ export function render({ navigate, route, chrome }) {
} }
}; };
} }
skeleton.remove(); skeleton?.remove();
clearContent();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, { cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
chrome,
restorePosition,
showStatus, showStatus,
isActive: () => !disposed, isActive: () => !disposed,
onAddMessage: () => { onAddMessage: () => {
@@ -3094,7 +2991,8 @@ export function render({ navigate, route, chrome }) {
}); });
} catch (error) { } catch (error) {
if (disposed || seq !== refreshSeq) return; if (disposed || seq !== refreshSeq) return;
skeleton.remove(); skeleton?.remove();
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить канал.')); return; }
if (isChannelsDemoMode()) { if (isChannelsDemoMode()) {
renderDemoFallback(screen, navigate, error); renderDemoFallback(screen, navigate, error);
return; return;
@@ -3108,6 +3006,7 @@ export function render({ navigate, route, chrome }) {
screen.refresh = refresh; screen.refresh = refresh;
screen.cleanup = () => { screen.cleanup = () => {
if (disposed) return; if (disposed) return;
rememberChannelPosition(positionKey);
disposed = true; disposed = true;
refreshSeq += 1; refreshSeq += 1;
clearContent(); clearContent();
+109 -49
View File
@@ -1,3 +1,4 @@
import { iconHtml } from '../components/ui-icon.js';
import { channels as mockChannels } from '../mock-data.js'; import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js'; import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
@@ -5,7 +6,7 @@ import { parseMessageAttachments } from '../services/attachment-format.js';
import { import {
animatePress, animatePress,
createSkeletonCard, createSkeletonCard,
formatRelativeTime, formatListTime,
readChannelNotificationsState, readChannelNotificationsState,
showToast, showToast,
softHaptic, softHaptic,
@@ -18,8 +19,9 @@ import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js'; import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } }; export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_STORIES = 0;
@@ -30,6 +32,7 @@ const DIARY_DISPLAY_NAME = 'Дневник';
const CHANNELS_VIEW_ALL = 'all'; const CHANNELS_VIEW_ALL = 'all';
const CHANNELS_VIEW_OWNED = 'owned'; const CHANNELS_VIEW_OWNED = 'owned';
const CHANNELS_VIEW_FOLLOWING = 'following'; const CHANNELS_VIEW_FOLLOWING = 'following';
const listQueries = new Map();
function channelMenuIcon(name) { function channelMenuIcon(name) {
const paths = { const paths = {
@@ -120,6 +123,7 @@ function createChannelAvatar(channel = {}) {
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name), initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null, avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
size: 'lg', size: 'lg',
className: 'avatar-plain',
title: channel.displayTitle || channel.title || channel.name || 'Канал', title: channel.displayTitle || channel.title || channel.name || 'Канал',
alt: 'Аватар канала', alt: 'Аватар канала',
}); });
@@ -752,7 +756,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
messagePreview: cleanChannelMessagePreview(lastMessage.text), messagePreview: cleanChannelMessagePreview(lastMessage.text),
messagesCount: Number(summary?.messagesCount || 0), messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0), unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(lastMessage.createdAtMs || 0), lastMessageAt: Number(lastMessage.createdAtMs || summary?.channel?.metaUpdatedAtMs || 0),
isOwnChannel: isOwn, isOwnChannel: isOwn,
isSubscribed: !isOwn, isSubscribed: !isOwn,
notificationsEnabled: notificationsState[rowId] === true, notificationsEnabled: notificationsState[rowId] === true,
@@ -847,16 +851,20 @@ function toListModel(groups) {
]; ];
} }
function renderEmptyState() { function renderEmptyState(listState, navigate) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent'; wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
if (!state.session.isAuthorized) { const heading = document.createElement('strong');
return wrap; heading.textContent = listState.query ? 'Ничего не найдено' : listState.viewMode === CHANNELS_VIEW_FOLLOWING ? 'Пока нет подписок' : listState.viewMode === CHANNELS_VIEW_OWNED ? 'Здесь будут ваши каналы' : 'Откройте свой первый канал';
}
const text = document.createElement('p'); const text = document.createElement('p');
text.className = 'meta-muted'; text.className = 'meta-muted';
text.textContent = 'У вас пока нет доступных каналов.'; text.textContent = state.session.isAuthorized ? 'Найдите канал по имени автора или создайте свой.' : 'Войдите, чтобы видеть свои каналы и подписки.';
wrap.append(text); const action = document.createElement('button');
action.type = 'button';
action.className = 'primary-btn';
action.textContent = state.session.isAuthorized ? 'Найти по @автору' : 'Войти';
action.addEventListener('click', () => state.session.isAuthorized ? openChannelFinderModal({ navigate }) : navigate('login-view'));
wrap.append(heading, text, action);
return wrap; return wrap;
} }
@@ -957,12 +965,13 @@ function renderChannelMain(channel) {
const time = document.createElement('span'); const time = document.createElement('span');
time.className = 'channel-row-time'; time.className = 'channel-row-time';
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : ''; time.textContent = channel.lastMessageAt ? formatListTime(channel.lastMessageAt) : '';
titleLine.append(title, time); titleLine.append(title, time);
const technical = document.createElement('p'); const technical = document.createElement('p');
technical.className = 'channel-row-technical'; technical.className = 'channel-row-technical';
technical.textContent = channel.technicalLabel || `@${channel.ownerName || ''}/${channel.channelName || ''}`; technical.textContent = `@${channel.ownerName || 'автор'}`;
technical.hidden = !!channel.isOwnChannel;
if (channel.channelDescription) { if (channel.channelDescription) {
const desc = document.createElement('p'); const desc = document.createElement('p');
@@ -976,7 +985,8 @@ function renderChannelMain(channel) {
const preview = document.createElement('p'); const preview = document.createElement('p');
preview.className = 'channel-row-message'; preview.className = 'channel-row-message';
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний'; preview.textContent = channel.messagePreview || 'Пока нет сообщений';
if (!channel.messagePreview) preview.classList.add('is-empty');
previewLine.append(preview); previewLine.append(preview);
@@ -997,10 +1007,15 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
if (listState.viewMode === CHANNELS_VIEW_OWNED) return channel.isOwnChannel === true; if (listState.viewMode === CHANNELS_VIEW_OWNED) return channel.isOwnChannel === true;
if (listState.viewMode === CHANNELS_VIEW_FOLLOWING) return channel.sourceBucket === 'followedChannels'; if (listState.viewMode === CHANNELS_VIEW_FOLLOWING) return channel.sourceBucket === 'followedChannels';
return true; return true;
}).filter((channel) => {
const query = String(listState.query || '').trim().toLowerCase();
if (!query) return true;
return [channel.title, channel.ownerName, channel.channelName, channel.technicalLabel]
.some((value) => String(value || '').toLowerCase().includes(query));
}); });
if (!filtered.length) { if (!filtered.length) {
container.append(renderEmptyState()); container.append(renderEmptyState(listState, navigate));
return; return;
} }
@@ -1010,10 +1025,12 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const rerenderList = () => renderListContent({ screen, container, listState, navigate, refreshFeed }); const rerenderList = () => renderListContent({ screen, container, listState, navigate, refreshFeed });
filtered.forEach((channel) => { filtered.forEach((channel) => {
const row = document.createElement('article'); const row = document.createElement('button');
row.type = 'button';
row.className = 'channel-row'; row.className = 'channel-row';
const countersVisible = listState.revealedCounters.has(channel.id); const countersVisible = listState.revealedCounters.has(channel.id);
row.classList.toggle('is-counters-visible', countersVisible); row.classList.toggle('is-counters-visible', countersVisible);
row.classList.toggle('has-unread', Number(channel.unreadCount || 0) > 0);
const avatar = createChannelAvatar(channel); const avatar = createChannelAvatar(channel);
@@ -1047,6 +1064,8 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
} }
async function loadFeedAndRender({ screen, listState, contentEl, navigate, silent = false }) { async function loadFeedAndRender({ screen, listState, contentEl, navigate, silent = false }) {
if (listState.disposed) return;
const seq = ++listState.loadSeq;
if (!silent) renderSkeletonList(contentEl, 5); if (!silent) renderSkeletonList(contentEl, 5);
if (!state.session.isAuthorized) { if (!state.session.isAuthorized) {
@@ -1072,6 +1091,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate, silen
try { try {
const feed = await authService.listSubscriptionsFeed(state.session.login, 200); const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
if (listState.disposed || seq !== listState.loadSeq) return;
// FEATURE DISABLED: personal Diary is intentionally hidden from the Channels UI. // FEATURE DISABLED: personal Diary is intentionally hidden from the Channels UI.
// The server/API implementation is preserved so the feature can be restored later. // The server/API implementation is preserved so the feature can be restored later.
@@ -1096,7 +1116,12 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate, silen
navigate, navigate,
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }), refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
}); });
if (Number.isFinite(listState.restorePosition)) {
restoreChannelPosition(listState.restorePosition);
listState.restorePosition = undefined;
}
} catch (error) { } catch (error) {
if (listState.disposed || seq !== listState.loadSeq) return;
if (silent) return; if (silent) return;
setChannelsFeed(null, {}); setChannelsFeed(null, {});
contentEl.innerHTML = ''; contentEl.innerHTML = '';
@@ -1118,52 +1143,81 @@ export function render({ navigate, route, chrome }) {
const notificationsState = readChannelNotificationsState(); const notificationsState = readChannelNotificationsState();
const isGuest = !state.session.isAuthorized; const isGuest = !state.session.isAuthorized;
const positionKey = `${state.session.login}:channels:${normalizeChannelsViewMode(route)}`;
const listState = { const listState = {
restorePosition: readChannelPosition(positionKey),
disposed: false,
loadSeq: 0,
notificationsState, notificationsState,
revealedCounters: new Set(), revealedCounters: new Set(),
channels: [], channels: [],
viewMode: normalizeChannelsViewMode(route), viewMode: normalizeChannelsViewMode(route),
query: listQueries.get(positionKey) || '',
}; };
const contentEl = document.createElement('div'); const contentEl = document.createElement('div');
contentEl.className = 'channels-list-content'; contentEl.className = 'channels-list-content';
const topTitle = document.createElement('button');
topTitle.type = 'button';
topTitle.className = 'channels-top-title channels-filter-title';
const channelsFilterMenu = createDropdownMenu({
transparent: true,
anchorEl: topTitle,
placement: 'bottom-start',
leftShift: 72,
minWidth: 220,
items: [
{ label: 'Все каналы', iconHtml: channelMenuIcon('all'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
{ label: 'Мои каналы', iconHtml: channelMenuIcon('mine'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
{ label: 'Подписки', iconHtml: channelMenuIcon('following'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
],
});
const topBarEl = createTopBar({ const topBarEl = createTopBar({
center: topTitle, title: 'Каналы',
className: 'topbar--root',
actions: [ actions: [
{ {
iconNode: createOverflowDots(), label: '+',
title: 'Ещё действия', title: 'Создать канал',
ariaLabel: 'Ещё действия', ariaLabel: 'Создать канал',
className: 'channels-top-more-btn', className: 'channels-create-btn',
menu: { onClick: () => navigate('add-channel-view'),
minWidth: 220,
items: [
{ label: 'Найти канал', iconHtml: channelMenuIcon('search'), action: () => openChannelFinderModal({ navigate }) },
{ label: 'Новый канал', iconHtml: channelMenuIcon('add'), action: () => navigate('add-channel-view') },
],
},
}, },
], ],
}); });
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
const controls = document.createElement('div');
controls.className = 'channels-list-controls';
const searchWrap = document.createElement('div');
searchWrap.className = 'channels-inline-search';
searchWrap.innerHTML = `<span class="channels-inline-search__icon" aria-hidden="true">${iconHtml('search')}</span><span class="sr-only">Поиск каналов</span>`;
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.value = listState.query;
searchInput.placeholder = 'В вашем списке';
searchInput.setAttribute('aria-label', 'Найти канал или автора');
const serverSearchButton = document.createElement('button');
serverSearchButton.type = 'button';
serverSearchButton.className = 'text-btn channels-server-search';
serverSearchButton.textContent = 'По @автору';
serverSearchButton.addEventListener('click', () => openChannelFinderModal({ navigate }));
searchWrap.append(searchInput, serverSearchButton);
const tabs = document.createElement('div');
tabs.className = 'tabs tabs--three';
tabs.setAttribute('role', 'tablist');
tabs.setAttribute('aria-label', 'Фильтр каналов');
tabs.addEventListener('keydown', (event) => {
const buttons = [...tabs.querySelectorAll('button')];
const index = buttons.indexOf(document.activeElement);
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
buttons[next].focus();
});
[
[CHANNELS_VIEW_ALL, 'Все'],
[CHANNELS_VIEW_FOLLOWING, 'Подписки'],
[CHANNELS_VIEW_OWNED, 'Мои'],
].forEach(([mode, label]) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'tab-btn';
button.textContent = label;
button.setAttribute('role', 'tab');
const selected = listState.viewMode === mode;
button.classList.toggle('active', selected);
button.setAttribute('aria-selected', String(selected));
button.addEventListener('click', () => navigate(buildChannelsViewRoute(mode)));
tabs.append(button);
});
controls.append(searchWrap, tabs);
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate }); const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
@@ -1192,13 +1246,16 @@ export function render({ navigate, route, chrome }) {
refreshFeed: reloadFeed, refreshFeed: reloadFeed,
}); });
topTitle.textContent = channelsViewTitle(listState.viewMode);
topMenuBtn.style.display = '';
}; };
searchInput.addEventListener('input', () => {
listState.restorePosition = undefined;
listState.query = searchInput.value;
rerenderList();
});
chrome?.setTopbar(topBarEl); chrome?.setTopbar(topBarEl);
screen.append(contentEl); screen.append(controls, contentEl);
if (createSuccessFlash) { if (createSuccessFlash) {
showToast(createSuccessFlash); showToast(createSuccessFlash);
@@ -1210,9 +1267,12 @@ export function render({ navigate, route, chrome }) {
loadFeedAndRender({ screen, listState, contentEl, navigate }); loadFeedAndRender({ screen, listState, contentEl, navigate });
screen.cleanup = () => { screen.cleanup = () => {
if (listState.disposed) return;
rememberChannelPosition(positionKey);
listQueries.set(positionKey, listState.query);
listState.disposed = true;
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer); if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
unsubscribeCountersChanged(); unsubscribeCountersChanged();
channelsFilterMenu.destroy();
}; };
return screen; return screen;
+21 -5
View File
@@ -28,7 +28,7 @@ import {
stopAllTwemojiAnimations, stopAllTwemojiAnimations,
} from '../components/emoji-picker.js?v=202607152130'; } from '../components/emoji-picker.js?v=202607152130';
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js'; import { dayKey, formatClockTime, formatDayLabel, showToast } from '../services/channels-ux.js';
import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { isDmFileTransferEnabled } from '../services/feature-settings.js'; import { isDmFileTransferEnabled } from '../services/feature-settings.js';
import { import {
@@ -44,6 +44,7 @@ import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-route
export const pageMeta = { export const pageMeta = {
id: 'chat-view', id: 'chat-view',
title: 'Чат', title: 'Чат',
hideToolbar: true,
shellMode: { shellMode: {
topFade: true, topFade: true,
bottomFade: true, bottomFade: true,
@@ -81,7 +82,7 @@ function chatRelationLabel(value) {
case 'close_friend': return 'Близкий друг'; case 'close_friend': return 'Близкий друг';
case 'friend': return 'Друг'; case 'friend': return 'Друг';
case 'contact': return 'Контакт'; case 'contact': return 'Контакт';
default: return 'Не в контактах'; default: return '';
} }
} }
@@ -117,7 +118,9 @@ function createChatHeaderParts(login, navigate) {
const lastName = String(currentPeer?.lastName || '').trim(); const lastName = String(currentPeer?.lastName || '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' '); const fullName = [firstName, lastName].filter(Boolean).join(' ');
nameEl.textContent = fullName || cleanLogin; nameEl.textContent = fullName || cleanLogin;
metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`; const relation = chatRelationLabel(currentPeer?.relationType);
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', relation].filter(Boolean).join(' · ');
metaEl.hidden = !metaEl.textContent;
const avatar = renderUserAvatar({ const avatar = renderUserAvatar({
login: cleanLogin, login: cleanLogin,
firstName, firstName,
@@ -1058,7 +1061,19 @@ function renderLog(
const messages = getChatMessages(chatId); const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false; let unreadSeparatorInserted = false;
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim(); const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
let lastDayKey = '';
messages.forEach((msg) => { messages.forEach((msg) => {
const msgTimeMs = resolveMessageTimeMs(msg);
const msgDayKey = dayKey(msgTimeMs);
if (msgDayKey && msgDayKey !== lastDayKey) {
lastDayKey = msgDayKey;
const daySep = document.createElement('div');
daySep.className = 'dm-day-separator';
const dayLabel = document.createElement('span');
dayLabel.textContent = formatDayLabel(msgTimeMs);
daySep.append(dayLabel);
list.append(daySep);
}
const isUnreadBoundary = showUnreadSeparator const isUnreadBoundary = showUnreadSeparator
&& !unreadSeparatorInserted && !unreadSeparatorInserted
&& separatorMessageKey && separatorMessageKey
@@ -1176,7 +1191,8 @@ function renderLog(
const timeNode = document.createElement('span'); const timeNode = document.createElement('span');
timeNode.className = 'bubble-time'; timeNode.className = 'bubble-time';
timeNode.textContent = formatMessageTime(resolveMessageTimeMs(msg)); timeNode.textContent = formatClockTime(resolveMessageTimeMs(msg));
timeNode.title = formatMessageTime(resolveMessageTimeMs(msg));
metaNode.append(timeNode); metaNode.append(timeNode);
const status = resolveDeliveryStatus(messages, msg); const status = resolveDeliveryStatus(messages, msg);
@@ -1812,7 +1828,7 @@ export function render({ navigate, route, chrome }) {
updateSendButtonMode(); updateSendButtonMode();
const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError'; const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError';
showToast( showToast(
denied ? 'Нужно разрешить SHiNE доступ к микрофону.' : (error?.message || 'Не удалось начать запись'), denied ? 'Нужно разрешить Сиянию доступ к микрофону.' : (error?.message || 'Не удалось начать запись'),
{ kind: 'error', timeoutMs: 2600 }, { kind: 'error', timeoutMs: 2600 },
); );
} }
+19 -14
View File
@@ -1,3 +1,4 @@
import { iconHtml } from '../components/ui-icon.js';
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { authService } from '../state.js'; import { authService } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
@@ -65,15 +66,17 @@ export function render({navigate, chrome}) {
let searchSeq = 0; let searchSeq = 0;
const input = document.createElement('input'); const input = document.createElement('input');
input.className = 'input dm-input contact-search-input'; input.className = 'search-field__input contact-search-input';
input.type = 'text'; input.type = 'text';
input.name = 'contact'; input.name = 'contact';
input.placeholder = 'Введите начало логина'; input.placeholder = 'Логин или его начало';
input.setAttribute('aria-label', 'Логин для поиска');
input.enterKeyHint = 'search';
input.autocomplete = 'off'; input.autocomplete = 'off';
input.maxLength = 80; input.maxLength = 80;
const resultsCard = document.createElement('section'); const resultsCard = document.createElement('section');
resultsCard.className = 'card stack contact-search-results-card'; resultsCard.className = 'stack contact-search-results';
resultsCard.hidden = true; resultsCard.hidden = true;
const status = document.createElement('p'); const status = document.createElement('p');
@@ -143,13 +146,13 @@ export function render({navigate, chrome}) {
searchTimer = window.setTimeout(() => { searchTimer = window.setTimeout(() => {
searchTimer = 0; searchTimer = 0;
void runSearch(); void runSearch();
}, 2000); }, 500);
}; };
const searchButton = document.createElement('button'); const searchButton = document.createElement('button');
searchButton.className = 'primary-btn dm-send-btn'; searchButton.className = 'search-field__action';
searchButton.type = 'button'; searchButton.type = 'button';
searchButton.textContent = 'Поиск'; searchButton.textContent = 'Найти';
searchButton.addEventListener('click', async () => { searchButton.addEventListener('click', async () => {
if (searchTimer) { if (searchTimer) {
window.clearTimeout(searchTimer); window.clearTimeout(searchTimer);
@@ -161,19 +164,21 @@ export function render({navigate, chrome}) {
input.addEventListener('input', () => { input.addEventListener('input', () => {
scheduleSearch(); scheduleSearch();
}); });
input.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
searchButton.click();
});
const controls = document.createElement('div'); const formCard = document.createElement('label');
controls.className = 'contact-search-actions'; formCard.className = 'search-field contact-search-field';
controls.append(searchButton); formCard.innerHTML = `<span class="search-field__icon" aria-hidden="true">${iconHtml('search')}</span>`;
formCard.append(input, searchButton);
const formCard = document.createElement('section');
formCard.className = 'card stack contact-search-form-card';
formCard.append(input, controls);
resultsCard.append(status, resultsList); resultsCard.append(status, resultsList);
chrome?.setTopbar(createTopBar({ chrome?.setTopbar(createTopBar({
title: 'Поиск контактов', title: 'Найти человека',
back: { label: '←', onClick: () => navigate('messages-list') }, back: { label: '←', onClick: () => navigate('messages-list') },
})); }));
screen.append( screen.append(
+20 -14
View File
@@ -207,7 +207,7 @@ async function forceUiUpdateNow() {
function showClientUpdateHelp() { function showClientUpdateHelp() {
window.alert( window.alert(
'Если UI не обновился:\n\n' 'Если UI не обновился:\n\n'
+ '1) Закройте вкладки с SHiNE.\n' + '1) Закройте вкладки с Сиянием.\n'
+ `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n` + `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n' + '3) Если приложение установлено как PWA — удалите его с устройства.\n'
+ `4) Откройте ${defaultServerHttp} заново и выполните вход.\n` + `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
@@ -256,17 +256,17 @@ export function render({navigate, chrome}) {
})); }));
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card stack settings-developer-card'; card.className = 'nav-list settings-developer-card';
card.innerHTML = ` card.innerHTML = `
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-ui-update">Принудительно обновить UI</button> <button class="nav-row" type="button" id="settings-force-ui-update"><span class="nav-row__label">Принудительно обновить UI</span></button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-update-help">Клиент не обновляется?</button> <button class="nav-row" type="button" id="settings-force-update-help"><span class="nav-row__label">Клиент не обновляется?</span></button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button> <button class="nav-row" type="button" id="settings-ui-error-reporting"><span class="nav-row__label">Отправлять ошибки на сервер</span></button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-users-init">Solana: init регистрации</button> <button class="nav-row" type="button" id="settings-solana-users-init"><span class="nav-row__label">Solana: первичная настройка регистрации</span></button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button> <button class="nav-row" type="button" id="settings-solana-rpc-check"><span class="nav-row__label">Solana: проверить публичный узел</span></button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-app-log">Лог приложения</button> <button class="nav-row" type="button" id="settings-app-log"><span class="nav-row__label">Лог приложения</span></button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button> <button class="nav-row" type="button" id="settings-pwa-diagnostics"><span class="nav-row__label">Диагностика PWA / Push</span></button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-install">Как установить PWA</button> <button class="nav-row" type="button" id="settings-pwa-install"><span class="nav-row__label">Установка на главный экран</span></button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-upload-avatar">Загрузить аватар</button> <button class="nav-row" type="button" id="settings-upload-avatar"><span class="nav-row__label">Загрузить аватар</span></button>
`; `;
const appLogBtn = card.querySelector('#settings-app-log'); const appLogBtn = card.querySelector('#settings-app-log');
@@ -307,16 +307,22 @@ export function render({navigate, chrome}) {
} }
}); });
// Меняем только подпись строки: textContent на самой кнопке стирал .nav-row__label, и строка выбивалась по кеглю.
const setPwaLabel = (text) => {
const labelEl = pwaInstallBtn.querySelector('.nav-row__label');
if (labelEl) labelEl.textContent = text;
else pwaInstallBtn.textContent = text;
};
const syncPwaButtonLabel = () => { const syncPwaButtonLabel = () => {
if (isStandalonePwaMode()) { if (isStandalonePwaMode()) {
pwaInstallBtn.textContent = 'PWA установлено (проверить WebPush)'; setPwaLabel('Приложение установлено — проверить уведомления');
return; return;
} }
if (canInstallPwa()) { if (canInstallPwa()) {
pwaInstallBtn.textContent = 'Зарегистрировать PWA'; setPwaLabel('Установить как приложение');
return; return;
} }
pwaInstallBtn.textContent = 'Как установить PWA'; setPwaLabel('Установка на главный экран');
}; };
const unsubscribeInstallAvailability = onPwaInstallAvailabilityChange(() => { const unsubscribeInstallAvailability = onPwaInstallAvailabilityChange(() => {
+2 -2
View File
@@ -250,7 +250,7 @@ export function render({navigate, chrome}) {
passwordDialog.style.inset = '0'; passwordDialog.style.inset = '0';
passwordDialog.style.zIndex = '30'; passwordDialog.style.zIndex = '30';
passwordDialog.innerHTML = ` passwordDialog.innerHTML = `
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.72); backdrop-filter:blur(4px);" data-action="close-dialog"></div> <div style="position:absolute; inset:0; background:var(--scrim); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
<div class="card stack" style="position:absolute; left:50%; top:24px; width:min(calc(100vw - 32px), 360px); transform:translateX(-50%); gap:12px; box-shadow:var(--shadow);"> <div class="card stack" style="position:absolute; left:50%; top:24px; width:min(calc(100vw - 32px), 360px); transform:translateX(-50%); gap:12px; box-shadow:var(--shadow);">
<div class="stack" style="gap:6px;"> <div class="stack" style="gap:6px;">
<p class="field-label" id="pairing-dialog-title">Задать дополнительный пароль</p> <p class="field-label" id="pairing-dialog-title">Задать дополнительный пароль</p>
@@ -292,7 +292,7 @@ export function render({navigate, chrome}) {
dialogOverlay.style.inset = '0'; dialogOverlay.style.inset = '0';
dialogOverlay.style.zIndex = '2'; dialogOverlay.style.zIndex = '2';
dialogOverlay.innerHTML = ` dialogOverlay.innerHTML = `
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.78);"></div> <div style="position:absolute; inset:0; background:var(--scrim);"></div>
<div class="card stack" style="position:absolute; left:50%; top:50%; width:min(calc(100% - 32px), 320px); transform:translate(-50%, -50%); gap:12px; box-shadow:var(--shadow);"> <div class="card stack" style="position:absolute; left:50%; top:50%; width:min(calc(100% - 32px), 320px); transform:translate(-50%, -50%); gap:12px; box-shadow:var(--shadow);">
<p class="field-label" id="pairing-dialog-overlay-title">Ошибка</p> <p class="field-label" id="pairing-dialog-overlay-title">Ошибка</p>
<p class="meta-muted" id="pairing-dialog-overlay-text"></p> <p class="meta-muted" id="pairing-dialog-overlay-text"></p>
+79 -74
View File
@@ -1,3 +1,4 @@
import { showToast } from '../services/channels-ux.js';
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { import {
isSessionInvalidError, isSessionInvalidError,
@@ -11,24 +12,49 @@ import {
export const pageMeta = { id: 'device-view', title: 'Устройства' }; export const pageMeta = { id: 'device-view', title: 'Устройства' };
function formatSessionType(sessionType) { function formatSessionType(sessionType) {
if (Number(sessionType) === 100) return 'Homeserver'; if (Number(sessionType) === 100) return 'Домашний сервер';
if (Number(sessionType) === 50) return 'Wallet'; if (Number(sessionType) === 50) return 'Кошелёк';
if (Number(sessionType) === 1) return 'Client'; if (Number(sessionType) === 1) return 'Приложение';
return `Type ${Number(sessionType) || 0}`; return `Тип ${Number(sessionType) || 0}`;
} }
// «Mozilla/5.0 (X11; Linux x86_64) … Chrome/139 …» → «Chrome · Linux».
function describeClient(userAgent) {
const ua = String(userAgent || '');
if (!ua) return 'Неизвестное устройство';
const browser = /Edg\//.test(ua) ? 'Edge'
: /OPR\/|Opera/.test(ua) ? 'Opera'
: /Firefox\//.test(ua) ? 'Firefox'
: /YaBrowser\//.test(ua) ? 'Яндекс Браузер'
: /Chrome\//.test(ua) ? 'Chrome'
: /Safari\//.test(ua) ? 'Safari'
: '';
const os = /iPhone/.test(ua) ? 'iPhone'
: /iPad/.test(ua) ? 'iPad'
: /Android/.test(ua) ? 'Android'
: /Windows/.test(ua) ? 'Windows'
: /Mac OS X|Macintosh/.test(ua) ? 'macOS'
: /Linux|X11/.test(ua) ? 'Linux'
: '';
const parts = [browser, os].filter(Boolean);
return parts.length ? parts.join(' · ') : ua.slice(0, 40);
}
// «сегодня, 02:20» / «вчера, 23:10» / «24 сентября, 18:05».
function formatSessionTime(ms) { function formatSessionTime(ms) {
return new Date(ms).toLocaleString('ru-RU', { const date = new Date(ms);
day: '2-digit', const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
month: '2-digit', const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
year: 'numeric', const days = Math.round((startOf(new Date()) - startOf(date)) / 86400000);
hour: '2-digit', if (days === 0) return `сегодня, ${time}`;
minute: '2-digit', if (days === 1) return `вчера, ${time}`;
}); const sameYear = date.getFullYear() === new Date().getFullYear();
const day = date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) });
return `${day}, ${time}`;
} }
function formatOnlineStatus(onlineOnThisServer) { function formatOnlineStatus(onlineOnThisServer) {
return onlineOnThisServer ? 'Online now' : 'Offline'; return onlineOnThisServer ? 'в сети' : 'не в сети';
} }
function sortSessionsByOnline(sessions = []) { function sortSessionsByOnline(sessions = []) {
@@ -47,15 +73,15 @@ export function render({navigate, chrome}) {
chrome?.setTopbar(createTopBar({ chrome?.setTopbar(createTopBar({
title: 'Устройства', title: 'Устройства',
back: { label: '←', onClick: () => navigate('settings-view') }, back: { label: '←', onClick: () => navigate('settings-view') },
actions: [{ icon: 'refresh', title: 'Обновить список сеансов', ariaLabel: 'Обновить список сеансов', id: 'reload-sessions', onClick: () => reloadSessions() }],
})); }));
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'card stack'; actions.className = 'nav-list';
actions.innerHTML = ` actions.innerHTML = `
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="reload-sessions-btn">Обновить сессии</button> <button class="nav-row" type="button" id="pair-by-code-btn"><span class="nav-row__label">Подключить по коду</span><span class="nav-row__hint">Войти на другом устройстве по короткому коду</span></button>
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="pair-by-code-btn">Подключить по коду</button> <button class="nav-row" type="button" id="connect-device-btn"><span class="nav-row__label">Другие способы подключения</span></button>
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="connect-device-btn">Другие способы подключения</button> <button class="nav-row" type="button" id="show-keys-btn"><span class="nav-row__label">Ключи этого устройства</span></button>
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="show-keys-btn">Показать ключи</button>
`; `;
actions.querySelector('#pair-by-code-btn').addEventListener('click', () => navigate('device-pairing-view')); actions.querySelector('#pair-by-code-btn').addEventListener('click', () => navigate('device-pairing-view'));
@@ -63,7 +89,7 @@ export function render({navigate, chrome}) {
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view')); actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
const sessionsBlock = document.createElement('div'); const sessionsBlock = document.createElement('div');
sessionsBlock.className = 'card stack'; sessionsBlock.className = 'stack device-sessions';
const buildList = () => { const buildList = () => {
sessionsBlock.innerHTML = ''; sessionsBlock.innerHTML = '';
@@ -75,22 +101,25 @@ export function render({navigate, chrome}) {
const item = document.createElement('button'); const item = document.createElement('button');
item.className = 'ui-button session-item'; item.className = 'ui-button session-item';
item.type = 'button'; item.type = 'button';
const sessionTypeText = formatSessionType(session.sessionType); const isWebClient = Number(session.sessionType) === 1 && /web/i.test(String(session.clientPlatform || ''));
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : ''; const sessionTypeText = isWebClient ? 'Браузер' : formatSessionType(session.sessionType);
const sessionPlatformText = !isWebClient && session.clientPlatform ? ` · ${session.clientPlatform}` : '';
const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer); const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer);
const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status'; const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status';
item.innerHTML = ` item.innerHTML = `
<div class="row" style="align-items:flex-start;"> <span class="session-item__main">
<div class="stack" style="gap:4px; text-align:left;"> <strong class="session-item__title"></strong>
<strong>${session.clientInfoFromClient || 'unknown client'}</strong> <span class="meta-muted">${sessionTypeText}${sessionPlatformText} · <span class="${onlineStatusClass}">${onlineStatusText}</span></span>
<span class="meta-muted"><strong>Type:</strong> ${sessionTypeText}${sessionPlatformText}</span> ${session.geo && session.geo !== 'unknown' ? '<span class="meta-muted session-item__geo"></span>' : ''}
<span class="${onlineStatusClass}"><strong>Status:</strong> ${onlineStatusText}</span> </span>
<span class="meta-muted">${session.geo || 'unknown'}</span> <span class="session-item__side">
</div>
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span> <span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
</div> </span>
${isCurrent ? '<div><span class="session-current-badge">Текущий сеанс</span></div>' : ''}
`; `;
item.querySelector('.session-item__title').textContent = describeClient(session.clientInfoFromClient);
item.title = String(session.clientInfoFromClient || '');
const geoEl = item.querySelector('.session-item__geo');
if (geoEl) geoEl.textContent = session.geo;
item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`)); item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
return item; return item;
}; };
@@ -103,60 +132,36 @@ export function render({navigate, chrome}) {
return; return;
} }
const currentMenu = document.createElement('div'); const groupTitle = (text) => {
currentMenu.className = 'stack'; const title = document.createElement('p');
currentMenu.innerHTML = '<p class="meta-muted">Текущий сеанс</p>'; title.className = 'nav-list__title';
currentMenu.append(createSessionItem(current, true)); title.textContent = text;
return title;
const endCurrentSessionBtn = document.createElement('button'); };
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn'; const currentList = document.createElement('div');
endCurrentSessionBtn.type = 'button'; currentList.className = 'nav-list';
endCurrentSessionBtn.textContent = 'Завершить текущую сессию'; currentList.append(createSessionItem(current, true));
endCurrentSessionBtn.addEventListener('click', async () => { sessionsBlock.append(groupTitle('Это устройство'), currentList, groupTitle('Другие сеансы'));
const confirmed = window.confirm('Хотите завершить текущую сессию?');
if (!confirmed) return;
try {
await terminateCurrentSession({
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
closeServerSession: true,
});
} catch (error) {
if (!isSessionInvalidError(error)) {
setAuthError(error.message);
window.alert(error.message);
return;
}
await terminateCurrentSession({
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
});
}
});
currentMenu.append(endCurrentSessionBtn);
const othersMenu = document.createElement('div');
othersMenu.className = 'stack';
othersMenu.innerHTML = '<p class="meta-muted">Остальные сеансы</p>';
if (others.length === 0) { if (others.length === 0) {
const empty = document.createElement('p'); const empty = document.createElement('p');
empty.className = 'meta-muted'; empty.className = 'empty-note';
empty.textContent = 'Других сеансов нет.'; empty.textContent = 'Других сеансов нет.';
othersMenu.append(empty); sessionsBlock.append(empty);
} else { } else {
others.forEach((session) => { const othersList = document.createElement('div');
othersMenu.append(createSessionItem(session, false)); othersList.className = 'nav-list';
}); others.forEach((session) => othersList.append(createSessionItem(session, false)));
sessionsBlock.append(othersList);
} }
sessionsBlock.append(currentMenu, othersMenu);
}; };
actions.querySelector('#reload-sessions-btn').addEventListener('click', async () => { async function reloadSessions() {
try { try {
await refreshSessions(); await refreshSessions();
buildList(); buildList();
setAuthInfo('Список сессий обновлён.'); setAuthInfo('Список сеансов обновлён.');
showToast('Список сеансов обновлён');
} catch (error) { } catch (error) {
if (isSessionInvalidError(error)) { if (isSessionInvalidError(error)) {
await terminateCurrentSession({ await terminateCurrentSession({
@@ -165,9 +170,9 @@ export function render({navigate, chrome}) {
return; return;
} }
setAuthError(error.message); setAuthError(error.message);
window.alert(error.message); showToast(error.message, { kind: 'error' });
}
} }
});
buildList(); buildList();
screen.append(actions, sessionsBlock); screen.append(actions, sessionsBlock);
+4 -4
View File
@@ -58,7 +58,7 @@ export function render() {
amountHint.style.fontSize = '22px'; amountHint.style.fontSize = '22px';
amountHint.style.fontWeight = '800'; amountHint.style.fontWeight = '800';
amountHint.style.lineHeight = '1.25'; amountHint.style.lineHeight = '1.25';
amountHint.style.color = '#1fc97a'; amountHint.style.color = 'var(--success)';
amountHint.style.textAlign = 'center'; amountHint.style.textAlign = 'center';
amountHint.textContent = ''; amountHint.textContent = '';
@@ -69,7 +69,7 @@ export function render() {
balanceHint.style.margin = '0'; balanceHint.style.margin = '0';
balanceHint.style.textAlign = 'center'; balanceHint.style.textAlign = 'center';
balanceHint.style.fontSize = '14px'; balanceHint.style.fontSize = '14px';
balanceHint.style.color = 'rgba(255,255,255,0.9)'; balanceHint.style.color = 'var(--text-primary)';
balanceHint.textContent = ''; balanceHint.textContent = '';
const closeHint = document.createElement('p'); const closeHint = document.createElement('p');
@@ -79,7 +79,7 @@ export function render() {
closeHint.style.fontSize = '26px'; closeHint.style.fontSize = '26px';
closeHint.style.fontWeight = '800'; closeHint.style.fontWeight = '800';
closeHint.style.lineHeight = '1.25'; closeHint.style.lineHeight = '1.25';
closeHint.style.color = '#1fc97a'; closeHint.style.color = 'var(--success)';
closeHint.style.textAlign = 'center'; closeHint.style.textAlign = 'center';
closeHint.textContent = 'Можете закрыть эту страницу и продолжить регистрацию.'; closeHint.textContent = 'Можете закрыть эту страницу и продолжить регистрацию.';
@@ -140,7 +140,7 @@ export function render() {
status.textContent = `Транзакция прошла.\nSignature: ${tx.signature}`; status.textContent = `Транзакция прошла.\nSignature: ${tx.signature}`;
status.style.fontSize = '13px'; status.style.fontSize = '13px';
status.style.fontWeight = '500'; status.style.fontWeight = '500';
status.style.color = 'rgba(255,255,255,0.88)'; status.style.color = 'var(--text-secondary)';
fillBtn.style.display = 'none'; fillBtn.style.display = 'none';
actions.style.display = 'none'; actions.style.display = 'none';
amountHint.style.display = ''; amountHint.style.display = '';
+6 -6
View File
@@ -11,7 +11,7 @@ export const pageMeta = { id: 'entry-settings-view', title: 'Настройки
const SERVER_FIELDS = [ const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Адрес Solana сервера' }, { key: 'solanaServer', label: 'Адрес Solana сервера' },
{ key: 'shineServerLogin', label: 'Логин сервера Сияние' }, { key: 'shineServerLogin', label: 'Сервер Сияния' },
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' }, { key: 'arweaveServer', label: 'Адрес сервера Arweave' },
]; ];
@@ -50,7 +50,7 @@ export function render({ navigate }) {
controls.className = 'row wrap-row'; controls.className = 'row wrap-row';
const checkButton = document.createElement('button'); const checkButton = document.createElement('button');
checkButton.className = 'shine-btn shine-btn--settings server-check-btn'; checkButton.className = 'secondary-btn server-check-btn';
checkButton.type = 'button'; checkButton.type = 'button';
checkButton.textContent = 'Проверить'; checkButton.textContent = 'Проверить';
@@ -132,7 +132,7 @@ export function render({ navigate }) {
if (isLocalDemoAvailable()) { if (isLocalDemoAvailable()) {
const localDemoButton = document.createElement('button'); const localDemoButton = document.createElement('button');
localDemoButton.className = 'shine-btn shine-btn--settings preauth-local-demo-btn'; localDemoButton.className = 'secondary-btn preauth-local-demo-btn';
localDemoButton.type = 'button'; localDemoButton.type = 'button';
localDemoButton.textContent = 'Открыть локальный тестовый режим'; localDemoButton.textContent = 'Открыть локальный тестовый режим';
localDemoButton.addEventListener('click', () => { localDemoButton.addEventListener('click', () => {
@@ -143,7 +143,7 @@ export function render({ navigate }) {
} }
const serverUiButton = document.createElement('button'); const serverUiButton = document.createElement('button');
serverUiButton.className = 'shine-btn shine-btn--settings'; serverUiButton.className = 'secondary-btn';
serverUiButton.type = 'button'; serverUiButton.type = 'button';
serverUiButton.textContent = 'Настроить свой сервер'; serverUiButton.textContent = 'Настроить свой сервер';
serverUiButton.addEventListener('click', () => { serverUiButton.addEventListener('click', () => {
@@ -152,13 +152,13 @@ export function render({ navigate }) {
}); });
const cancelButton = document.createElement('button'); const cancelButton = document.createElement('button');
cancelButton.className = 'shine-btn shine-btn--settings'; cancelButton.className = 'secondary-btn';
cancelButton.type = 'button'; cancelButton.type = 'button';
cancelButton.textContent = 'Отмена'; cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view')); cancelButton.addEventListener('click', () => navigate('start-view'));
const saveButton = document.createElement('button'); const saveButton = document.createElement('button');
saveButton.className = 'shine-btn shine-btn--primary'; saveButton.className = 'primary-btn';
saveButton.type = 'button'; saveButton.type = 'button';
saveButton.textContent = 'Сохранить'; saveButton.textContent = 'Сохранить';
saveButton.addEventListener('click', async () => { saveButton.addEventListener('click', async () => {
+1 -1
View File
@@ -40,7 +40,7 @@ export function render({ navigate }) {
</div> </div>
<div class="key-card stack"> <div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label> <label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне SHiNE.</p> <p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне Сияния.</p>
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" /> <input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
</div> </div>
<div class="key-card stack"> <div class="key-card stack">
+20 -37
View File
@@ -14,55 +14,38 @@ export function render({navigate, chrome}) {
const returnPage = resolveReturnPage(); const returnPage = resolveReturnPage();
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru'; let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
chrome?.setTopbar(createTopBar({ const topbar = createTopBar({
title: 'Язык / Language', title: 'Язык / Language',
back: { label: '←', onClick: () => { back: { label: '←', onClick: () => {
sessionStorage.removeItem('shine-language-return-page'); sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage); navigate(returnPage);
} }, } },
})); });
const embedTopbar = !state.session.isAuthorized;
if (!embedTopbar) chrome?.setTopbar(topbar);
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card stack language-choice-card'; card.className = 'nav-list language-list';
card.innerHTML = ` card.setAttribute('role', 'radiogroup');
<p class="meta-muted language-choice-hint">Выберите язык интерфейса</p> card.setAttribute('aria-label', 'Язык интерфейса / Interface language');
<div class="language-segmented-control" role="radiogroup" aria-label="Язык интерфейса"> [['ru', 'Русский'], ['en', 'English']].forEach(([code, label]) => {
<button class="language-segment${pendingLanguage === 'ru' ? ' is-selected' : ''}" type="button" data-language="ru" role="radio" aria-checked="${pendingLanguage === 'ru'}">Русский</button> const row = document.createElement('button');
<button class="language-segment${pendingLanguage === 'en' ? ' is-selected' : ''}" type="button" data-language="en" role="radio" aria-checked="${pendingLanguage === 'en'}">English</button> row.type = 'button';
<span class="language-segmented-thumb" aria-hidden="true"></span> row.className = 'nav-row language-row';
</div> row.setAttribute('role', 'radio');
<div class="language-choice-actions"> row.setAttribute('aria-checked', String(pendingLanguage === code));
<button class="shine-btn shine-btn--settings" type="button" data-action="cancel">Отмена</button> row.innerHTML = `<span class="nav-row__label"></span>`;
<button class="shine-btn shine-btn--primary" type="button" data-action="ok">OK</button> row.querySelector('.nav-row__label').textContent = label;
</div> row.addEventListener('click', () => {
`; pendingLanguage = code;
saveEntryLanguage(code);
const syncSelection = () => {
card.dataset.language = pendingLanguage;
card.querySelectorAll('[data-language]').forEach((item) => {
const selected = item.dataset.language === pendingLanguage;
item.classList.toggle('is-selected', selected);
item.setAttribute('aria-checked', String(selected));
});
};
syncSelection();
card.querySelectorAll('[data-language]').forEach((button) => {
button.addEventListener('click', () => {
pendingLanguage = button.dataset.language === 'en' ? 'en' : 'ru';
syncSelection();
});
});
card.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
sessionStorage.removeItem('shine-language-return-page'); sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage); navigate(returnPage);
}); });
card.querySelector('[data-action="ok"]')?.addEventListener('click', () => { card.append(row);
saveEntryLanguage(pendingLanguage);
sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage);
}); });
if (embedTopbar) screen.append(topbar);
screen.append(card); screen.append(card);
return screen; return screen;
} }
+4 -4
View File
@@ -66,7 +66,7 @@ export function render({ navigate }) {
const remoteText = document.createElement('p'); const remoteText = document.createElement('p');
remoteText.className = 'auth-copy'; remoteText.className = 'auth-copy';
const serverLoginEl = document.createElement('strong'); const serverLoginEl = document.createElement('strong');
const textBefore = document.createTextNode('Этот пользователь SHiNE зарегистрирован на другом сервере доступа: '); const textBefore = document.createTextNode('Этот пользователь Сияния зарегистрирован на другом сервере доступа: ');
remoteText.append(textBefore, serverLoginEl, document.createTextNode('. Для входа перейдите на его сервер.')); remoteText.append(textBefore, serverLoginEl, document.createTextNode('. Для входа перейдите на его сервер.'));
const serverLinkEl = document.createElement('a'); const serverLinkEl = document.createElement('a');
@@ -111,11 +111,11 @@ export function render({ navigate }) {
const resolution = String(resolved?.resolution || '').trim().toUpperCase(); const resolution = String(resolved?.resolution || '').trim().toUpperCase();
if (resolution === 'NOT_FOUND') { if (resolution === 'NOT_FOUND') {
setStatus(status, 'Пользователь с таким логином не зарегистрирован в SHiNE.'); setStatus(status, 'Пользователь с таким логином не зарегистрирован в Сиянии.');
return; return;
} }
if (resolution === 'NO_ACCESS_SERVER') { if (resolution === 'NO_ACCESS_SERVER') {
setStatus(status, 'Пользователь зарегистрирован в SHiNE, но для него не найден действующий сервер доступа.'); setStatus(status, 'Пользователь зарегистрирован в Сиянии, но для него не найден действующий сервер доступа.');
return; return;
} }
if (resolution === 'REMOTE') { if (resolution === 'REMOTE') {
@@ -155,7 +155,7 @@ export function render({ navigate }) {
screen.append( screen.append(
createTopBar({ createTopBar({
title: '', title: 'Вход',
back: { back: {
label: '←', label: '←',
onClick: async () => { onClick: async () => {
+48 -79
View File
@@ -8,21 +8,13 @@ import {
terminateCurrentSession, terminateCurrentSession,
} from '../state.js'; } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { createShineConnectionsLogo } from '../components/shine-logo.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
import { formatRelativeTime } from '../services/channels-ux.js'; import { formatListTime } from '../services/channels-ux.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const PREVIEW_MAX_LEN = 200; const PREVIEW_MAX_LEN = 200;
const SVG_CHEVRON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l6 6-6 6"></path>
</svg>
`;
const DM_BLOB_PREVIEW_CACHE = new Map(); const DM_BLOB_PREVIEW_CACHE = new Map();
const DM_BLOB_PREVIEW_PENDING = new Map(); const DM_BLOB_PREVIEW_PENDING = new Map();
const dmAvatarSnapshotCache = new Map(); const dmAvatarSnapshotCache = new Map();
@@ -119,6 +111,8 @@ function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
return `${normalized.slice(0, maxLen - 1)}…`; return `${normalized.slice(0, maxLen - 1)}…`;
} }
const DM_PREVIEW_ENCRYPTED = 'Зашифрованное сообщение';
async function resolveDialogPreview(dialog) { async function resolveDialogPreview(dialog) {
const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim()); const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim());
if (localText) return localText; if (localText) return localText;
@@ -131,6 +125,9 @@ async function resolveDialogPreview(dialog) {
String(state.session.storagePwdInMemory || '').trim(), String(state.session.storagePwdInMemory || '').trim(),
].join('|'); ].join('|');
// После перезагрузки пароля хранилища в памяти нет — расшифровать нечем. Не кэшируем,
// чтобы превью появилось, когда пароль снова будет введён.
if (!String(state.session.storagePwdInMemory || '').trim()) return DM_PREVIEW_ENCRYPTED;
if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey); if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey);
if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey); if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey);
@@ -149,7 +146,7 @@ async function resolveDialogPreview(dialog) {
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result); DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
return result; return result;
} catch { } catch {
const fallback = 'Сообщение недоступно'; const fallback = DM_PREVIEW_ENCRYPTED;
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback); DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
return fallback; return fallback;
} finally { } finally {
@@ -185,7 +182,7 @@ function latestLocalDialogMessage(peerLogin) {
} }
function formatChatRowTime(ts) { function formatChatRowTime(ts) {
return formatRelativeTime(ts); return formatListTime(ts);
} }
function compareChatRows(a, b) { function compareChatRows(a, b) {
@@ -201,63 +198,45 @@ export function render({ navigate, chrome }) {
const login = String(state.session.login || '').trim(); const login = String(state.session.login || '').trim();
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen'; screen.className = 'stack dm-screen dm-list-screen';
const brand = document.createElement('div');
brand.className = 'dm-head-brand';
const logoWrap = document.createElement('span');
logoWrap.className = 'dm-head-logo-wrap';
logoWrap.setAttribute('aria-hidden', 'true');
logoWrap.append(createShineConnectionsLogo({ className: 'dm-head-logo' }));
brand.append(logoWrap);
let currentChatFilter = 'all'; let currentChatFilter = 'all';
const filterTitle = document.createElement('button'); const CHAT_FILTERS = [
filterTitle.type = 'button'; ['all', 'Все'],
filterTitle.className = 'dm-head-filter-title'; ['close_friend', 'Близкие'],
filterTitle.textContent = 'Чаты'; ['friend', 'Друзья'],
const filterLabels = { ['contact', 'Контакты'],
all: 'Чаты', ['none', 'Новые'],
close_friend: 'Близкие друзья', ];
friend: 'Друзья',
contact: 'Контакты',
none: 'Новые',
};
let reloadForFilter = () => {}; let reloadForFilter = () => {};
const chatFilterMenu = createDropdownMenu({ const filterRow = document.createElement('div');
transparent: true, filterRow.className = 'chip-row chip-row--bleed dm-filter-row';
anchorEl: filterTitle, filterRow.setAttribute('role', 'toolbar');
placement: 'bottom-start', filterRow.setAttribute('aria-label', 'Фильтр чатов');
leftShift: 72, CHAT_FILTERS.forEach(([value, label]) => {
minWidth: 225, const chip = document.createElement('button');
items: [ chip.type = 'button';
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } }, chip.className = 'chip';
{ label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } }, chip.dataset.filter = value;
{ label: 'Друзья', action: () => { currentChatFilter = 'friend'; filterTitle.textContent = filterLabels.friend; reloadForFilter(); } }, chip.textContent = label;
{ label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } }, chip.setAttribute('aria-pressed', String(value === currentChatFilter));
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } }, chip.addEventListener('click', () => {
], if (currentChatFilter === value) return;
currentChatFilter = value;
filterRow.querySelectorAll('.chip').forEach((el) => el.setAttribute('aria-pressed', String(el.dataset.filter === value)));
reloadForFilter();
});
filterRow.append(chip);
}); });
const searchIconHtml = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
`;
const head = createTopBar({ const head = createTopBar({
left: brand, title: 'Чаты',
center: filterTitle, className: 'topbar--root',
actions: [ actions: [
{ {
iconNode: createOverflowDots(), icon: 'search',
title: 'Меню чатов', title: 'Найти человека',
ariaLabel: 'Меню чатов', ariaLabel: 'Найти человека',
className: 'messages-topbar-menu-btn topbar-overflow-action--raised', className: 'messages-search-btn',
menu: { onClick: () => navigate('contact-search-view'),
minWidth: 210,
items: [
{ label: 'Поиск пользователей', iconHtml: searchIconHtml, action: () => navigate('contact-search-view') },
],
},
}, },
], ],
}); });
@@ -269,9 +248,7 @@ function renderRow(item) {
const row = document.createElement('article'); const row = document.createElement('article');
row.className = 'list-item dm-dialog-card'; row.className = 'list-item dm-dialog-card';
const relationFlag = normalizeRelationFlag(item.relationFlag); const relationFlag = normalizeRelationFlag(item.relationFlag);
const relationBadge = relationFlag === 'none' const relationBadge = relationFlag === 'none' ? '' : relationLabel(relationFlag);
? 'не в контактах'
: relationLabel(relationFlag);
const avatarEl = createDmAvatar(item.peerLogin, { const avatarEl = createDmAvatar(item.peerLogin, {
avatar: item.avatar, avatar: item.avatar,
firstName: item.firstName, firstName: item.firstName,
@@ -280,19 +257,19 @@ function renderRow(item) {
avatarEl.classList.add('avatar'); avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div'); const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default'; avatarWrap.className = 'dm-av dm-av--default';
if (relationFlag !== 'none') row.classList.add(`dm-card--${relationFlag === 'close_friend' ? 'family' : 'contact'}`);
avatarWrap.append(avatarEl); avatarWrap.append(avatarEl);
row.innerHTML = ` row.innerHTML = `
<div class="dm-row-main"> <div class="dm-row-main">
<div class="dm-row-titleline dm-row-titlewrap"> <div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title"></strong> <strong class="dm-row-title"></strong>
<span class="dm-contact-note">${relationBadge}</span> ${relationBadge ? `<span class="dm-contact-note dm-contact-note--${relationFlag}">${relationBadge}</span>` : ''}
</div> </div>
<p class="dm-row-last-message"></p> <p class="dm-row-last-message"></p>
</div> </div>
<div class="dm-row-meta-col"> <div class="dm-row-meta-col">
<div class="dm-row-meta-line"> <div class="dm-row-meta-line">
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'} ${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
<span class="dm-chevron">${SVG_CHEVRON}</span>
</div> </div>
${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'} ${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
</div> </div>
@@ -310,6 +287,7 @@ function renderRow(item) {
void resolveDialogPreview(item).then((text) => { void resolveDialogPreview(item).then((text) => {
if (!previewEl?.isConnected) return; if (!previewEl?.isConnected) return;
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.'; previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
previewEl.classList.toggle('is-encrypted', text === DM_PREVIEW_ENCRYPTED);
}); });
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`)); row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
return row; return row;
@@ -387,20 +365,13 @@ function renderRow(item) {
if (!rows.length) { if (!rows.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'empty-note';
empty.textContent = 'Пока нет диалогов'; empty.textContent = 'Пока нет переписок. Найдите человека через поиск вверху.';
list.append(empty); list.append(empty);
return; return;
} }
let dividerInserted = false;
rows.forEach((item) => { rows.forEach((item) => {
if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) {
const divider = document.createElement('div');
divider.className = 'dm-divider';
list.append(divider);
dividerInserted = true;
}
list.append(renderRow(item)); list.append(renderRow(item));
}); });
} catch (error) { } catch (error) {
@@ -444,12 +415,10 @@ function renderRow(item) {
reloadForFilter = () => { void loadList(); }; reloadForFilter = () => { void loadList(); };
chrome?.setTopbar(head); chrome?.setTopbar(head);
screen.append(list); screen.append(filterRow, list);
loadList(); loadList();
screen.cleanup = () => { screen.cleanup = () => {};
chatFilterMenu.destroy();
};
return screen; return screen;
} }
+13 -24
View File
@@ -1,5 +1,4 @@
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { authService, state } from '../state.js'; import { authService, state } from '../state.js';
import { makeProfileRoute } from '../services/shine-routes.js'; import { makeProfileRoute } from '../services/shine-routes.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute } from '../services/shine-routes.js';
@@ -358,7 +357,7 @@ async function buildSecondLevelEngineModel(baseModel, getGraph) {
.filter(Boolean); .filter(Boolean);
if (failedParents.length) { if (failedParents.length) {
const sample = failedParents.slice(0, 3).map((node) => node.login || node.id).join(', '); const sample = failedParents.slice(0, 3).map((node) => node.login || node.id).join(', ');
throw new Error(`X2: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`); throw new Error(`Друзья друзей: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`);
} }
// ФАЗА 3. Из уже полностью полученных графов собираем кандидатов второго уровня и все рёбра. // ФАЗА 3. Из уже полностью полученных графов собираем кандидатов второго уровня и все рёбра.
@@ -930,16 +929,16 @@ export function render({ navigate, route, chrome } = {}) {
function updateHistoryChip() { function updateHistoryChip() {
if (!(historyChip instanceof HTMLButtonElement)) return; if (!(historyChip instanceof HTMLButtonElement)) return;
historyChip.textContent = historyDepth > 0 ? `История ${historyDepth}` : 'История'; historyChip.textContent = historyDepth > 0 ? `Недавние · ${historyDepth}` : 'Недавние';
historyChip.classList.toggle('is-active', historyDepth > 0); historyChip.classList.toggle('is-active', historyDepth > 0);
historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false'); historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false');
historyChip.disabled = x2Enabled; historyChip.disabled = x2Enabled;
historyChip.setAttribute('aria-disabled', x2Enabled ? 'true' : 'false'); historyChip.setAttribute('aria-disabled', x2Enabled ? 'true' : 'false');
historyChip.title = x2Enabled historyChip.title = x2Enabled
? 'X2 показывает отдельную карту и временно не использует историю.' ? '«Друзья друзей» показывают отдельную карту — недавние временно не используются.'
: (historyDepth > 0 : (historyDepth > 0
? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.` ? `На карте остаются последние ${historyDepth} человека, через которых вы переходили. Нажмите, чтобы изменить.`
: 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.'); : 'Недавние выключены. Нажмите, чтобы на карте оставался предыдущий человек.');
} }
function cycleHistoryDepth() { function cycleHistoryDepth() {
@@ -956,7 +955,7 @@ export function render({ navigate, route, chrome } = {}) {
if (!(x2Chip instanceof HTMLButtonElement)) return; if (!(x2Chip instanceof HTMLButtonElement)) return;
x2Chip.classList.toggle('is-active', x2Enabled); x2Chip.classList.toggle('is-active', x2Enabled);
x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false'); x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false');
x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.'; x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы скрыть.' : 'Показать друзей друзей.';
updateHistoryChip(); updateHistoryChip();
} }
@@ -1289,26 +1288,16 @@ export function render({ navigate, route, chrome } = {}) {
} }
const searchIconHtml = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
`;
const header = createTopBar({ const header = createTopBar({
title: 'Связи', title: 'Связи',
className: 'topbar--root',
actions: [ actions: [
{ {
iconNode: createOverflowDots(), icon: 'search',
title: 'Меню связей', title: 'Найти пользователя',
ariaLabel: 'Открыть меню связей', ariaLabel: 'Найти пользователя',
className: 'chat-header-icon-btn network-header-menu-btn', className: 'network-search-btn',
menu: { onClick: openSearchModal,
minWidth: 220,
items: [
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
],
},
}, },
], ],
}); });
@@ -1365,7 +1354,7 @@ export function render({ navigate, route, chrome } = {}) {
x2Chip = document.createElement('button'); x2Chip = document.createElement('button');
x2Chip.type = 'button'; x2Chip.type = 'button';
x2Chip.className = 'fg-filter-chip fg-x2-chip'; x2Chip.className = 'fg-filter-chip fg-x2-chip';
x2Chip.textContent = 'X2'; x2Chip.textContent = 'Друзья друзей';
x2Chip.addEventListener('click', () => { void toggleX2(); }); x2Chip.addEventListener('click', () => { void toggleX2(); });
filterBar.append(x2Chip); filterBar.append(x2Chip);
updateX2Chip(); updateX2Chip();
+26 -5
View File
@@ -106,8 +106,27 @@ function easeOutCubic(t) {
return 1 - x * x * x; return 1 - x * x * x;
} }
const RELATION_COLORS_LIGHT = {
family: 'rgba(184, 92, 20, 0.9)',
friend: 'rgba(38, 96, 196, 0.9)',
close_friend: 'rgba(38, 96, 196, 0.98)',
business: 'rgba(108, 62, 200, 0.9)',
contact: 'rgba(92, 98, 121, 0.75)',
};
function isLightTheme() {
return document.documentElement.dataset.theme === 'light';
}
function shineStroke() {
return isLightTheme()
? { halo: '#0a7fb3', core: '#06567a', blend: 'normal' }
: { halo: '#00e5ff', core: '#dffaff', blend: 'screen' };
}
function relationColor(relationType) { function relationColor(relationType) {
return RELATION_COLORS[relationType] || RELATION_COLORS.contact; const palette = isLightTheme() ? RELATION_COLORS_LIGHT : RELATION_COLORS;
return palette[relationType] || palette.contact;
} }
function resolveAvatarPhotoSrc(src) { function resolveAvatarPhotoSrc(src) {
@@ -1057,8 +1076,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро). // 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
if (pe > 0.02) { if (pe > 0.02) {
if (shine) { if (shine) {
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`); const ss = shineStroke();
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`); parts.push(`<path d="${d}" fill="none" stroke="${ss.halo}" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:${ss.blend}" />`);
parts.push(`<path d="${d}" fill="none" stroke="${ss.core}" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
} else { } else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="0.8" stroke-linecap="round" opacity="${(0.34 * pe * sp).toFixed(2)}" />`); parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="0.8" stroke-linecap="round" opacity="${(0.34 * pe * sp).toFixed(2)}" />`);
} }
@@ -1067,8 +1087,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// 2-й уровень: связь В ЦВЕТЕ ТИПА (семья/друзья/...). Сияющая связь — светящаяся линия. // 2-й уровень: связь В ЦВЕТЕ ТИПА (семья/друзья/...). Сияющая связь — светящаяся линия.
if (pe > 0.02) { if (pe > 0.02) {
if (shine) { if (shine) {
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`); const ss = shineStroke();
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`); parts.push(`<path d="${d}" fill="none" stroke="${ss.halo}" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:${ss.blend}" />`);
parts.push(`<path d="${d}" fill="none" stroke="${ss.core}" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
} else { } else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="1.0" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" />`); parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="1.0" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" />`);
} }
+17 -7
View File
@@ -158,7 +158,7 @@ function renderEmpty(activeTab) {
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов'; title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
const text = document.createElement('p'); const text = document.createElement('p');
text.className = 'meta-muted'; text.className = 'meta-muted';
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.'; text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или обсуждении, ответ появится здесь.';
card.append(title, text); card.append(title, text);
return card; return card;
} }
@@ -322,12 +322,15 @@ export function render({ navigate, chrome } = {}) {
screen.className = 'stack notifications-screen'; screen.className = 'stack notifications-screen';
const tabs = document.createElement('div'); const tabs = document.createElement('div');
tabs.className = 'notification-feed-tabs app-top-tabs'; tabs.className = 'tabs tabs--three notification-feed-tabs';
tabs.setAttribute('role', 'radiogroup');
tabs.setAttribute('aria-label', 'Категория уведомлений');
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']]; const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
chrome?.setTopbar(createTopBar({ chrome?.setTopbar(createTopBar({
center: tabs, title: 'Уведомления',
className: 'notifications-topbar', className: 'topbar--root notifications-topbar',
})); }));
screen.append(tabs);
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'stack notifications-list'; list.className = 'stack notifications-list';
@@ -352,15 +355,22 @@ export function render({ navigate, chrome } = {}) {
if (!btn) return; if (!btn) return;
let badge = btn.querySelector('.notification-toolbar-badge'); let badge = btn.querySelector('.notification-toolbar-badge');
if (state.notificationUnreadTotal <= 0) { badge?.remove(); return; } if (state.notificationUnreadTotal <= 0) { badge?.remove(); return; }
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); } if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; (btn.querySelector('.toolbar-icon') || btn).append(badge); }
badge.textContent = state.notificationUnreadTotal > 99 ? '99+' : String(state.notificationUnreadTotal); badge.textContent = state.notificationUnreadTotal > 99 ? '99+' : String(state.notificationUnreadTotal);
} }
function renderTabs(payload) { function renderTabs(payload) {
const counts = countsFromPayload(payload); const counts = countsFromPayload(payload);
tabs.replaceChildren(...tabDefs.map(([key,label]) => { tabs.replaceChildren(...tabDefs.map(([key,label]) => {
const b=document.createElement('button'); b.type='button'; b.className=`fg-filter-chip notification-tab-btn ${state.notificationsTab===key?'is-active':''}`; b.dataset.tab=key; b.setAttribute('aria-selected',state.notificationsTab===key?'true':'false'); const b=document.createElement('button'); b.type='button'; b.className='tab-btn notification-tab-btn'; b.dataset.tab=key; b.setAttribute('role','radio'); b.setAttribute('aria-checked',state.notificationsTab===key?'true':'false');
b.textContent = counts[key] > 0 ? `${label} ${counts[key]}` : label; b.textContent = label;
if (counts[key] > 0) {
const count = document.createElement('span');
count.className = 'tab-count';
count.textContent = counts[key] > 99 ? '99+' : String(counts[key]);
count.setAttribute('aria-label', `новых: ${counts[key]}`);
b.append(count);
}
b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); }); b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); });
return b; return b;
})); }));
+19 -22
View File
@@ -114,7 +114,7 @@ export function render({ navigate, chrome }) {
); );
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card stack'; card.className = 'stack profile-edit-card';
const topRow = document.createElement('div'); const topRow = document.createElement('div');
topRow.className = 'row'; topRow.className = 'row';
@@ -127,14 +127,14 @@ export function render({ navigate, chrome }) {
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div> <div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
</div> </div>
</div> </div>
<button class="primary-btn" type="button" data-reload="true">Обновить</button> <button class="secondary-btn profile-edit-reload" type="button" data-reload="true" hidden>Обновить</button>
`; `;
const badgesRow = document.createElement('div'); const badgesRow = document.createElement('div');
badgesRow.className = 'row'; badgesRow.className = 'chip-row profile-edit-statuses';
badgesRow.innerHTML = ` badgesRow.innerHTML = `
<button class="badge profile-toggle-btn is-no" type="button" data-status="account_role">Аккаунт: Не указано</button> <button class="chip profile-toggle-btn is-no" type="button" data-status="account_role">Тип аккаунта: Не указано</button>
<button class="badge profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button> <button class="chip profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
`; `;
const status = document.createElement('div'); const status = document.createElement('div');
@@ -142,16 +142,13 @@ export function render({ navigate, chrome }) {
status.textContent = 'Загрузка параметров...'; status.textContent = 'Загрузка параметров...';
const listWrap = document.createElement('div'); const listWrap = document.createElement('div');
listWrap.className = 'stack profile-param-list'; listWrap.className = 'nav-list profile-param-list';
const relativesCard = document.createElement('div'); const relativesCard = document.createElement('div');
relativesCard.className = 'card stack'; relativesCard.className = 'card stack';
relativesCard.innerHTML = ` relativesCard.innerHTML = `
<div class="profile-param-value"><b>Друзья</b></div> <div class="profile-param-value"><b>Друзья</b></div>
<div class="meta-muted"> <div class="meta-muted">Добавьте человека в друзья или в близкие друзья.</div>
Добавьте пользователя в друзья или в близкие друзья.
Родственные типы связей сохранены в протоколе, но пока скрыты из интерфейса.
</div>
<button class="secondary-btn" type="button" data-add-relative="true">Добавить друга</button> <button class="secondary-btn" type="button" data-add-relative="true">Добавить друга</button>
`; `;
@@ -250,7 +247,7 @@ export function render({ navigate, chrome }) {
function updateStatusesUi() { function updateStatusesUi() {
if (accountRoleBtn) { if (accountRoleBtn) {
accountRoleBtn.textContent = `Аккаунт: ${accountRoleLabel(currentAccountRole)}`; accountRoleBtn.textContent = `Тип аккаунта: ${accountRoleLabel(currentAccountRole)}`;
accountRoleBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested'); accountRoleBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
accountRoleBtn.classList.add(currentAccountRole === PROFILE_ACCOUNT_ROLE_PRIMARY ? 'is-yes-official' : 'is-no'); accountRoleBtn.classList.add(currentAccountRole === PROFILE_ACCOUNT_ROLE_PRIMARY ? 'is-yes-official' : 'is-no');
} }
@@ -582,22 +579,22 @@ export function render({ navigate, chrome }) {
function renderFields(fields) { function renderFields(fields) {
listWrap.innerHTML = ''; listWrap.innerHTML = '';
fields.forEach((field) => { fields.forEach((field) => {
const row = document.createElement('div'); const row = document.createElement('button');
row.className = 'card profile-param-item row'; row.type = 'button';
row.className = 'nav-row profile-param-item';
row.dataset.editField = field.key; row.dataset.editField = field.key;
row.style.cursor = 'pointer'; const value = String(field.value || '').trim();
const value = String(field.value || '').trim() || 'не заполнено'; row.innerHTML = `<span class="nav-row__hint">${escapeHtml(field.label)}</span><span class="nav-row__label${value ? '' : ' is-empty'}">${escapeHtml(value || 'Не заполнено')}</span>`;
const isNameField = field.key === 'first_name' || field.key === 'last_name';
const valueClass = isNameField ? 'profile-param-value profile-param-value-small' : 'profile-param-value';
row.innerHTML = `<div class="${valueClass}"><b>${field.label}</b>: ${escapeHtml(value)}</div>`;
listWrap.append(row); listWrap.append(row);
if (field.key === 'last_name') { if (field.key === 'last_name') {
const genderRow = document.createElement('div'); const genderRow = document.createElement('button');
genderRow.className = 'card profile-param-item row'; genderRow.type = 'button';
genderRow.className = 'nav-row profile-param-item';
genderRow.dataset.editGender = 'true'; genderRow.dataset.editGender = 'true';
genderRow.style.cursor = 'pointer'; const genderText = genderLabel(currentGender);
genderRow.innerHTML = `<div class="profile-param-value"><b>Пол</b>: <span data-gender-value>${escapeHtml(genderLabel(currentGender))}</span></div>`; const genderEmpty = genderText === 'Не указан';
genderRow.innerHTML = `<span class="nav-row__hint">Пол</span><span class="nav-row__label${genderEmpty ? ' is-empty' : ''}" data-gender-value>${escapeHtml(genderEmpty ? 'Не указан' : genderText)}</span>`;
listWrap.append(genderRow); listWrap.append(genderRow);
} }
}); });
+17 -158
View File
@@ -1,104 +1,20 @@
import { profile } from '../mock-data.js'; import { profile } from '../mock-data.js';
import { state } from '../state.js'; import { state } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { loadUserProfileCard } from '../services/user-connections.js'; import { loadUserProfileCard } from '../services/user-connections.js';
import { profileCardHtml, profileTileHtml } from '../components/profile-card.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' }; export const pageMeta = { id: 'profile-view', title: 'Профиль' };
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
const numericValue = Number(value || 0);
return `
<button
type="button"
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
data-profile-list="${escapeHtml(kind)}"
aria-label="${escapeHtml(label)}: ${numericValue}"
>
<span class="user-profile-metric-circle">${numericValue}</span>
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
</button>`;
}
function friendsMetricHtml(stats = {}) {
const friends = Number(stats.friendsCount || 0);
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<button
type="button"
class="user-profile-metric is-social-metric is-friends-combined"
data-profile-list="friends"
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
>
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
<span class="user-profile-metric-label">Друзья</span>
</button>`;
}
function hasContacts(card) {
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
}
function spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
}
function contactsDetailHtml(card) {
const rows = [
['Ссылки', card?.web],
['Телефон', card?.phone],
['Адрес', card?.address],
].filter(([, value]) => String(value || '').trim());
if (!rows.length) {
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
}
return rows.map(([label, value]) => `
<div class="user-profile-contact-row">
<span>${escapeHtml(label)}</span>
<b>${escapeHtml(value)}</b>
</div>`).join('');
}
export function render({ navigate, chrome }) { export function render({ navigate, chrome }) {
const login = String(state.session.login || profile.login || '').trim(); const login = String(state.session.login || profile.login || '').trim();
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack user-profile-screen'; screen.className = 'stack user-profile-screen';
const topbar = createTopBar({ const topbar = createTopBar({
title: login || 'Профиль', title: 'Профиль',
className: 'topbar--profile user-profile-header', className: 'topbar--root user-profile-header',
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню профиля',
ariaLabel: 'Меню профиля',
className: 'profile-head-menu-btn topbar-overflow-action--raised',
menu: {
className: 'profile-head-menu',
minWidth: 250,
items: [
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
{ label: 'Подтверждённые аккаунты', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/primary_given`) },
{ label: 'Подтверждённые сияющие', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/shine_given`) },
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
],
},
},
],
}); });
chrome?.setTopbar(topbar); chrome?.setTopbar(topbar);
@@ -114,57 +30,21 @@ export function render({ navigate, chrome }) {
function renderProfile() { function renderProfile() {
if (!card) return; if (!card) return;
const stats = card.stats || {};
const official = card.accountRole === 'primary'; const official = card.accountRole === 'primary';
const shining = card.shineStatus === 'shining'; const shining = card.shineStatus === 'shining';
const fullName = [card.firstName, card.lastName] body.innerHTML = profileCardHtml({
.map((value) => String(value || '').trim()) card,
.filter(Boolean) login,
.join(' '); tilesHtml: [
const displayName = fullName || card.login || login || 'Профиль'; profileTileHtml({ icon: 'edit', label: 'Редактировать', attrs: 'data-self-profile-action="edit"' }),
const about = String(card.about || '').trim(); profileTileHtml({ icon: 'wallet', label: 'Кошелёк', attrs: 'data-self-profile-action="wallet"' }),
const spiritualPath = String(card.spiritualPath || '').trim(); profileTileHtml({ icon: 'settings', label: 'Настройки', attrs: 'data-self-profile-action="settings"' }),
const contactsVisible = hasContacts(card); ].join(''),
}) + `
const title = topbar.querySelector('.topbar__title'); <div class="nav-list profile-more-list">
if (title) title.textContent = card.login || login || 'Профиль'; <button class="nav-row" type="button" data-profile-list="primary_given"><span class="nav-row__label">Подтверждённые мной аккаунты</span><span class="nav-row__hint">Аккаунты, за которые вы поручились</span></button>
<button class="nav-row" type="button" data-profile-list="shine_given"><span class="nav-row__label">Отмеченные мной как сияющие</span></button>
body.innerHTML = ` <button class="nav-row" type="button" data-self-profile-action="profiles"><span class="nav-row__label">Сменить профиль</span><span class="nav-row__hint">Другие аккаунты на этом устройстве</span></button>
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div>
</div>
<div class="user-profile-identity">
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
<div class="user-profile-login">@${escapeHtml(card.login || login)}</div>
</div>
<div class="user-profile-social-metrics">
${friendsMetricHtml(stats)}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
</div>
<section class="user-profile-about-card" aria-label="О себе">
<div class="user-profile-about-title">О себе</div>
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
</section>
${(contactsVisible || spiritualPath) ? `
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о профиле">
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
</div>
<section class="user-profile-detail-panel" id="profile-view-detail-panel" aria-live="polite" hidden></section>` : ''}
<div class="user-profile-actions-wrap">
<div class="user-profile-actions" aria-label="Действия со своим профилем">
<button type="button" class="user-profile-action-btn" data-self-profile-action="edit" aria-label="Редактировать профиль" title="Редактировать профиль"><img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="wallet" aria-label="Кошелёк" title="Кошелёк"><img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="settings" aria-label="Настройки" title="Настройки"><img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true"></button>
</div>
</div>`; </div>`;
const avatarSlot = body.querySelector('.user-profile-avatar-slot'); const avatarSlot = body.querySelector('.user-profile-avatar-slot');
@@ -198,31 +78,10 @@ export function render({ navigate, chrome }) {
if (action === 'edit') navigate('profile-edit-view'); if (action === 'edit') navigate('profile-edit-view');
if (action === 'wallet') navigate('wallet-view'); if (action === 'wallet') navigate('wallet-view');
if (action === 'settings') navigate('settings-view'); if (action === 'settings') navigate('settings-view');
if (action === 'profiles') navigate('profiles-view');
return; return;
} }
const detailButton = event.target.closest('[data-profile-detail]');
if (!detailButton) return;
const detailKind = detailButton.dataset.profileDetail;
const detailPanel = body.querySelector('#profile-view-detail-panel');
if (!detailPanel) return;
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
const active = button === detailButton;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
if (detailKind === 'spiritual-path') {
detailPanel.innerHTML = spiritualPathDetailHtml(card);
} else if (detailKind === 'contacts') {
detailPanel.innerHTML = contactsDetailHtml(card);
} else {
return;
}
detailPanel.hidden = false;
detailPanel.dataset.activeDetail = detailKind;
}); });
async function refresh() { async function refresh() {
+14 -15
View File
@@ -1,10 +1,10 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { import {
closeAllSavedProfiles, closeAllSavedProfiles,
closeSavedProfile, closeSavedProfile,
getSavedProfiles, getSavedProfiles,
prepareAddProfileLogin, prepareAddProfileLogin,
state,
switchToSavedProfile, switchToSavedProfile,
} from '../state.js'; } from '../state.js';
@@ -58,12 +58,12 @@ export function render({navigate, chrome}) {
const closeAllButton = document.createElement('button'); const closeAllButton = document.createElement('button');
closeAllButton.type = 'button'; closeAllButton.type = 'button';
closeAllButton.className = 'secondary-btn profiles-close-all'; closeAllButton.className = 'destructive-btn profiles-close-all';
closeAllButton.textContent = 'Закрыть все профили'; closeAllButton.textContent = 'Выйти из всех профилей';
closeAllButton.addEventListener('click', async () => { closeAllButton.addEventListener('click', async () => {
const profiles = getSavedProfiles(); const profiles = getSavedProfiles();
if (!profiles.length) return; if (!profiles.length) return;
const confirmed = window.confirm('Закрыть все профили на этом устройстве? После этого откроется экран входа.'); const confirmed = await confirmDialog({ title: 'Выйти из всех профилей?', text: 'Все профили на этом устройстве будут закрыты, откроется экран входа.', confirmLabel: 'Выйти', danger: true });
if (!confirmed) return; if (!confirmed) return;
closeAllButton.disabled = true; closeAllButton.disabled = true;
status.hidden = false; status.hidden = false;
@@ -83,10 +83,8 @@ export function render({navigate, chrome}) {
const renderList = () => { const renderList = () => {
const profiles = getSavedProfiles(); const profiles = getSavedProfiles();
const active = profiles.find((item) => item.isActive); intro.textContent = profiles.length ? '' : 'На устройстве нет сохранённых профилей.';
intro.textContent = profiles.length intro.hidden = profiles.length > 0;
? `Профилей на устройстве: ${profiles.length}. Активен: ${active?.login || state.session.login || '—'}`
: 'На устройстве нет сохранённых профилей.';
closeAllButton.disabled = profiles.length === 0; closeAllButton.disabled = profiles.length === 0;
list.innerHTML = ''; list.innerHTML = '';
@@ -97,11 +95,11 @@ export function render({navigate, chrome}) {
const select = document.createElement('button'); const select = document.createElement('button');
select.type = 'button'; select.type = 'button';
select.className = 'profiles-select'; select.className = 'profiles-select';
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="badge">Активный</span>' : ''}`; select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="profiles-active-badge">Сейчас открыт</span>' : ''}`;
select.disabled = profile.isActive; select.disabled = profile.isActive;
select.addEventListener('click', async () => { select.addEventListener('click', async () => {
if (profile.isActive) return; if (profile.isActive) return;
const confirmed = window.confirm(`Переключиться на профиль «${profile.login}»?`); const confirmed = await confirmDialog({ title: 'Переключить профиль?', text: `Откроется профиль «${profile.login}».`, confirmLabel: 'Переключить' });
if (!confirmed) return; if (!confirmed) return;
status.hidden = false; status.hidden = false;
status.className = 'status-line'; status.className = 'status-line';
@@ -118,16 +116,17 @@ export function render({navigate, chrome}) {
const close = document.createElement('button'); const close = document.createElement('button');
close.type = 'button'; close.type = 'button';
close.className = 'profiles-close'; close.className = 'profiles-close';
close.setAttribute('aria-label', `Закрыть профиль ${profile.login}`); close.setAttribute('aria-label', `Выйти из профиля ${profile.login}`);
close.title = 'Выйти из профиля';
close.textContent = '×'; close.textContent = '×';
close.addEventListener('click', async () => { close.addEventListener('click', async () => {
const others = profiles.filter((item) => item.login.toLowerCase() !== profile.login.toLowerCase()); const others = profiles.filter((item) => item.login.toLowerCase() !== profile.login.toLowerCase());
const message = profile.isActive const message = profile.isActive
? (others.length ? (others.length
? `Закрыть текущий профиль «${profile.login}»? После закрытия приложение переключится на следующий сохранённый профиль.` ? 'Приложение переключится на следующий сохранённый профиль.'
: `Закрыть текущий профиль «${profile.login}»? После закрытия откроется экран входа.`) : 'После выхода откроется экран входа.')
: `Закрыть профиль «${profile.login}» на этом устройстве?`; : 'Профиль будет закрыт на этом устройстве.';
if (!window.confirm(message)) return; if (!await confirmDialog({ title: `Выйти из профиля «${profile.login}»?`, text: message, confirmLabel: 'Выйти', danger: true })) return;
status.hidden = false; status.hidden = false;
status.className = 'status-line'; status.className = 'status-line';
@@ -247,10 +247,10 @@ function queueSeedFor(queueId) {
function styleInputField(field) { function styleInputField(field) {
if (!field) return; if (!field) return;
field.style.color = '#111111'; field.style.color = 'var(--text-primary)';
field.style.webkitTextFillColor = '#111111'; field.style.webkitTextFillColor = 'var(--text-primary)';
field.style.caretColor = '#111111'; field.style.caretColor = 'var(--accent)';
field.style.backgroundColor = '#ffffff'; field.style.backgroundColor = 'var(--surface)';
} }
function readTicketFromUrl() { function readTicketFromUrl() {
+3 -3
View File
@@ -13,7 +13,7 @@ import {
} from '../services/password-words.js'; } from '../services/password-words.js';
import { openRegistrationFaq } from './registration-faq-view.js'; import { openRegistrationFaq } from './registration-faq-view.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; export const pageMeta = { id: 'register-view', title: 'Регистрация', showAppChrome: false };
const MIN_REGISTRATION_LOGIN_LENGTH = 8; const MIN_REGISTRATION_LOGIN_LENGTH = 8;
function normalizeLoginForTemporaryUiGuard(login) { function normalizeLoginForTemporaryUiGuard(login) {
@@ -132,7 +132,7 @@ export function render({ navigate }) {
} }
function updatePasswordLength() { function updatePasswordLength() {
passwordLengthText.textContent = `Итоговая длина пароля: ${getCurrentPassword().length} символов.`; passwordLengthText.textContent = `Символов в пароле: ${getCurrentPassword().length}`;
} }
function setStatusMessage(message, kind = '') { function setStatusMessage(message, kind = '') {
@@ -425,7 +425,7 @@ export function render({ navigate }) {
screen.append( screen.append(
createTopBar({ createTopBar({
title: 'Зарегистрироваться', title: 'Регистрация',
back: { back: {
label: '←', label: '←',
onClick: () => { onClick: () => {
+19 -17
View File
@@ -10,7 +10,7 @@ export const REGISTRATION_FAQ_TOPICS = [
title: 'У кого хранятся ключи?', title: 'У кого хранятся ключи?',
paragraphs: [ paragraphs: [
'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.', 'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.',
'SHiNE не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.', 'Сияние не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.', 'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.',
], ],
}, },
@@ -26,11 +26,11 @@ export const REGISTRATION_FAQ_TOPICS = [
}, },
{ {
id: 'key-derivation', id: 'key-derivation',
shortTitle: 'Деривация', shortTitle: 'Как пароль превращается в ключи',
title: 'Как генерируются ключи и что делает пароль?', title: 'Как генерируются ключи и что делает пароль?',
paragraphs: [ paragraphs: [
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.', 'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
'Уже из этого секрета детерминированно строятся четыре основных ключа: recovery key, root key, blockchain key и client key.', 'Уже из этого секрета детерминированно строятся четыре основных ключа: ключ восстановления, главный ключ, ключ публикаций и ключ устройства.',
'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.', 'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.',
], ],
}, },
@@ -39,9 +39,9 @@ export const REGISTRATION_FAQ_TOPICS = [
shortTitle: 'Три ключа', shortTitle: 'Три ключа',
title: 'Зачем нужны три ключа?', title: 'Зачем нужны три ключа?',
paragraphs: [ paragraphs: [
'Root key нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.', 'Главный ключ нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
'Blockchain key нужен для подписания действий и записей в блокчейне SHiNE.', 'Ключ публикаций нужен для подписания действий и записей в блокчейне Сияния.',
'Client key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.', 'Ключ устройства нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.', 'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.',
], ],
}, },
@@ -78,9 +78,9 @@ export const REGISTRATION_FAQ_TOPICS = [
{ {
id: 'first-server', id: 'first-server',
shortTitle: 'Первый сервер', shortTitle: 'Первый сервер',
title: 'Что такое первый сервер SHiNE?', title: 'Что такое первый сервер Сияния?',
paragraphs: [ paragraphs: [
'Первый сервер SHiNE это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.', 'Первый сервер Сияния это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
'Позже вы сможете сменить сервер, а ваши данные останутся с вами. В будущем серверов может быть несколько одновременно.', 'Позже вы сможете сменить сервер, а ваши данные останутся с вами. В будущем серверов может быть несколько одновременно.',
'Если серверов несколько, данные между ними будут синхронизироваться автоматически. Если добавляете новый сервер и убираете старый, просто дождитесь завершения синхронизации перед отключением старого.', 'Если серверов несколько, данные между ними будут синхронизироваться автоматически. Если добавляете новый сервер и убираете старый, просто дождитесь завершения синхронизации перед отключением старого.',
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.', 'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
@@ -93,7 +93,7 @@ export const REGISTRATION_FAQ_TOPICS = [
paragraphs: [ paragraphs: [
'На новом устройстве выберите вход через другое устройство и получите код подключения.', 'На новом устройстве выберите вход через другое устройство и получите код подключения.',
'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.', 'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.',
'Client key передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны root key и/или blockchain key, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.', 'Ключ устройства передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны главный ключ и/или ключ публикаций, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
], ],
}, },
{ {
@@ -118,7 +118,7 @@ export const REGISTRATION_FAQ_TOPICS = [
shortTitle: 'Кошелёк', shortTitle: 'Кошелёк',
title: 'Можно ли использовать такое устройство как кошелёк?', title: 'Можно ли использовать такое устройство как кошелёк?',
paragraphs: [ paragraphs: [
'Да. Идея SHiNE в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.', 'Да. Идея Сияния в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
'То есть это направление совместимо с моделью аппаратного кошелька: вы храните ключи у себя, а устройство подписывает то, что вы разрешили.', 'То есть это направление совместимо с моделью аппаратного кошелька: вы храните ключи у себя, а устройство подписывает то, что вы разрешили.',
'Пока ещё не все валюты и сценарии доведены до готового пользовательского уровня, но архитектурно это именно путь к универсальному подписывающему устройству.', 'Пока ещё не все валюты и сценарии доведены до готового пользовательского уровня, но архитектурно это именно путь к универсальному подписывающему устройству.',
], ],
@@ -142,7 +142,6 @@ export function render({ navigate }) {
const heroCard = document.createElement('div'); const heroCard = document.createElement('div');
heroCard.className = 'card stack registration-faq-hero'; heroCard.className = 'card stack registration-faq-hero';
heroCard.innerHTML = ` heroCard.innerHTML = `
<div class="badge alt">Вопросы о регистрации</div>
<p class="auth-copy">Короткие ответы на самые частые вопросы о ключах, пароле, первом сервере и доверенных устройствах.</p> <p class="auth-copy">Короткие ответы на самые частые вопросы о ключах, пароле, первом сервере и доверенных устройствах.</p>
`; `;
@@ -174,20 +173,23 @@ export function render({ navigate }) {
} }
const topicsCard = document.createElement('div'); const topicsCard = document.createElement('div');
topicsCard.className = 'card stack'; topicsCard.className = 'stack';
const topicsLabel = document.createElement('p'); const topicsLabel = document.createElement('p');
topicsLabel.className = 'field-label'; topicsLabel.className = 'nav-list__title';
topicsLabel.textContent = 'Другие вопросы'; topicsLabel.textContent = 'Другие вопросы';
const topicsGrid = document.createElement('div'); const topicsGrid = document.createElement('div');
topicsGrid.className = 'registration-faq-grid'; topicsGrid.className = 'nav-list';
REGISTRATION_FAQ_TOPICS.forEach((topic) => { REGISTRATION_FAQ_TOPICS.filter((topic) => topic.id !== selectedTopic.id).forEach((topic) => {
const button = document.createElement('button'); const button = document.createElement('button');
button.className = topic.id === selectedTopic.id ? 'secondary-btn' : 'ghost-btn'; button.className = 'nav-row';
button.type = 'button'; button.type = 'button';
button.textContent = topic.shortTitle; const label = document.createElement('span');
label.className = 'nav-row__label';
label.textContent = topic.title;
button.append(label);
button.addEventListener('click', () => { button.addEventListener('click', () => {
state.registrationHelp.selectedTopic = topic.id; state.registrationHelp.selectedTopic = topic.id;
navigate('registration-faq-view'); navigate('registration-faq-view');
+1 -1
View File
@@ -83,7 +83,7 @@ export function render({ navigate }) {
const blockchainRow = createKeyInfo( const blockchainRow = createKeyInfo(
blockchainToggle, blockchainToggle,
'Ключ blockchain', 'Ключ blockchain',
'Используется для подписи ваших действий и записей в блокчейне SHiNE.', 'Используется для подписи ваших действий и записей в блокчейне Сияния.',
); );
const deviceRow = createKeyInfo( const deviceRow = createKeyInfo(
+7 -11
View File
@@ -6,7 +6,7 @@ export const pageMeta = { id: 'server-settings-view', title: 'Серверы б
const SERVER_FIELDS = [ const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Адрес Solana сервера' }, { key: 'solanaServer', label: 'Адрес Solana сервера' },
{ key: 'shineServerLogin', label: 'Логин сервера Сияние' }, { key: 'shineServerLogin', label: 'Сервер Сияния' },
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' }, { key: 'arweaveServer', label: 'Адрес сервера Arweave' },
]; ];
@@ -27,13 +27,9 @@ export function render({navigate, chrome}) {
const timers = new Map(); const timers = new Map();
const introCard = document.createElement('div'); const introCard = document.createElement('div');
introCard.className = 'card stack'; introCard.className = 'settings-intro';
introCard.innerHTML = ` introCard.innerHTML = `
<p class="field-label">Серверы блокчейнов и публичных данных</p> <p class="meta-muted">Откуда приложение читает публичные данные: Solana, Сияние и Arweave. Личные серверы доступа здесь не меняются.</p>
<p class="meta-muted">
Здесь настраиваются Solana, SHiNE и Arweave для чтения публичных данных и доступа к блокчейну.
Эти настройки не меняют список личных серверов доступа пользователя.
</p>
`; `;
const body = document.createElement('div'); const body = document.createElement('div');
@@ -138,7 +134,7 @@ export function render({navigate, chrome}) {
const callTimeoutLabel = document.createElement('label'); const callTimeoutLabel = document.createElement('label');
callTimeoutLabel.className = 'field-label'; callTimeoutLabel.className = 'field-label';
callTimeoutLabel.textContent = 'Таймаут пред-подключения перед звонком (мс)'; callTimeoutLabel.textContent = 'Сколько ждать связи с сервером перед звонком (мс)';
const callTimeoutInput = document.createElement('input'); const callTimeoutInput = document.createElement('input');
callTimeoutInput.className = 'input'; callTimeoutInput.className = 'input';
@@ -155,13 +151,13 @@ export function render({navigate, chrome}) {
const callTimeoutHint = document.createElement('p'); const callTimeoutHint = document.createElement('p');
callTimeoutHint.className = 'meta-muted'; callTimeoutHint.className = 'meta-muted';
callTimeoutHint.textContent = 'Перед исходящим звонком клиент проверяет и восстанавливает WS-сессию. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».'; callTimeoutHint.textContent = 'Перед звонком приложение проверяет и при необходимости восстанавливает соединение с сервером. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».';
callSettings.append(callTimeoutLabel, callTimeoutInput, callTimeoutHint); callSettings.append(callTimeoutLabel, callTimeoutInput, callTimeoutHint);
body.append(callSettings); body.append(callSettings);
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'auth-footer-actions'; actions.className = 'action-pair';
const cancelButton = document.createElement('button'); const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn'; cancelButton.className = 'ghost-btn';
@@ -190,7 +186,7 @@ export function render({navigate, chrome}) {
help.textContent = '?'; help.textContent = '?';
help.addEventListener('click', () => { help.addEventListener('click', () => {
window.alert( window.alert(
'Текст для разработчиков: для SHiNE вводится логин серверного аккаунта. Клиент читает его PDA, берёт server_address, показывает точный https-адрес и проверяет доступность WS-канала автоматически.', 'Текст для разработчиков: для Сияния вводится логин серверного аккаунта. Клиент читает его PDA, берёт server_address, показывает точный https-адрес и проверяет доступность WS-канала автоматически.',
); );
}); });
+67 -27
View File
@@ -1,6 +1,9 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js'; import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
import { isDeveloperToolsEnabled } from '../services/feature-settings.js'; import { isDeveloperToolsEnabled } from '../services/feature-settings.js';
import { getThemeMode, setThemeMode } from '../services/theme-service.js';
import { openPaletteEditor } from '../components/palette-editor.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' }; export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -39,7 +42,7 @@ export function render({navigate, chrome}) {
const logoButton = document.createElement('button'); const logoButton = document.createElement('button');
logoButton.type = 'button'; logoButton.type = 'button';
logoButton.className = 'settings-shine-logo-button'; logoButton.className = 'settings-shine-logo-button';
logoButton.setAttribute('aria-label', 'Логотип SHiNE'); logoButton.setAttribute('aria-label', 'Логотип Сияния');
logoButton.title = 'SHiNE'; logoButton.title = 'SHiNE';
logoButton.innerHTML = '<img class="settings-shine-logo" src="/img/shine-logo-transparent-final_big.png" alt="SHiNE" />'; logoButton.innerHTML = '<img class="settings-shine-logo" src="/img/shine-logo-transparent-final_big.png" alt="SHiNE" />';
@@ -55,32 +58,65 @@ export function render({navigate, chrome}) {
}); });
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card stack'; card.className = 'stack settings-sections';
const row = (id, label, hint) => `<button class="nav-row" type="button" id="${id}"><span class="nav-row__label">${label}</span>${hint ? `<span class="nav-row__hint">${hint}</span>` : ''}</button>`;
card.innerHTML = ` card.innerHTML = `
<button class="shine-btn shine-btn--settings" type="button" id="settings-device">Устройства</button> <div class="card stack settings-theme-card">
<button class="shine-btn shine-btn--settings" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button> <strong id="settings-theme-label">Оформление</strong>
<button class="shine-btn shine-btn--settings" type="button" id="settings-access-servers"> <div class="tabs tabs--auto" id="settings-theme" role="radiogroup" aria-labelledby="settings-theme-label">
<span style="display:block; text-align:left;"> <button type="button" class="tab-btn" role="radio" data-mode="system">Авто</button>
<strong>Сервер доступа</strong> <button type="button" class="tab-btn" role="radio" data-mode="light">День</button>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span> <button type="button" class="tab-btn" role="radio" data-mode="dark">Ночь</button>
</span> </div>
</button> <span class="meta-muted">«Авто» — как на устройстве.</span>
<button class="shine-btn shine-btn--settings" type="button" id="settings-blockchain-servers"> </div>
<span style="display:block; text-align:left;"> <div class="nav-list">
<strong>Серверы блокчейнов</strong> ${row('settings-device', 'Устройства', 'Сеансы, подключение устройств, ключи')}
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span> ${row('settings-access-servers', 'Сервер доступа', 'Личная переписка, звонки и зашифрованные данные')}
</span> ${row('settings-blockchain-servers', 'Серверы блокчейнов', 'Solana, Сияние и Arweave для публичных данных')}
</button> ${row('settings-arweave-uploads', 'Файлы в блокчейне', 'Заранее загрузить файл и выбрать его потом')}
<button class="shine-btn shine-btn--settings" type="button" id="settings-arweave-uploads"> ${row('settings-remote-addblock', 'Публикация через домашний сервер', 'Когда ключа публикаций нет на этом устройстве')}
<span style="display:block; text-align:left;"> ${row('settings-language', 'Язык / Language', 'Русский')}
<strong>Загрузить файлы в блокчейн</strong> </div>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span> <div class="nav-list">
</span> <button class="nav-row nav-row--danger" type="button" id="settings-signout"><span class="nav-row__label">Выйти из аккаунта на этом устройстве</span></button>
</button> </div>
<button class="shine-btn shine-btn--settings" type="button" id="settings-language">Язык / Language</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-signout">Завершить текущий сеанс</button>
`; `;
const themeSwitch = card.querySelector('#settings-theme');
const syncThemeSwitch = () => {
const mode = getThemeMode();
themeSwitch.querySelectorAll('[data-mode]').forEach((btn) => {
btn.setAttribute('aria-checked', String(btn.dataset.mode === mode));
});
};
syncThemeSwitch();
const LONG_PRESS_MS = 600;
let longPressTimer = 0;
let longPressFired = false;
const cancelLongPress = () => { window.clearTimeout(longPressTimer); longPressTimer = 0; };
themeSwitch.addEventListener('pointerdown', (event) => {
const mode = event.target.closest('[data-mode]')?.dataset.mode;
longPressFired = false;
if (mode !== 'light' && mode !== 'dark') return;
longPressTimer = window.setTimeout(() => {
longPressFired = true;
setThemeMode(mode);
syncThemeSwitch();
openPaletteEditor({ theme: mode });
}, LONG_PRESS_MS);
});
['pointerup', 'pointerleave', 'pointercancel'].forEach((type) => themeSwitch.addEventListener(type, cancelLongPress));
// На телефоне долгое нажатие иначе вызывает системное меню выделения.
themeSwitch.addEventListener('contextmenu', (event) => event.preventDefault());
themeSwitch.addEventListener('click', (event) => {
const mode = event.target.closest('[data-mode]')?.dataset.mode;
if (!mode || longPressFired) return;
setThemeMode(mode);
syncThemeSwitch();
});
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view')); card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view')); card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view')); card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
@@ -93,9 +129,12 @@ export function render({navigate, chrome}) {
const signOutBtn = card.querySelector('#settings-signout'); const signOutBtn = card.querySelector('#settings-signout');
signOutBtn.addEventListener('click', async () => { signOutBtn.addEventListener('click', async () => {
const confirmed = window.confirm( const confirmed = await confirmDialog({
'Завершить текущий профиль на этом устройстве? Если есть другие сохранённые профили, приложение переключится на следующий.' title: 'Выйти из аккаунта?',
); text: 'Вы выйдете на этом устройстве. Если сохранены другие профили, приложение переключится на следующий.',
confirmLabel: 'Выйти',
danger: true,
});
if (!confirmed) return; if (!confirmed) return;
signOutBtn.disabled = true; signOutBtn.disabled = true;
@@ -170,6 +209,7 @@ export function render({navigate, chrome}) {
screen.cleanup = () => { screen.cleanup = () => {
isDisposed = true; isDisposed = true;
cancelLongPress();
}; };
return screen; return screen;
} }
+12 -23
View File
@@ -4,7 +4,7 @@ import { bytesToBase58 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js'; import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js'; import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' }; export const pageMeta = { id: 'show-keys-view', title: 'Ключи этого устройства' };
export function render({navigate, chrome}) { export function render({navigate, chrome}) {
const screen = document.createElement('section'); const screen = document.createElement('section');
@@ -23,19 +23,18 @@ export function render({navigate, chrome}) {
}; };
chrome?.setTopbar(createTopBar({ chrome?.setTopbar(createTopBar({
title: 'Показать ключи', title: 'Ключи этого устройства',
back: { label: '←', onClick: () => navigate('device-view') }, back: { label: '←', onClick: () => navigate('device-view') },
})); }));
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card stack'; card.className = 'nav-list key-list';
const status = document.createElement('p'); const status = document.createElement('p');
status.className = 'meta-muted'; status.className = 'meta-muted';
status.textContent = 'Загружаем сохранённые ключи...'; status.textContent = 'Загружаем сохранённые ключи...';
card.append(status);
const renderField = (id, label) => { const renderField = (id, label, hint = '') => {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'key-card stack'; row.className = 'key-card stack';
const eyeIcon = ` const eyeIcon = `
@@ -53,8 +52,8 @@ export function render({navigate, chrome}) {
`; `;
row.innerHTML = ` row.innerHTML = `
<div class="row"> <div class="row">
<span class="field-label">${label}</span> <span class="field-label">${label}${hint ? `<small class="key-card__hint">${hint}</small>` : ''}</span>
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeOffIcon}</button> <button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeIcon}</button>
</div> </div>
<div class="key-value key-value--compact" data-value="${id}">*****</div> <div class="key-value key-value--compact" data-value="${id}">*****</div>
`; `;
@@ -64,9 +63,9 @@ export function render({navigate, chrome}) {
}; };
card.append( card.append(
renderField('root', 'root key (base58)'), renderField('root', 'Главный ключ', 'Восстановление аккаунта и важные настройки'),
renderField('blockchain', 'blockchain.key (base58)'), renderField('blockchain', 'Ключ публикаций', 'Подпись ваших записей'),
renderField('device', 'client key (base58)'), renderField('device', 'Ключ устройства', 'Вход с этого устройства'),
); );
const setMissingState = (id) => { const setMissingState = (id) => {
@@ -91,8 +90,8 @@ export function render({navigate, chrome}) {
valueEl.textContent = visible[id] ? keys[id] : '*****'; valueEl.textContent = visible[id] ? keys[id] : '*****';
btnEl.disabled = false; btnEl.disabled = false;
btnEl.innerHTML = visible[id] btnEl.innerHTML = visible[id]
? field?._eyeIcon || '' ? field?._eyeOffIcon || ''
: field?._eyeOffIcon || ''; : field?._eyeIcon || '';
btnEl.setAttribute('aria-label', visible[id] ? 'Скрыть ключ' : 'Показать ключ'); btnEl.setAttribute('aria-label', visible[id] ? 'Скрыть ключ' : 'Показать ключ');
btnEl.title = visible[id] ? 'Скрыть ключ' : 'Показать ключ'; btnEl.title = visible[id] ? 'Скрыть ключ' : 'Показать ключ';
}; };
@@ -108,16 +107,6 @@ export function render({navigate, chrome}) {
['root', 'blockchain', 'device'].forEach((id) => updateField(id)); ['root', 'blockchain', 'device'].forEach((id) => updateField(id));
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const closeButton = document.createElement('button');
closeButton.className = 'ghost-btn';
closeButton.type = 'button';
closeButton.textContent = 'Назад';
closeButton.addEventListener('click', () => navigate('device-view'));
actions.append(closeButton);
(async () => { (async () => {
try { try {
if (!state.session.login || !state.session.storagePwdInMemory) { if (!state.session.login || !state.session.storagePwdInMemory) {
@@ -149,6 +138,6 @@ export function render({navigate, chrome}) {
['root', 'blockchain', 'device'].forEach((id) => updateField(id)); ['root', 'blockchain', 'device'].forEach((id) => updateField(id));
})(); })();
screen.append(card, actions); screen.append(status, card);
return screen; return screen;
} }
+4 -4
View File
@@ -31,13 +31,13 @@ export function render({ navigate }) {
loginButton.addEventListener('click', () => navigate('login-view')); loginButton.addEventListener('click', () => navigate('login-view'));
const registerButton = document.createElement('button'); const registerButton = document.createElement('button');
registerButton.className = 'shine-btn shine-btn--register'; registerButton.className = 'secondary-btn shine-btn--register';
registerButton.type = 'button'; registerButton.type = 'button';
registerButton.textContent = 'Зарегистрироваться'; registerButton.textContent = 'Зарегистрироваться';
registerButton.addEventListener('click', () => navigate('register-view')); registerButton.addEventListener('click', () => navigate('register-view'));
const languageButton = document.createElement('button'); const languageButton = document.createElement('button');
languageButton.className = 'shine-btn shine-btn--view'; languageButton.className = 'text-btn start-language-link';
languageButton.type = 'button'; languageButton.type = 'button';
languageButton.textContent = 'Язык / Language'; languageButton.textContent = 'Язык / Language';
languageButton.addEventListener('click', () => { languageButton.addEventListener('click', () => {
@@ -46,9 +46,9 @@ export function render({ navigate }) {
}); });
const settingsButton = document.createElement('button'); const settingsButton = document.createElement('button');
settingsButton.className = 'shine-btn shine-btn--settings'; settingsButton.className = 'text-btn start-settings-link';
settingsButton.type = 'button'; settingsButton.type = 'button';
settingsButton.textContent = 'Настройки'; settingsButton.textContent = 'Настройки входа';
settingsButton.addEventListener('click', () => navigate('entry-settings-view')); settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
actions.append(loginButton, registerButton, languageButton, settingsButton); actions.append(loginButton, registerButton, languageButton, settingsButton);
@@ -3,7 +3,7 @@ import { authService, setAuthError, setAuthInfo, state } from '../state.js';
import { deriveEspPairingPasswordHash } from '../services/device-pairing-service.js'; import { deriveEspPairingPasswordHash } from '../services/device-pairing-service.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'trusted-device-login-settings-view', title: 'Настройки входа через устройство' }; export const pageMeta = { id: 'trusted-device-login-settings-view', title: 'Вход с другого устройства' };
function setStatus(statusEl, message, kind = 'info') { function setStatus(statusEl, message, kind = 'info') {
statusEl.classList.toggle('is-unavailable', kind === 'error'); statusEl.classList.toggle('is-unavailable', kind === 'error');
@@ -38,15 +38,14 @@ export function render({navigate, chrome}) {
status.style.display = 'none'; status.style.display = 'none';
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'row'; actions.className = 'stack trusted-login-actions';
actions.style.flexWrap = 'wrap';
const enableToggleBtn = document.createElement('button'); const enableToggleBtn = document.createElement('button');
enableToggleBtn.className = 'primary-btn'; enableToggleBtn.className = 'primary-btn';
enableToggleBtn.type = 'button'; enableToggleBtn.type = 'button';
const noPasswordBtn = document.createElement('button'); const noPasswordBtn = document.createElement('button');
noPasswordBtn.className = 'ghost-btn'; noPasswordBtn.className = 'secondary-btn';
noPasswordBtn.type = 'button'; noPasswordBtn.type = 'button';
noPasswordBtn.textContent = 'Сделать вход без пароля'; noPasswordBtn.textContent = 'Сделать вход без пароля';
@@ -77,6 +76,7 @@ export function render({navigate, chrome}) {
busy = flag; busy = flag;
enableToggleBtn.disabled = flag; enableToggleBtn.disabled = flag;
noPasswordBtn.disabled = flag || !settings.enabled || !settings.hasPassword; noPasswordBtn.disabled = flag || !settings.enabled || !settings.hasPassword;
noPasswordBtn.hidden = !settings.enabled || !settings.hasPassword;
savePasswordBtn.disabled = flag; savePasswordBtn.disabled = flag;
passwordInput.disabled = flag; passwordInput.disabled = flag;
passwordConfirmInput.disabled = flag; passwordConfirmInput.disabled = flag;
@@ -87,6 +87,7 @@ export function render({navigate, chrome}) {
enableToggleBtn.textContent = settings.enabled enableToggleBtn.textContent = settings.enabled
? 'Запретить вход через другое устройство' ? 'Запретить вход через другое устройство'
: 'Разрешить вход через другое устройство'; : 'Разрешить вход через другое устройство';
enableToggleBtn.className = settings.enabled ? 'destructive-btn' : 'primary-btn';
actions.innerHTML = ''; actions.innerHTML = '';
actions.append(enableToggleBtn); actions.append(enableToggleBtn);
@@ -183,7 +184,7 @@ export function render({navigate, chrome}) {
}); });
chrome?.setTopbar(createTopBar({ chrome?.setTopbar(createTopBar({
title: 'Настройки входа через устройство', title: 'Вход с другого устройства',
back: { label: '←', onClick: () => navigate('device-pairing-view') }, back: { label: '←', onClick: () => navigate('device-pairing-view') },
})); }));
screen.append( screen.append(
+13 -139
View File
@@ -5,6 +5,7 @@ import { authService, state } from '../state.js';
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js'; import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js'; import { navigateBack } from '../router.js';
import { profileCardHtml, profileTileHtml } from '../components/profile-card.js';
export const pageMeta = { id: 'user', title: 'Профиль' }; export const pageMeta = { id: 'user', title: 'Профиль' };
@@ -24,68 +25,6 @@ function effectiveSocial(flags = {}) {
return 'none'; return 'none';
} }
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
const numericValue = Number(value || 0);
return `
<button
type="button"
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
data-profile-list="${escapeHtml(kind)}"
aria-label="${escapeHtml(label)}: ${numericValue}"
>
<span class="user-profile-metric-circle">${numericValue}</span>
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
</button>`;
}
function friendsMetricHtml(stats = {}) {
const friends = Number(stats.friendsCount || 0);
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<button
type="button"
class="user-profile-metric is-social-metric is-friends-combined"
data-profile-list="friends"
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
>
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
<span class="user-profile-metric-label">Друзья</span>
</button>`;
}
function hasContacts(card) {
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
}
function spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
}
function contactsDetailHtml(card) {
const rows = [
['Ссылки', card?.web],
['Телефон', card?.phone],
['Адрес', card?.address],
].filter(([, value]) => String(value || '').trim());
if (!rows.length) {
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
}
return rows.map(([label, value]) => `
<div class="user-profile-contact-row">
<span>${escapeHtml(label)}</span>
<b>${escapeHtml(value)}</b>
</div>`).join('');
}
function addIconHtml() {
return `
<svg class="user-profile-action-svg" viewBox="0 0 40 40" aria-hidden="true">
<path d="M10.5 20.5 17 27l13-14" />
</svg>`;
}
function relationMenuHtml(flags = {}) { function relationMenuHtml(flags = {}) {
const current = effectiveSocial(flags); const current = effectiveSocial(flags);
const rows = [ const rows = [
@@ -107,7 +46,7 @@ export function render({ navigate, route, chrome }) {
screen.className = 'stack user-profile-screen'; screen.className = 'stack user-profile-screen';
const header = createTopBar({ const header = createTopBar({
title: requestedLogin || 'Профиль', title: 'Профиль',
back: { label: '←', onClick: () => navigateBack() }, back: { label: '←', onClick: () => navigateBack() },
}); });
header.classList.add('user-profile-header'); header.classList.add('user-profile-header');
@@ -186,60 +125,20 @@ export function render({ navigate, route, chrome }) {
function renderProfile() { function renderProfile() {
if (!card) return; if (!card) return;
const isSelf = card.login.toLowerCase() === selfLogin.toLowerCase(); const isSelf = card.login.toLowerCase() === selfLogin.toLowerCase();
const stats = card.stats || {};
const official = card.accountRole === 'primary'; const official = card.accountRole === 'primary';
const shining = card.shineStatus === 'shining'; const shining = card.shineStatus === 'shining';
const fullName = [card.firstName, card.lastName]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' ');
const displayName = fullName || card.login || 'Профиль';
const about = String(card.about || '').trim();
const spiritualPath = String(card.spiritualPath || '').trim();
const contactsVisible = hasContacts(card);
const title = header.querySelector('.topbar__title'); body.innerHTML = profileCardHtml({
if (title) title.textContent = card.login; card,
login: requestedLogin,
body.innerHTML = ` isSelf,
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}"> beforeTilesHtml: '<div class="user-profile-add-menu" hidden></div>',
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })} tilesHtml: isSelf ? '' : [
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })} profileTileHtml({ icon: 'user-plus', label: 'Добавить', attrs: 'data-profile-action="add" aria-haspopup="menu" aria-expanded="false"' }),
<div class="user-profile-avatar-slot"></div> profileTileHtml({ label: 'Связи', attrs: 'data-profile-action="links"', iconMarkup: `<img class="pf-tile-mandala" src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true">` }),
</div> profileTileHtml({ icon: 'message', label: 'Написать', attrs: 'data-profile-action="chat"' }),
].join(''),
<div class="user-profile-identity"> });
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
<div class="user-profile-login">@${escapeHtml(card.login)}</div>
</div>
<div class="user-profile-social-metrics">
${friendsMetricHtml(stats)}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
</div>
<section class="user-profile-about-card" aria-label="О себе">
<div class="user-profile-about-title">О себе</div>
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
</section>
${(contactsVisible || spiritualPath) ? `
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о пользователе">
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
</div>
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>` : ''}
${!isSelf ? `
<div class="user-profile-actions-wrap">
<div class="user-profile-add-menu" hidden></div>
<div class="user-profile-actions" aria-label="Действия с пользователем">
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">${addIconHtml()}</button>
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи"><img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение"><img src="/assets/icon_lichnye.png" alt="" aria-hidden="true"></button>
</div>
</div>` : ''}`;
const avatarSlot = body.querySelector('.user-profile-avatar-slot'); const avatarSlot = body.querySelector('.user-profile-avatar-slot');
avatarSlot?.append(renderUserAvatar({ avatarSlot?.append(renderUserAvatar({
@@ -272,31 +171,6 @@ export function render({ navigate, route, chrome }) {
return; return;
} }
const detailButton = event.target.closest('[data-profile-detail]');
if (detailButton) {
const detailKind = detailButton.dataset.profileDetail;
const detailPanel = body.querySelector('#user-profile-detail-panel');
if (!detailPanel) return;
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
const active = button === detailButton;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
if (detailKind === 'spiritual-path') {
detailPanel.innerHTML = spiritualPathDetailHtml(card);
} else if (detailKind === 'contacts') {
detailPanel.innerHTML = contactsDetailHtml(card);
} else {
return;
}
detailPanel.hidden = false;
detailPanel.dataset.activeDetail = detailKind;
return;
}
const relationButton = event.target.closest('[data-relation-kind]'); const relationButton = event.target.closest('[data-relation-kind]');
if (relationButton) { if (relationButton) {
if (!selfLogin) { if (!selfLogin) {
+24 -51
View File
@@ -531,10 +531,10 @@ export function render({navigate, chrome}) {
function styleSupportInputField(field) { function styleSupportInputField(field) {
if (!field) return; if (!field) return;
field.style.color = '#111111'; field.style.color = 'var(--text-primary)';
field.style.webkitTextFillColor = '#111111'; field.style.webkitTextFillColor = 'var(--text-primary)';
field.style.caretColor = '#111111'; field.style.caretColor = 'var(--accent)';
field.style.backgroundColor = '#ffffff'; field.style.backgroundColor = 'var(--surface)';
} }
function renderSupportHub() { function renderSupportHub() {
@@ -1283,55 +1283,28 @@ export function render({navigate, chrome}) {
content.innerHTML = ''; content.innerHTML = '';
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card stack'; card.className = 'nav-list';
card.innerHTML = ` const items = [
<h2 style="margin:0 0 6px;">Кошелёк</h2> ['Solana кошелёк', 'Баланс SOL', () => renderSolanaWallet()],
<p class="meta-muted">Выберите режим кошелька.</p> ['Arweave кошелёк', 'Баланс AR', () => renderArweaveWallet()],
`; ['Предоплаченное место Сияния', 'Лимит, израсходовано и остаток', () => renderShineBlockchainWallet()],
['Закрепление в Solana', 'Что закреплено в Solana и на сервере', () => renderSolanaPublishWallet()],
const solanaBtn = document.createElement('button'); ['Поддержать проект Сияние', 'Билеты и справка', () => renderSupportHub()],
solanaBtn.className = 'primary-btn'; ];
solanaBtn.style.width = '100%'; const buttons = items.map(([label, hint, open]) => {
solanaBtn.textContent = 'Solana кошелёк'; const btn = document.createElement('button');
solanaBtn.addEventListener('click', () => { btn.type = 'button';
void renderSolanaWallet(); btn.className = 'nav-row';
btn.innerHTML = `<span class="nav-row__label"></span><span class="nav-row__hint"></span>`;
btn.querySelector('.nav-row__label').textContent = label;
btn.querySelector('.nav-row__hint').textContent = hint;
btn.addEventListener('click', () => { void open(); });
return btn;
}); });
const [solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn] = buttons;
const arweaveBtn = document.createElement('button');
arweaveBtn.className = 'primary-btn';
arweaveBtn.style.width = '100%';
arweaveBtn.textContent = 'Arweave кошелёк';
arweaveBtn.addEventListener('click', () => {
void renderArweaveWallet();
});
const shineBchBtn = document.createElement('button');
shineBchBtn.className = 'primary-btn';
shineBchBtn.style.width = '100%';
shineBchBtn.textContent = 'Предоплаченное место Сияния';
shineBchBtn.addEventListener('click', () => {
void renderShineBlockchainWallet();
});
const solanaPublishBtn = document.createElement('button');
solanaPublishBtn.className = 'primary-btn';
solanaPublishBtn.style.width = '100%';
solanaPublishBtn.textContent = 'Закрепление в Solana';
solanaPublishBtn.addEventListener('click', () => {
void renderSolanaPublishWallet();
});
const supportBtn = document.createElement('button');
supportBtn.className = 'primary-btn';
supportBtn.style.width = '100%';
supportBtn.textContent = 'Поддержать проект Сияние';
supportBtn.addEventListener('click', () => {
void renderSupportHub();
});
card.append(solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn); card.append(solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn);
content.append(card); content.append(card);
setStatus('Выберите тип кошелька.'); setStatus('');
} }
async function renderShineBlockchainWallet() { async function renderShineBlockchainWallet() {
@@ -2051,7 +2024,7 @@ export function render({navigate, chrome}) {
helpCard.innerHTML = ` helpCard.innerHTML = `
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary> <summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
<p class="meta-muted" style="margin-top:8px;"> <p class="meta-muted" style="margin-top:8px;">
SHiNE берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк. Сияние берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства. Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
</p> </p>
`; `;
+10 -1
View File
@@ -476,7 +476,17 @@ export function navigateBack() {
window.history.back(); window.history.back();
} }
// Чужой профиль открывают из чатов, связей и каналов — подсвечиваем вкладку, откуда пришли.
let lastToolbarTab = 'messages-list';
export function resolveToolbarActive(pageId) { export function resolveToolbarActive(pageId) {
if (pageId === 'user') return lastToolbarTab;
const tab = resolveToolbarTab(pageId);
lastToolbarTab = tab;
return tab;
}
function resolveToolbarTab(pageId) {
if ( if (
pageId === 'messages-list' pageId === 'messages-list'
|| pageId === 'channels-list' || pageId === 'channels-list'
@@ -510,7 +520,6 @@ export function resolveToolbarActive(pageId) {
) return 'profile-view'; ) return 'profile-view';
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list'; if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-donate-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list'; if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-donate-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
if (pageId === 'user') return 'messages-list';
return 'profile-view'; return 'profile-view';
} }
+11 -3
View File
@@ -177,14 +177,20 @@ function stopVideos(root) {
}); });
} }
function createDownloadLink(url, label = 'Скачать') { function createDownloadLink(url, label = 'Скачать', { iconOnly = false } = {}) {
const link = document.createElement('a'); const link = document.createElement('a');
link.className = 'message-attachment-download'; link.className = `message-attachment-download${iconOnly ? ' message-attachment-download--icon' : ''}`;
link.href = url; link.href = url;
link.target = '_blank'; link.target = '_blank';
link.rel = 'noopener'; link.rel = 'noopener';
link.download = ''; link.download = '';
if (iconOnly) {
link.setAttribute('aria-label', label);
link.title = label;
link.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v12M6 10l6 6 6-6M5 20h14"/></svg>';
} else {
link.textContent = label; link.textContent = label;
}
link.addEventListener('click', (event) => { link.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
}); });
@@ -287,6 +293,7 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
const bindImageLayout = (img) => { const bindImageLayout = (img) => {
img.addEventListener('load', () => { img.addEventListener('load', () => {
frame.classList.add('is-loaded');
const naturalWidth = Number(img.naturalWidth || 0); const naturalWidth = Number(img.naturalWidth || 0);
const naturalHeight = Number(img.naturalHeight || 0); const naturalHeight = Number(img.naturalHeight || 0);
const isLandscape = naturalWidth > 0 && naturalHeight > 0 && naturalWidth > naturalHeight; const isLandscape = naturalWidth > 0 && naturalHeight > 0 && naturalWidth > naturalHeight;
@@ -326,13 +333,14 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
video.muted = true; video.muted = true;
video.playsInline = true; video.playsInline = true;
video.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true }); video.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
video.addEventListener('loadeddata', () => frame.classList.add('is-loaded'), { once: true });
const play = document.createElement('span'); const play = document.createElement('span');
play.className = 'message-attachment-play'; play.className = 'message-attachment-play';
play.textContent = '▶'; play.textContent = '▶';
frame.append(video, play); frame.append(video, play);
} }
frame.append(createDownloadLink(url)); frame.append(createDownloadLink(url, 'Скачать', { iconOnly: true }));
return frame; return frame;
} }
+1 -1
View File
@@ -1845,7 +1845,7 @@ export class AuthService {
const ansTags = [{ name: 'App', value: 'test5590' }]; const ansTags = [{ name: 'App', value: 'test5590' }];
const cleanChannelSlug = String(channelSlug || '').trim(); const cleanChannelSlug = String(channelSlug || '').trim();
if (cleanChannelSlug) ansTags.push({ name: 'c', value: cleanChannelSlug }); if (cleanChannelSlug) ansTags.push({ name: 'c_test5590', value: cleanChannelSlug });
const { response, blockchainName } = await this.runAddBlockWithRetry({ const { response, blockchainName } = await this.runAddBlockWithRetry({
login: cleanLogin, login: cleanLogin,
@@ -0,0 +1,10 @@
// Состояние чтения живёт только в текущей вкладке и разделено по аккаунтам.
const positions = new Map();
export function rememberChannelPosition(key) {
positions.set(key, document.getElementById('app-screen')?.scrollTop || 0);
}
export function readChannelPosition(key) { return positions.get(key); }
export function restoreChannelPosition(value) {
const root = document.getElementById('app-screen');
if (root && Number.isFinite(value)) root.scrollTop = value;
}
+52
View File
@@ -28,6 +28,56 @@ function pickUnit(seconds) {
return ['year', Math.round(years)]; return ['year', Math.round(years)];
} }
// Время поста внутри дня: «14:32». День показывается отдельной плашкой.
export function formatClockTime(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '';
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(ts));
}
// Плашка дня в ленте: «Сегодня», «Вчера», «24 сентября», «24 сентября 2025».
export function formatDayLabel(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '';
const dt = new Date(ts);
const today = new Date();
const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const diffDays = Math.round((startOf(today) - startOf(dt)) / 86400000);
if (diffDays === 0) return 'Сегодня';
if (diffDays === 1) return 'Вчера';
const sameYear = dt.getFullYear() === today.getFullYear();
return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) }).format(dt);
}
export function dayKey(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '';
const dt = new Date(ts);
return `${dt.getFullYear()}-${dt.getMonth()}-${dt.getDate()}`;
}
// Время в строках списков (чаты, каналы) — коротко, как в мессенджерах:
// сегодня «21:41», вчера «вчера», на этой неделе «пт», в этом году «24.09», раньше «24.09.25».
export function formatListTime(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '';
const dt = new Date(ts);
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
if (ts >= startOfToday) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(dt);
}
const dayMs = 86400000;
if (ts >= startOfToday - dayMs) return 'вчера';
if (ts >= startOfToday - 6 * dayMs) {
return new Intl.DateTimeFormat('ru-RU', { weekday: 'short' }).format(dt);
}
if (dt.getFullYear() === now.getFullYear()) {
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(dt);
}
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' }).format(dt);
}
export function formatRelativeTime(timestampMs) { export function formatRelativeTime(timestampMs) {
const ts = toNumber(timestampMs); const ts = toNumber(timestampMs);
if (!ts) return '—'; if (!ts) return '—';
@@ -39,6 +89,8 @@ export function formatRelativeTime(timestampMs) {
const ageSeconds = Math.max(0, (now - ts) / 1000); const ageSeconds = Math.max(0, (now - ts) / 1000);
const ageDays = ageSeconds / 86400; const ageDays = ageSeconds / 86400;
if (ageSeconds < 60) return 'только что';
if (ageDays < 7) { if (ageDays < 7) {
const [unit, value] = pickUnit(diffSeconds); const [unit, value] = pickUnit(diffSeconds);
if (rtf) return rtf.format(value, unit); if (rtf) return rtf.format(value, unit);
+3 -3
View File
@@ -43,7 +43,7 @@ function wsUrlToHttpBase(wsUrl = '') {
if (parsed.protocol === 'wss:') parsed.protocol = 'https:'; if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
else if (parsed.protocol === 'ws:') parsed.protocol = 'http:'; else if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Не удалось определить HTTP-адрес сервера SHiNE'); throw new Error('Не удалось определить HTTP-адрес сервера Сияния');
} }
parsed.pathname = '/'; parsed.pathname = '/';
parsed.search = ''; parsed.search = '';
@@ -609,7 +609,7 @@ function suggestedPickerTypes(attachment) {
const dot = name.lastIndexOf('.'); const dot = name.lastIndexOf('.');
const extension = dot >= 0 ? name.slice(dot) : ''; const extension = dot >= 0 ? name.slice(dot) : '';
return [{ return [{
description: 'Файл SHiNE', description: 'Файл Сияния',
accept: { [mime]: extension ? [extension] : [] }, accept: { [mime]: extension ? [extension] : [] },
}]; }];
} }
@@ -692,7 +692,7 @@ export async function downloadAndDecryptDmFile(attachment = {}, { onProgress = n
} }
export async function buildDmFileTorrentV2(attachment = {}) { export async function buildDmFileTorrentV2(attachment = {}) {
if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов SHiNE v2'); if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов Сияния v2');
const context = await loadV2RootManifest(attachment); const context = await loadV2RootManifest(attachment);
const pieceLayer = []; const pieceLayer = [];
try { try {
@@ -55,7 +55,7 @@ export function makeKeyTransferText({ login, keys }) {
export function parseKeyTransferText(text) { export function parseKeyTransferText(text) {
const raw = String(text || '').trim(); const raw = String(text || '').trim();
if (!raw.startsWith(TRANSFER_PREFIX)) { if (!raw.startsWith(TRANSFER_PREFIX)) {
throw new Error('Это не QR-код переноса ключей SHiNE'); throw new Error('Это не QR-код переноса ключей Сияния');
} }
const json = decoder.decode(base64UrlToBytes(raw.slice(TRANSFER_PREFIX.length))); const json = decoder.decode(base64UrlToBytes(raw.slice(TRANSFER_PREFIX.length)));
const payload = JSON.parse(json); const payload = JSON.parse(json);
@@ -46,7 +46,7 @@ export async function resolveShineServerByServerLogin({ serverLogin, solanaEndpo
solanaEndpoint, solanaEndpoint,
}); });
if (!parsed?.isServer) { if (!parsed?.isServer) {
throw new Error(`Логин @${cleanServerLogin} не опубликован как сервер SHiNE.`); throw new Error(`Логин @${cleanServerLogin} не опубликован как сервер Сияния.`);
} }
const serverAddress = normalizeHostLike(parsed?.serverAddress || ''); const serverAddress = normalizeHostLike(parsed?.serverAddress || '');
if (!serverAddress) { if (!serverAddress) {
@@ -695,7 +695,7 @@ export async function readShineUserPda({ login, solanaEndpoint }) {
const enc = new TextEncoder(); const enc = new TextEncoder();
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)], usersProgram); const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)], usersProgram);
const accountInfo = await connection.getAccountInfo(userPda, 'confirmed'); const accountInfo = await connection.getAccountInfo(userPda, 'confirmed');
if (!accountInfo?.data) throw new Error(`PDA не найдена для логина «${cleanLogin}»`); if (!accountInfo?.data) throw new Error(`Запись аккаунта «${cleanLogin}» в Solana не найдена`);
return { return {
...parseShineUserPda(accountInfo.data), ...parseShineUserPda(accountInfo.data),
userPda: userPda.toBase58(), userPda: userPda.toBase58(),
+232
View File
@@ -0,0 +1,232 @@
const STORAGE_KEY = 'shine-ui-theme-mode-v1';
// Выбранная палитра и личные правки цветов (для дня и ночи отдельно).
const PALETTE_KEY = 'shine-ui-palette-v1';
// Итоговые значения для каждой темы: их читает скрипт в index.html до первой отрисовки,
// чтобы при запуске не мелькала палитра по умолчанию.
const APPLIED_KEY = 'shine-ui-palette-applied-v1';
const MODES = new Set(['system', 'light', 'dark']);
let sessionMode = null;
// Роли цветов — тот же контракт, что в docs/UI-Design/DESIGN.md (раздел 4.1) и styles/main.css.
export const PALETTE_ROLES = [
{ id: 'background', label: 'Фон' },
{ id: 'surface', label: 'Поверхности: поля, меню, карточки' },
{ id: 'surface-selected', label: 'Выбранное и лёгкое выделение' },
{ id: 'text-primary', label: 'Основной текст' },
{ id: 'text-secondary', label: 'Вторичный текст: время, подсказки' },
{ id: 'border-subtle', label: 'Разделители' },
{ id: 'border-control', label: 'Рамки полей и кнопок' },
{ id: 'accent', label: 'Акцент: главные кнопки, ссылки' },
{ id: 'on-accent', label: 'Текст на акцентной кнопке' },
{ id: 'reaction-active', label: 'Поставленный лайк' },
{ id: 'danger', label: 'Ошибки и удаление' },
{ id: 'success', label: 'Успех' },
{ id: 'warning', label: 'Предупреждение' },
];
export const PALETTE_PRESETS = {
club: {
label: 'Индиго',
dark: {
background: '#12141f', surface: '#1c2031', 'surface-selected': '#272c45',
'text-primary': '#eceefa', 'text-secondary': '#a0a6c2',
'border-subtle': '#343a55', 'border-control': '#6d7396',
accent: '#a9b4ff', 'on-accent': '#151a3d', 'reaction-active': '#ff9bb3',
danger: '#ffa7a3', success: '#8fdab4', warning: '#e8c585',
},
light: {
background: '#eef0f5', surface: '#ffffff', 'surface-selected': '#dde3f5',
'text-primary': '#1e2233', 'text-secondary': '#5c6279',
'border-subtle': '#d5d9e5', 'border-control': '#8a90a8',
accent: '#4c5caa', 'on-accent': '#ffffff', 'reaction-active': '#c02a55',
danger: '#b3261e', success: '#2c6b4a', warning: '#7d5a10',
},
},
shine: {
label: 'Бирюза',
dark: {
background: '#101b20', surface: '#17272d', 'surface-selected': '#213b3a',
'text-primary': '#e8f2f1', 'text-secondary': '#98adaf',
'border-subtle': '#2a3b40', 'border-control': '#71888a',
accent: '#94e1ce', 'on-accent': '#102c27', 'reaction-active': '#f29aab',
danger: '#ffaba8', success: '#94e1ce', warning: '#e6c184',
},
light: {
background: '#faf7f0', surface: '#fffdf8', 'surface-selected': '#efe6d6',
'text-primary': '#302c25', 'text-secondary': '#706658',
'border-subtle': '#e6dfd1', 'border-control': '#978b79',
accent: '#93511e', 'on-accent': '#fffaf3', 'reaction-active': '#a43350',
danger: '#b13135', success: '#346a48', warning: '#845b14',
},
},
mono: {
label: 'Монохром',
dark: {
background: '#111212', surface: '#1c1d1d', 'surface-selected': '#2c2d2d',
'text-primary': '#f3f3f1', 'text-secondary': '#a3a3a0',
'border-subtle': '#2a2b2b', 'border-control': '#6f706e',
accent: '#f3f3f1', 'on-accent': '#111212', 'reaction-active': '#ff8a9a',
danger: '#ff9d97', success: '#9fd8a8', warning: '#e8c37e',
},
light: {
background: '#ffffff', surface: '#f6f6f5', 'surface-selected': '#ebebea',
'text-primary': '#141414', 'text-secondary': '#626262',
'border-subtle': '#e4e4e2', 'border-control': '#8c8c8a',
accent: '#141414', 'on-accent': '#ffffff', 'reaction-active': '#d0214a',
danger: '#b3261e', success: '#2f6b3b', warning: '#7d5a10',
},
},
};
const DEFAULT_PRESET = 'club';
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
function normalizeMode(value) {
const mode = String(value || '').trim().toLowerCase();
return MODES.has(mode) ? mode : 'system';
}
function readJson(key) {
try {
return JSON.parse(localStorage.getItem(key) || 'null');
} catch {
return null;
}
}
function writeJson(key, value) {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, JSON.stringify(value));
} catch {
// В приватном режиме палитра всё равно действует до закрытия страницы.
}
}
function cleanOverrides(raw) {
const out = {};
for (const role of PALETTE_ROLES) {
const value = String(raw?.[role.id] || '').trim();
if (HEX_COLOR.test(value)) out[role.id] = value.toLowerCase();
}
return out;
}
let sessionPalette = null;
export function getPaletteSettings() {
if (sessionPalette) return sessionPalette;
const raw = readJson(PALETTE_KEY) || {};
const preset = PALETTE_PRESETS[raw.preset] ? raw.preset : DEFAULT_PRESET;
return {
preset,
custom: { dark: cleanOverrides(raw.custom?.dark), light: cleanOverrides(raw.custom?.light) },
};
}
export function resolvePalette(resolvedTheme, settings = getPaletteSettings()) {
const theme = resolvedTheme === 'light' ? 'light' : 'dark';
return { ...PALETTE_PRESETS[settings.preset][theme], ...settings.custom[theme] };
}
function isDefaultPalette(settings) {
return settings.preset === DEFAULT_PRESET
&& !Object.keys(settings.custom.dark).length
&& !Object.keys(settings.custom.light).length;
}
function applyPaletteVars(resolvedTheme) {
const root = document.documentElement;
const settings = getPaletteSettings();
for (const role of PALETTE_ROLES) root.style.removeProperty(`--${role.id}`);
root.style.removeProperty('--focus-ring');
// Палитра по умолчанию живёт в styles/main.css — inline-переменные не нужны.
if (isDefaultPalette(settings)) return;
const colors = resolvePalette(resolvedTheme, settings);
for (const [role, value] of Object.entries(colors)) root.style.setProperty(`--${role}`, value);
root.style.setProperty('--focus-ring', colors.accent);
}
export function setPaletteSettings(next) {
const settings = {
preset: PALETTE_PRESETS[next?.preset] ? next.preset : DEFAULT_PRESET,
custom: { dark: cleanOverrides(next?.custom?.dark), light: cleanOverrides(next?.custom?.light) },
};
sessionPalette = settings;
if (isDefaultPalette(settings)) {
writeJson(PALETTE_KEY, null);
writeJson(APPLIED_KEY, null);
} else {
writeJson(PALETTE_KEY, settings);
writeJson(APPLIED_KEY, { dark: resolvePalette('dark', settings), light: resolvePalette('light', settings) });
}
return applyThemeMode();
}
export function exportPalette() {
const settings = getPaletteSettings();
return JSON.stringify({
preset: settings.preset,
dark: resolvePalette('dark', settings),
light: resolvePalette('light', settings),
}, null, 2);
}
export function importPalette(text) {
const data = JSON.parse(String(text || ''));
const preset = PALETTE_PRESETS[data?.preset] ? data.preset : DEFAULT_PRESET;
const base = PALETTE_PRESETS[preset];
const diff = (theme) => {
const colors = cleanOverrides(data?.[theme]);
return Object.fromEntries(Object.entries(colors).filter(([role, value]) => base[theme][role] !== value));
};
return setPaletteSettings({ preset, custom: { dark: diff('dark'), light: diff('light') } });
}
export function getThemeMode() {
if (sessionMode !== null) return sessionMode;
try {
return normalizeMode(localStorage.getItem(STORAGE_KEY));
} catch {
return 'system';
}
}
export function resolveThemeMode(mode = getThemeMode()) {
const normalized = normalizeMode(mode);
if (normalized !== 'system') return normalized;
return window.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark';
}
export function applyThemeMode(mode = getThemeMode()) {
const normalized = normalizeMode(mode);
const resolved = resolveThemeMode(normalized);
document.documentElement.dataset.themeMode = normalized;
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
applyPaletteVars(resolved);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', resolvePalette(resolved).background);
return { mode: normalized, resolved };
}
export function setThemeMode(mode) {
const normalized = normalizeMode(mode);
sessionMode = normalized;
try {
localStorage.setItem(STORAGE_KEY, normalized);
} catch {
// В приватном режиме тема всё равно применяется до закрытия страницы.
}
return applyThemeMode(normalized);
}
export function watchSystemTheme() {
const media = window.matchMedia?.('(prefers-color-scheme: light)');
if (!media) return () => {};
const onChange = () => {
if (getThemeMode() === 'system') applyThemeMode('system');
};
media.addEventListener?.('change', onChange);
return () => media.removeEventListener?.('change', onChange);
}
+1 -1
View File
@@ -10,7 +10,7 @@ export const profileFieldDefs = [
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' }, { key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
{ key: 'last_name', readKeys: ['last_name'], label: 'Фамилия', placeholder: 'Введите фамилию' }, { key: 'last_name', readKeys: ['last_name'], label: 'Фамилия', placeholder: 'Введите фамилию' },
{ key: 'address', readKeys: ['address'], label: 'Адрес', placeholder: 'Город, улица, дом' }, { key: 'address', readKeys: ['address'], label: 'Адрес', placeholder: 'Город, улица, дом' },
{ key: 'web', readKeys: ['web'], label: 'Links', placeholder: 'Сайт, профиль или другая ссылка' }, { key: 'web', readKeys: ['web'], label: 'Ссылки', placeholder: 'Сайт, профиль или другая ссылка' },
{ key: 'phone', readKeys: ['phone'], label: 'Телефон', placeholder: '+7 ...' }, { key: 'phone', readKeys: ['phone'], label: 'Телефон', placeholder: '+7 ...' },
{ key: 'about', readKeys: ['about'], label: 'О себе', placeholder: 'Коротко расскажите о себе', maxLength: 160, multiline: true }, { key: 'about', readKeys: ['about'], label: 'О себе', placeholder: 'Коротко расскажите о себе', maxLength: 160, multiline: true },
{ key: 'spiritual_path', readKeys: ['spiritual_path'], label: 'Духовный путь', placeholder: 'Расскажите о своём духовном пути, опыте, практиках и взглядах', maxLength: 5000, multiline: true }, { key: 'spiritual_path', readKeys: ['spiritual_path'], label: 'Духовный путь', placeholder: 'Расскажите о своём духовном пути, опыте, практиках и взглядах', maxLength: 5000, multiline: true },
+54
View File
@@ -0,0 +1,54 @@
# Static sites
Рабочая папка для временных статических сайтов, дизайн-примеров и standalone-viewer'ов.
Все содержимое этой папки деплоится вместе с `shine-UI`, потому что общий UI deploy синхронизирует весь каталог `shine-UI/` в Caddy web root. На текущей схеме отдельная перенастройка Caddy для каждого нового сайта не нужна: Caddy сначала отдает существующие файлы из UI root, а уже потом делает fallback на основной `index.html`.
## URL
Если UI задеплоен на `https://t2.shineup.me`, то эта папка доступна как:
```text
https://t2.shineup.me/static-sites/
```
Текущие подпапки:
```text
/static-sites/design-examples/channels-v1/
/static-sites/arweave-viewer/
```
Для production домен меняется на нужный хост, например:
```text
https://shineup.me/static-sites/
https://server2.shineup.me/static-sites/
```
## Как добавлять новый временный сайт
1. Создать подпапку внутри `shine-UI/static-sites/`.
2. Положить туда `index.html` и связанные файлы.
3. Выполнить обычный UI deploy нужного контура через `deploy/scripts/*_ui.sh`.
После этого сайт будет доступен по URL:
```text
https://<host>/static-sites/<folder>/
```
## Arweave viewer
Постоянная папка для viewer:
```text
shine-UI/static-sites/arweave-viewer/
```
Когда появляется новая версия viewer, можно заменить содержимое этой папки новой версией и выполнить обычный UI deploy. URL останется прежним:
```text
https://<host>/static-sites/arweave-viewer/
```

Some files were not shown because too many files have changed in this diff Show More