11 Commits

Author SHA1 Message Date
vsecoder
a75cbcd224 feat: resource caps, Saved Messages, author walls, docs for node bring-up
Node flags (cmd/node/main.go):
  --max-cpu / --max-ram-mb — Go runtime caps (GOMAXPROCS / GOMEMLIMIT)
  --feed-disk-limit-mb — hard 507 refusal for new post bodies over quota
  --chain-disk-limit-mb — advisory watcher (can't reject blocks without
  breaking consensus; logs WARN every minute)

Client — Saved Messages (self-chat):
  - Auto-created on sign-in, pinned top of chat list, blue bookmark avatar
  - Send short-circuits the relay (no encrypt, no fee, no mailbox hop)
  - Empty state rendered outside inverted FlatList — fixes the mirrored
    "say hi…" on Android RTL-aware layout builds
  - PostCard shows "You" for own posts instead of the self-contact alias

Client — user walls:
  - New route /(app)/feed/author/[pub] with infinite-scroll via
    `created_at` cursor and pull-to-refresh
  - Profile screen gains "View posts" button (universal) next to
    "Open chat" (contact-only)

Feed pipeline:
  - Bump client JPEG quality 0.5 → 0.75 to match server scrubber (Q=75),
    so a 60 KiB compose doesn't balloon past 256 KiB after server re-encode
  - ErrPostTooLarge now wraps with the actual size vs cap, errors.Is
    preserved in the HTTP layer
  - FeedMailbox quota + DiskUsage surface — supports new CLI flag

README:
  - Step-by-step "first node / joiner" section on the landing page,
    full flag tables incl. the new resource-cap group, minimal
    checklists for open/private/low-end deployments
2026-04-19 13:14:47 +03:00
vsecoder
e6f3d2bcf8 feat(client): transaction detail screen (wallet history → tap to inspect)
Users can now tap any row in the wallet history and see the full
transaction detail, matching what the block explorer shows for the
same tx. Covers every visible activity — transfers, contact
requests, likes, posts, follows, relay proofs, contract calls.

Components

  lib/api.ts
    - New TxDetail interface mirroring node/api_explorer.go's
      txDetail JSON (id, type, from/to + their DC addresses, µT
      amount + display string, fee, block coords, gas, payload,
      signature hex).
    - getTxDetail(txID) with 404→null handling.

  app/(app)/tx/[id].tsx — new screen
    - Hero row: icon + type label + local-time timestamp
    - Big amount pill (only for txs that move tokens) — signed by
      the viewer's perspective (+ when you received, − when you
      paid, neutral when it's someone else's tx or a non-transfer)
    - Info card rows with tap-to-copy on hashes and addresses:
      Tx ID, From (highlighted "you" when it's the signed-in user),
      To (same), Block, Fee, Gas used (when > 0), Memo (when set)
    - Collapsible Payload section — renders JSON with 2-space
      indent if the node could decode it, otherwise the raw hex
    - Signature copy row at the bottom (useful for debugging / audits)
    - txMeta() covers all EventTypes from blockchain/types.go
      (TRANSFER, CONTACT_REQUEST/ACCEPT/BLOCK, REGISTER_KEY/RELAY,
      BIND_WALLET, RELAY_PROOF, BLOCK_REWARD, HEARTBEAT, CREATE_POST,
      DELETE_POST, LIKE_POST/UNLIKE_POST, FOLLOW/UNFOLLOW,
      CALL_CONTRACT, DEPLOY_CONTRACT, STAKE/UNSTAKE) with
      distinct icons + in/out/neutral tone.
    - Nested Stack layout so router.back() pops to the caller;
      safeBack() fallback when entered via deep link.

  app/(app)/wallet.tsx
    - TxTile's outer Pressable was a no-op onPress handler; now
      router.push(`/(app)/tx/${tx.hash}`). Entire row is the
      touch target (icon + type + addr + time + amount).

  app/(app)/_layout.tsx
    - /tx/* added to hideNav regex so the detail screen is
      full-screen without the 5-icon bar at the bottom.

Translation quirk

  The screen is English to match the rest of the UI (what the user
  just asked for in the previous commit). Handles copying via
  expo-clipboard — tapping an address/hash shows "Copied" for 1.5s
  with a green check, then reverts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:49:33 +03:00
vsecoder
e62b72b5be fix(client): safeBack helper + prevent self-contact-request
1. GO_BACK warning & stuck screens

   When a deep link or direct push put the user on /feed/[id],
   /profile/[address], /compose, or /settings without any prior stack
   entry, tapping the header chevron emitted:
     "ERROR The action 'GO_BACK' was not handled by any navigator"
   and did nothing — user was stuck.

   New helper lib/utils.safeBack(fallback = '/(app)/chats') wraps
   router.canGoBack() — when there's history it pops; otherwise it
   replace-navigates to a sensible fallback (chats list by default,
   '/' for auth screens so we land back at the onboarding).

   Applied to every header chevron and back-from-detail flow:
   app/(app)/chats/[id], app/(app)/compose, app/(app)/feed/[id]
   (header + onDeleted), app/(app)/feed/tag/[tag],
   app/(app)/profile/[address], app/(app)/new-contact (header + OK
   button on request-sent alert), app/(app)/settings,
   app/(auth)/create, app/(auth)/import.

2. Prevent self-contact-request

   new-contact.tsx now compares the resolved address against
   keyFile.pub_key at two points:
     - right after resolveUsername + getIdentity in search() — before
       the profile card even renders, so the user doesn't see the
       "Send request" CTA for themselves.
     - inside sendRequest() as a belt-and-braces guard in case the
       check was somehow bypassed.
   The search path shows an inline error ("That's you. You can't
   send a contact request to yourself."); sendRequest falls back to
   an Alert with the same meaning. Both compare case-insensitively
   against the pubkey hex so mixed-case pastes work.

   Technically the server would still accept a self-request (the
   chain stores it under contact_in:<self>:<self>), but it's a dead-
   end UX-wise — the user can't chat with themselves — so the client
   blocks it preemptively instead of letting users pay the fee for
   nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:45:19 +03:00
vsecoder
f7a849ddcb chore(client): translate all user-visible strings to English
Mixed-language UI was confusing — onboarding said "Why DChain / How it
works / Your keys" in English headings but feature descriptions and
CTAs were in Russian; compose's confirm dialog was Russian; feed tabs
were Russian; error messages in humanizeTxError were Russian.
Everything user-facing is now English.

Files touched (only string literals, not comments):
  app/index.tsx              onboarding slides + CTA buttons
  app/(app)/compose.tsx      composer alerts, header button, placeholder,
                             attachment-size hint
  app/(app)/feed/index.tsx   tab labels (Following/For you/Trending),
                             empty-state hints, retry button
  app/(app)/feed/[id].tsx    post detail header + stats rows (Views,
                             Likes, Size, Paid to publish, Hosted on,
                             Hashtags)
  app/(app)/feed/tag/[tag].tsx  empty-state copy
  app/(app)/profile/[address].tsx  Profile header, Follow/Following,
                             Edit, Open chat, Address, Copied, Encryption,
                             Added, Members, unknown-contact hint
  app/(app)/new-contact.tsx  Search title, placeholder, Search button,
                             empty-state hint, E2E-ready indicator,
                             Intro label + placeholder, fee-tier labels
                             (Min / Standard / Priority), Send request,
                             Insufficient-balance alert, Request-sent
                             alert
  app/(app)/requests.tsx     Notifications title, empty-state, Accept /
                             Decline buttons, decline-confirm alert,
                             "wants to add you" line
  components/SearchBar.tsx   default placeholder
  components/feed/PostCard.tsx  long-press menu (Delete post, confirm,
                             Actions / Cancel)
  components/feed/ShareSheet.tsx  sheet title, contact-search placeholder,
                             empty state, Select contacts / Send button,
                             plural helper rewritten for English
  components/chat/PostRefCard.tsx  "POST" ribbon, "photo" indicator
  lib/api.ts                 humanizeTxError (rate-limit, clock skew,
                             bad signature, 400/5xx/network-error
                             messages)
  lib/dates.ts               dateBucket now returns Today/Yesterday/
                             "Jun 17, 2025"; month array switched to
                             English short forms

Code comments left in Russian intentionally — they're developer
context, not user-facing. This commit is purely display-string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:39:38 +03:00
vsecoder
060ac6c2c9 fix(contact): fee-tier pills lose background via Pressable style-fn
Same Pressable dynamic-style bug that keeps reappearing: on some RN
builds the style function is re-evaluated during render in a way that
drops properties (previously hit on the FAB, then on PostCard, now on
the anti-spam fee selector). User saw three bare text labels on black
stuck together instead of distinct white/grey pills.

Fix: move visual properties (backgroundColor, borderWidth, padding,
border) to a static inner <View>. Pressable keeps only the opacity-
based press feedback which is stable because no other properties need
to flip on press. Functionally identical UX, layout guaranteed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:22:11 +03:00
vsecoder
516940fa8e fix(client): contact-request endpoint path + search screen polish
1. Contact requests silently 404'd
   fetchContactRequests hit /api/relay/contacts, but the server mounts
   the whole /relay/* group at root (no /api prefix). Result: every
   poll returned 404, the catch swallowed it, and the notifications
   tab stayed empty even after the user sent themselves a CONTACT_
   REQUEST on-chain. Fixed the client path to /relay/contacts — same
   pattern as sendEnvelope / fetchInbox in the v1.0.x relay cleanup.

2. Search screen was half-finished
   SearchBar used a dual-state hack (idle-centered Text overlaid with
   an invisible TextInput) that broke focus + alignment on Android and
   sometimes ate taps. Rewrote as a plain single-row pill: icon +
   TextInput + optional clear button. Fewer moving parts, predictable
   focus, proper placeholder styling.

   new-contact.tsx cleaned up:
   - Title "New chat" → "Поиск" (matches the NavBar tab label and the
     rest of the Russian UI).
   - All labels localised: "Accept/Decline", "Intro", "Anti-spam fee",
     fee-tier names, error messages, final CTA.
   - Proper empty-state hint when query is empty (icon + headline +
     explanation of @username / hex / DC prefix) instead of just a
     floating helper text.
   - Search button hidden until user types something — the empty-
     state stands alone, no dead grey button under it.
   - onClear handler on SearchBar resets the resolved profile too.

   requests.tsx localised: title, empty-state, Accept/Decline button
   copy, confirmation Alert text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:19:03 +03:00
vsecoder
3e9ddc1a43 chore(client): remove dev-seed for chats + feed
Two dev-only seed modules removed now that the app talks to a real
backend:

- lib/devSeed.ts — fake 15+ contacts with mock chat histories,
  mounted via useDevSeed() in (app)/_layout.tsx on empty store.
  Was useful during client-first development; now it fights real
  contact sync and confuses operators bringing up fresh nodes
  ("why do I see NBA scores and a dchain_updates channel in my
  chat list?").

- lib/devSeedFeed.ts — 12 synthetic feed posts surfaced when the
  real API returned empty. Same reasoning: operator imports genesis
  key on a fresh node, opens Feed, sees 12 mock posts that aren't on
  their chain. "Test data" that looks real is worse than an honest
  empty state.

Feed screen now shows its proper empty state ("Пока нет
рекомендаций", etc.) when the API returns zero items OR on network
error. Chat screen starts empty until real contacts + messages
arrive via WS / storage cache.

Also cleaned a stale comment in chats/[id].tsx that referenced
devSeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:07:42 +03:00
vsecoder
6ed4e7ca50 fix(node): fail loudly when key file exists but is unreadable
Operator hit this in the wild: keys/node.json mounted into a container
as 600 root:root while the node process runs as an unprivileged user.
os.ReadFile returned a permission error, loadOrCreateIdentity fell
through to "generate a new identity", and genesis allocation (21M
tokens) was credited to the auto-generated key — which then vanished
when the container restarted because the read-only mount also
couldn't be written.

The symptom was a 0-balance import: operators extracted node.json
from the host keys dir, imported it into the mobile client, and
wondered why the genesis validator's wallet was empty.

Fix: distinguish "file doesn't exist" (first boot, generate) from
"file exists but can't be read" (operator error, log.Fatalf with a
hint about permissions / read-only mount). Also fail loudly on JSON
parse errors and decode errors instead of silently generating.

When the new-identity path is taken and the save fails (read-only
mount), the warning now explicitly says the key is ephemeral and the
node's identity will change on restart — operators can catch this
before genesis commits to a throwaway key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:04:53 +03:00
vsecoder
f726587ac6 fix(docker): pre-create /data as dchain user so named volumes inherit ownership
Running Dockerfile.slim with a fresh named volume crashed on startup:

  [NODE] open chain: open badger: Error Creating Dir: "/data/chain"
    error: mkdir /data/chain: permission denied

Docker copies the mount-point's directory ownership (from the image)
into a new named volume at first attach. In the previous Dockerfile
/data was created implicitly by the VOLUME directive, which means it
was owned by root — but the container runs as the unprivileged
`dchain` user, so it couldn't `mkdir /data/chain` on first boot.

Fix: explicitly `mkdir /data && chown dchain:dchain /data` in the
same RUN that creates the user, before the VOLUME directive. Fresh
volumes now inherit dchain:dchain ownership automatically; no
operator-side `docker run --user root chown` workaround needed.

Operators already running with a root-owned volume from before this
fix need to chown once manually:

  docker run --rm -v dchain_data:/data --user root alpine \
    sh -c 'chown -R 100:101 /data'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:43:31 +03:00
vsecoder
1e7f4d8da4 fix(docker): bump build-stage Go to 1.25 (matches go.mod)
When v2.0.0 added the golang.org/x/image/webp dependency (used by the
media scrubber for WebP decoding), go mod tidy bumped the module's
minimum Go version in go.mod:

  module go-blockchain
  go 1.25.0

The three Dockerfiles in the repo were still pinned to older images:

  /Dockerfile              FROM golang:1.24-alpine
  /deploy/prod/Dockerfile.slim  FROM golang:1.24-alpine
  /docker/media-sidecar/Dockerfile  FROM golang:1.22-alpine

Result: `docker build` on any of them fails at `go mod download` with
  go: go.mod requires go >= 1.25.0 (running go 1.24.13; GOTOOLCHAIN=local)
because Alpine's golang image pins GOTOOLCHAIN=local to keep the
toolchain reproducible.

Fix: bump all three to golang:1.25-alpine. The media-sidecar module
doesn't actually need 1.25 (it's self-contained and only uses stdlib),
but keeping all three in sync avoids surprise the next time somebody
adds a dep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:32:22 +03:00
vsecoder
29e95485fa Merge 'feature/feed' into main — v2.0.0
Two minor-version cycles and one major:
  v1.0.1 — relay hardening (canonical envelope ID, server SentAt,
           /relay/send marked non-E2E)
  v1.0.2 — further relay hardening (RELAY_PROOF dedup, DELETE auth,
           rate limits, WS soft-fail close)
  v2.0.0 — social feed replacing channels:
             • on-chain CREATE_POST / DELETE_POST / FOLLOW / LIKE
             • relay feed-mailbox with EXIF-scrub pipeline
             • Twitter-style client (Feed tab + post detail +
               compose + profile Follow + share-to-chat)
             • infinite scroll + lazy render on both feed and chat
             • docs: full API reference + architecture update
2026-04-18 22:06:18 +03:00
39 changed files with 1698 additions and 984 deletions

View File

@@ -1,5 +1,5 @@
# ---- build stage ---- # ---- build stage ----
FROM golang:1.24-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app

155
README.md
View File

@@ -22,6 +22,7 @@
## Содержание ## Содержание
- [Быстрый старт](#быстрый-старт) - [Быстрый старт](#быстрый-старт)
- [Поднятие ноды — пошагово](#поднятие-ноды--пошагово)
- [Продакшен деплой](#продакшен-деплой) - [Продакшен деплой](#продакшен-деплой)
- [Архитектура](#архитектура) - [Архитектура](#архитектура)
- [REST / WebSocket API](#rest--websocket-api) - [REST / WebSocket API](#rest--websocket-api)
@@ -66,6 +67,160 @@ curl -s http://localhost:8080/api/well-known-version | jq .
3-node dev-кластер (для тестов PBFT кворума, slashing, federation): `docker compose up --build -d` — см. [`docs/quickstart.md`](docs/quickstart.md). 3-node dev-кластер (для тестов PBFT кворума, slashing, federation): `docker compose up --build -d` — см. [`docs/quickstart.md`](docs/quickstart.md).
## Поднятие ноды — пошагово
Ниже — полный минимум для двух сценариев, которые покрывают 99% случаев:
**первая нода сети** (genesis) и **присоединение к существующей сети**.
Все флаги читаются также из соответствующего `DCHAIN_*` env-var (CLI > env > default).
### Шаг 1. Ключи
```bash
# Ключ identity ноды (Ed25519 — подпись блоков + tx)
./client keygen --out keys/node.json
# relay-ключ (X25519 — E2E-mailbox) создаётся нодой сам при первом старте,
# но можно задать путь заранее через --relay-key.
```
### Шаг 2a. Первая нода (genesis)
Поднимает новую сеть с одним валидатором. `--genesis=true` **только** для самой первой ноды и **только один раз** — если блок 0 уже есть в `--db`, флаг игнорируется.
```bash
./node \
--genesis=true \
--key=keys/node.json \
--db=./chaindata \
--mailbox-db=./mailboxdata \
--feed-db=./feeddata \
--listen=/ip4/0.0.0.0/tcp/4001 \
--announce=/ip4/<ВАШ-ПУБЛИЧНЫЙ-IP>/tcp/4001 \
--stats-addr=:8080 \
--register-relay=true \
--relay-fee=1000
```
`--announce` **обязателен** для любой ноды смотрящей в интернет (VPS / внешний IP / Docker с проброшенным портом). Без него libp2p пытается UPnP/NAT-PMP и чаще всего промахивается.
### Шаг 2b. Вторая и последующие ноды
Нужен **один** из двух способов узнать первую ноду. Второй удобнее.
**Через HTTP URL живой ноды** (рекомендуется — нода сама заберёт multiaddr через `/api/network-info`, проверит genesis_hash и синхронизирует цепь):
```bash
./node \
--join=https://first-node.example.com \
--key=keys/node.json \
--db=./chaindata \
--mailbox-db=./mailboxdata \
--feed-db=./feeddata \
--listen=/ip4/0.0.0.0/tcp/4001 \
--announce=/ip4/<ВАШ-ПУБЛИЧНЫЙ-IP>/tcp/4001 \
--stats-addr=:8080 \
--register-relay=true \
--relay-fee=1000
```
**Через libp2p multiaddr** (если есть прямой мульти-адрес):
```bash
./node \
--peers=/ip4/1.2.3.4/tcp/4001/p2p/12D3KooW... \
# остальные флаги как выше
```
**Автоприсоединение к validator set** происходит не само: после того как нода синхронизируется, действующий validator должен вызвать `client add-validator --target <your-pub> --cosigs ...` (multi-sig admit). До этого новая нода живёт как **observer** — читает и гоняет tx, но не голосует. Запустить ноду **явно** как observer (никогда не проситься в validator set): `--observer=true`.
### Все флаги `node`
CLI / env / default. Группы:
**Storage**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--db` | `DCHAIN_DB` | `./chaindata` | BadgerDB блокчейна |
| `--mailbox-db` | `DCHAIN_MAILBOX_DB` | `./mailboxdata` | E2E-конверты 1:1 чатов |
| `--feed-db` | `DCHAIN_FEED_DB` | `./feeddata` | Тела постов ленты (off-chain) |
| `--feed-ttl-days` | `DCHAIN_FEED_TTL_DAYS` | `30` | Через сколько дней тела постов auto-evict'ятся (метаданные on-chain остаются вечно) |
**Identity**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--key` | `DCHAIN_KEY` | `./node.json` | Ed25519 ключ ноды |
| `--relay-key` | `DCHAIN_RELAY_KEY` | `./relay.json` | X25519 ключ для relay-mailbox (создастся сам) |
| `--wallet` | `DCHAIN_WALLET` | — | Отдельный payout-кошелёк (опционально) |
| `--wallet-pass` | `DCHAIN_WALLET_PASS` | — | Парольная фраза для wallet-файла |
**Network**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--listen` | `DCHAIN_LISTEN` | `/ip4/0.0.0.0/tcp/4001` | libp2p listen multiaddr |
| `--announce` | `DCHAIN_ANNOUNCE` | — | Multiaddr который нода рассказывает пирам (обязателен на VPS/внешнем IP) |
| `--peers` | `DCHAIN_PEERS` | — | Bootstrap multiaddrs, comma-separated |
| `--join` | `DCHAIN_JOIN` | — | HTTP URL живой ноды для авто-дискавери — получает peers и genesis_hash |
| `--allow-genesis-mismatch` | — | `false` | Отключить защиту, падающую при расхождении локального и seed'ового genesis (только для явной миграции) |
**Consensus & role**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--genesis` | `DCHAIN_GENESIS` | `false` | Создать блок 0 (только для самой первой ноды сети) |
| `--validators` | `DCHAIN_VALIDATORS` | — | Исходный validator set (CSV pub-keys) — применяется только при genesis |
| `--observer` | `DCHAIN_OBSERVER` | `false` | Observer-режим: синхронизируется и отдаёт API, но не голосует и не предлагает блоки |
| `--heartbeat` | `DCHAIN_HEARTBEAT` | `true` | Периодический HEARTBEAT-tx (нужен для liveness-детекции валидаторов) |
**Relay / mailbox**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--register-relay` | `DCHAIN_REGISTER_RELAY` | `false` | Отправить `REGISTER_RELAY` tx на старте (объявить ноду публичным relay'ем) |
| `--relay-fee` | `DCHAIN_RELAY_FEE` | `1000` | Плата за доставку одного сообщения в µT (1000 = 0.001 T). `0` = бесплатный relay |
**Media scrubber (feed)**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--media-sidecar-url` | `DCHAIN_MEDIA_SIDECAR_URL` | — | URL FFmpeg-сайдкара для видео-скраба. Пустой = только картинки |
| `--allow-unscrubbed-video` | `DCHAIN_ALLOW_UNSCRUBBED_VIDEO` | `false` | Принимать видео **без** серверного скраба (опасно — EXIF/GPS/автор-теги останутся) |
**HTTP API**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--stats-addr` | `DCHAIN_STATS_ADDR` | `:8080` | Адрес HTTP/WS сервера |
| `--api-token` | `DCHAIN_API_TOKEN` | — | Bearer-токен для submit tx. Пустой = публичная нода |
| `--api-private` | `DCHAIN_API_PRIVATE` | `false` | Требовать токен также на чтение |
| `--disable-ui` | `DCHAIN_DISABLE_UI` | `false` | Отключить HTML-explorer (JSON API остаётся) |
| `--disable-swagger` | `DCHAIN_DISABLE_SWAGGER` | `false` | Отключить `/swagger*` |
**Resource caps** (новое в v2.1.0)
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--max-cpu` | `DCHAIN_MAX_CPU` | `0` | Сколько CPU-ядер Go-runtime'у (`GOMAXPROCS`). `0` = все |
| `--max-ram-mb` | `DCHAIN_MAX_RAM_MB` | `0` | Soft-лимит Go-хипа в MiB (`GOMEMLIMIT`). `0` = без лимита. *Не OOM-kill'ит — усиливает GC при приближении* |
| `--feed-disk-limit-mb` | `DCHAIN_FEED_DISK_LIMIT_MB` | `0` | Жёсткая квота на feed-БД. При превышении `/feed/publish` отвечает 507. Существующие посты продолжают отдаваться |
| `--chain-disk-limit-mb` | `DCHAIN_CHAIN_DISK_LIMIT_MB` | `0` | Advisory-квота на блокчейн-БД. Превышение → `WARN` в лог раз в минуту (жёстко не отказываем — сломали бы консенсус) |
Для реального sandboxing (hard-kill при OOM, hard CPU throttling) используйте `docker run --cpus --memory` или systemd `CPUQuota` / `MemoryMax` поверх этих флагов.
**Update / versioning**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--update-source-url` | `DCHAIN_UPDATE_SOURCE_URL` | — | Gitea `/api/v1/repos/{owner}/{repo}/releases/latest` для `/api/update-check` |
| `--update-source-token` | `DCHAIN_UPDATE_SOURCE_TOKEN` | — | PAT для приватного репо |
| `--log-format` | `DCHAIN_LOG_FORMAT` | `text` | `text` (human) или `json` (Loki/ELK) |
| `--governance-contract` | `DCHAIN_GOVERNANCE_CONTRACT` | — | ID governance-контракта для динамических параметров |
| `--version` | — | — | Печатает версию и выходит |
### Минимальные чек-листы
**Первая нода (открытая):** `--genesis=true` + `--key` + `--announce` на внешний IP + `--stats-addr` + опционально `--register-relay=true --relay-fee=...` чтобы сразу монетизировать relay-трафик.
**Joiner:** `--join=<url-любой-живой-ноды>` + `--key` + `--announce` + `--stats-addr`. После синка попросите действующего валидатора поднять `add-validator` (иначе остаётесь observer'ом до принятия — это нормально и безопасно).
**Приватная/домашняя нода** без публичного эксплорера: добавьте `--api-token=<random>`, `--api-private=true`, `--disable-ui=true`, `--disable-swagger=true`. Clients передают `Authorization: Bearer <token>`.
**Слабое железо:** `--max-cpu=2 --max-ram-mb=1024 --feed-disk-limit-mb=2048 --chain-disk-limit-mb=10240`.
Docker-обёртка с теми же флагами — в [`deploy/single/README.md`](deploy/single/README.md).
## Продакшен деплой ## Продакшен деплой
Два варианта, по масштабу. Два варианта, по масштабу.

View File

@@ -23,9 +23,9 @@ import { useWellKnownContracts } from '@/hooks/useWellKnownContracts';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
import { useGlobalInbox } from '@/hooks/useGlobalInbox'; import { useGlobalInbox } from '@/hooks/useGlobalInbox';
import { getWSClient } from '@/lib/ws'; import { getWSClient } from '@/lib/ws';
import { useDevSeed } from '@/lib/devSeed';
import { NavBar } from '@/components/NavBar'; import { NavBar } from '@/components/NavBar';
import { AnimatedSlot } from '@/components/AnimatedSlot'; import { AnimatedSlot } from '@/components/AnimatedSlot';
import { saveContact } from '@/lib/storage';
export default function AppLayout() { export default function AppLayout() {
const keyFile = useStore(s => s.keyFile); const keyFile = useStore(s => s.keyFile);
@@ -37,18 +37,36 @@ export default function AppLayout() {
// - chat detail // - chat detail
// - compose (new post modal) // - compose (new post modal)
// - feed sub-routes (post detail, hashtag search) // - feed sub-routes (post detail, hashtag search)
// - tx detail
const hideNav = const hideNav =
/^\/chats\/[^/]+/.test(pathname) || /^\/chats\/[^/]+/.test(pathname) ||
pathname === '/compose' || pathname === '/compose' ||
/^\/feed\/.+/.test(pathname); /^\/feed\/.+/.test(pathname) ||
/^\/tx\/.+/.test(pathname);
useBalance(); useBalance();
useContacts(); useContacts();
useWellKnownContracts(); useWellKnownContracts();
useDevSeed();
useNotifications(); // permission + tap-handler useNotifications(); // permission + tap-handler
useGlobalInbox(); // global inbox listener → notifications on new peer msg useGlobalInbox(); // global inbox listener → notifications on new peer msg
// Ensure the Saved Messages (self-chat) contact exists as soon as the user
// is signed in, so it shows up in the chat list without any prior action.
const contacts = useStore(s => s.contacts);
const upsertContact = useStore(s => s.upsertContact);
useEffect(() => {
if (!keyFile) return;
if (contacts.some(c => c.address === keyFile.pub_key)) return;
const saved = {
address: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
alias: 'Saved Messages',
addedAt: Date.now(),
};
upsertContact(saved);
saveContact(saved).catch(() => { /* best-effort — re-added next boot anyway */ });
}, [keyFile, contacts, upsertContact]);
useEffect(() => { useEffect(() => {
const ws = getWSClient(); const ws = getWSClient();
if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key }); if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });

View File

@@ -26,7 +26,7 @@ import { encryptMessage } from '@/lib/crypto';
import { sendEnvelope } from '@/lib/api'; import { sendEnvelope } from '@/lib/api';
import { getWSClient } from '@/lib/ws'; import { getWSClient } from '@/lib/ws';
import { appendMessage, loadMessages } from '@/lib/storage'; import { appendMessage, loadMessages } from '@/lib/storage';
import { randomId } from '@/lib/utils'; import { randomId, safeBack } from '@/lib/utils';
import type { Message } from '@/lib/types'; import type { Message } from '@/lib/types';
import { Avatar } from '@/components/Avatar'; import { Avatar } from '@/components/Avatar';
@@ -63,6 +63,24 @@ export default function ChatScreen() {
clearContactNotifications(contactAddress); clearContactNotifications(contactAddress);
}, [contactAddress, clearUnread]); }, [contactAddress, clearUnread]);
const upsertContact = useStore(s => s.upsertContact);
const isSavedMessages = !!keyFile && contactAddress === keyFile.pub_key;
// Auto-materialise the Saved Messages contact the first time the user
// opens chat-with-self. The contact is stored locally only — no on-chain
// CONTACT_REQUEST needed, since both ends are the same key pair.
useEffect(() => {
if (!isSavedMessages || !keyFile) return;
const existing = contacts.find(c => c.address === keyFile.pub_key);
if (existing) return;
upsertContact({
address: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
alias: 'Saved Messages',
addedAt: Date.now(),
});
}, [isSavedMessages, keyFile, contacts, upsertContact]);
const contact = contacts.find(c => c.address === contactAddress); const contact = contacts.find(c => c.address === contactAddress);
const chatMsgs = messages[contactAddress ?? ''] ?? []; const chatMsgs = messages[contactAddress ?? ''] ?? [];
const listRef = useRef<FlatList>(null); const listRef = useRef<FlatList>(null);
@@ -121,9 +139,9 @@ export default function ChatScreen() {
// Восстановить сообщения из persistent-storage при первом заходе в чат. // Восстановить сообщения из persistent-storage при первом заходе в чат.
// //
// Важно: НЕ перезаписываем store пустым массивом — это стёрло бы // Важно: НЕ перезаписываем store пустым массивом — это стёрло бы
// содержимое, которое уже лежит в zustand (например, из devSeed или // содержимое, которое уже лежит в zustand (только что полученные по
// только что полученные по WS сообщения пока монтировались). Если // WS сообщения пока монтировались). Если в кэше что-то есть — мержим:
// в кэше что-то есть — мержим: берём max(cached, in-store) по id. // берём max(cached, in-store) по id.
useEffect(() => { useEffect(() => {
if (!contactAddress) return; if (!contactAddress) return;
loadMessages(contactAddress).then(cached => { loadMessages(contactAddress).then(cached => {
@@ -137,9 +155,11 @@ export default function ChatScreen() {
}); });
}, [contactAddress, setMsgs]); }, [contactAddress, setMsgs]);
const name = contact?.username const name = isSavedMessages
? `@${contact.username}` ? 'Saved Messages'
: contact?.alias ?? shortAddr(contactAddress ?? ''); : contact?.username
? `@${contact.username}`
: contact?.alias ?? shortAddr(contactAddress ?? '');
// ── Compose actions ──────────────────────────────────────────────────── // ── Compose actions ────────────────────────────────────────────────────
const cancelCompose = useCallback(() => { const cancelCompose = useCallback(() => {
@@ -172,7 +192,7 @@ export default function ChatScreen() {
const hasText = !!actualText.trim(); const hasText = !!actualText.trim();
const hasAttach = !!actualAttach; const hasAttach = !!actualAttach;
if (!hasText && !hasAttach) return; if (!hasText && !hasAttach) return;
if (!contact.x25519Pub) { if (!isSavedMessages && !contact.x25519Pub) {
Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.'); Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.');
return; return;
} }
@@ -188,7 +208,10 @@ export default function ChatScreen() {
setSending(true); setSending(true);
try { try {
if (hasText) { // Saved Messages short-circuits the relay entirely — the message never
// leaves the device, so no encryption/fee/network round-trip is needed.
// Regular chats still go through the NaCl + relay pipeline below.
if (hasText && !isSavedMessages) {
const { nonce, ciphertext } = encryptMessage( const { nonce, ciphertext } = encryptMessage(
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub, actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
); );
@@ -224,7 +247,7 @@ export default function ChatScreen() {
setSending(false); setSending(false);
} }
}, [ }, [
text, keyFile, contact, composeMode, chatMsgs, text, keyFile, contact, composeMode, chatMsgs, isSavedMessages,
setMsgs, cancelCompose, appendMsg, pendingAttach, setMsgs, cancelCompose, appendMsg, pendingAttach,
]); ]);
@@ -404,14 +427,14 @@ export default function ChatScreen() {
) : ( ) : (
<Header <Header
divider divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={ title={
<Pressable <Pressable
onPress={onOpenPeerProfile} onPress={onOpenPeerProfile}
hitSlop={4} hitSlop={4}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }} style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
> >
<Avatar name={name} address={contactAddress} size={28} /> <Avatar name={name} address={contactAddress} size={28} saved={isSavedMessages} />
<View style={{ minWidth: 0, flexShrink: 1 }}> <View style={{ minWidth: 0, flexShrink: 1 }}>
<Text <Text
numberOfLines={1} numberOfLines={1}
@@ -429,7 +452,7 @@ export default function ChatScreen() {
typing typing
</Text> </Text>
)} )}
{!peerTyping && !contact?.x25519Pub && ( {!peerTyping && !isSavedMessages && !contact?.x25519Pub && (
<Text style={{ color: '#f0b35a', fontSize: 11 }}> <Text style={{ color: '#f0b35a', fontSize: 11 }}>
waiting for key waiting for key
</Text> </Text>
@@ -447,37 +470,49 @@ export default function ChatScreen() {
с "scroll position at bottom" без ручного scrollToEnd, и новые с "scroll position at bottom" без ручного scrollToEnd, и новые
сообщения (добавляемые в начало reversed-массива) появляются сообщения (добавляемые в начало reversed-массива) появляются
внизу естественно. Никаких jerk'ов при открытии. */} внизу естественно. Никаких jerk'ов при открытии. */}
<FlatList {rows.length === 0 ? (
ref={listRef} // Empty state is rendered as a plain View instead of
data={rows} // ListEmptyComponent on an inverted FlatList — the previous
inverted // `transform: [{ scaleY: -1 }]` un-flip trick was rendering
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id} // text mirrored on some Android builds (RTL-aware layout),
renderItem={renderRow} // giving us the "say hi…" backwards bug.
contentContainerStyle={{ paddingVertical: 10 }} <View style={{
showsVerticalScrollIndicator={false} flex: 1, alignItems: 'center', justifyContent: 'center',
// Lazy render: only mount ~1.5 screens of bubbles initially, paddingHorizontal: 32, gap: 10,
// render further batches as the user scrolls older. Keeps }}>
// initial paint fast on chats with thousands of messages. <Avatar
initialNumToRender={25} name={name}
maxToRenderPerBatch={12} address={contactAddress}
windowSize={10} size={72}
removeClippedSubviews saved={isSavedMessages}
ListEmptyComponent={() => ( />
<View style={{ <Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
flex: 1, alignItems: 'center', justifyContent: 'center', {isSavedMessages ? 'Notes to self' : `Say hi to ${name}`}
paddingHorizontal: 32, gap: 10, </Text>
transform: [{ scaleY: -1 }], // inverted flips cells; un-flip empty state <Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
}}> {isSavedMessages
<Avatar name={name} address={contactAddress} size={72} /> ? 'Anything you send here stays on your device — use it as a scratchpad for links, drafts, or files.'
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}> : 'Your messages are end-to-end encrypted.'}
Say hi to {name} </Text>
</Text> </View>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}> ) : (
Your messages are end-to-end encrypted. <FlatList
</Text> ref={listRef}
</View> data={rows}
)} inverted
/> keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
renderItem={renderRow}
contentContainerStyle={{ paddingVertical: 10 }}
showsVerticalScrollIndicator={false}
// Lazy render: only mount ~1.5 screens of bubbles initially,
// render further batches as the user scrolls older. Keeps
// initial paint fast on chats with thousands of messages.
initialNumToRender={25}
maxToRenderPerBatch={12}
windowSize={10}
removeClippedSubviews
/>
)}
{/* Composer — floating, прибит к низу. */} {/* Composer — floating, прибит к низу. */}
<View style={{ paddingBottom: Math.max(insets.bottom, 4) + 6, backgroundColor: '#000000' }}> <View style={{ paddingBottom: Math.max(insets.bottom, 4) + 6, backgroundColor: '#000000' }}>

View File

@@ -28,6 +28,7 @@ export default function ChatsScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const contacts = useStore(s => s.contacts); const contacts = useStore(s => s.contacts);
const messages = useStore(s => s.messages); const messages = useStore(s => s.messages);
const keyFile = useStore(s => s.keyFile);
// Статус подключения: online / connecting / offline. // Статус подключения: online / connecting / offline.
// Название шапки и цвет pip'а на аватаре зависят от него. // Название шапки и цвет pip'а на аватаре зависят от него.
@@ -48,9 +49,14 @@ export default function ChatsScreen() {
return msgs && msgs.length ? msgs[msgs.length - 1] : null; return msgs && msgs.length ? msgs[msgs.length - 1] : null;
}; };
// Сортировка по последней активности. // Сортировка по последней активности. Saved Messages (self-chat) всегда
// закреплён сверху — это "Избранное", бессмысленно конкурировать с ним
// по recency'и обычным чатам.
const selfAddr = keyFile?.pub_key;
const sorted = useMemo(() => { const sorted = useMemo(() => {
return [...contacts] const saved = selfAddr ? contacts.find(c => c.address === selfAddr) : undefined;
const rest = contacts
.filter(c => c.address !== selfAddr)
.map(c => ({ c, last: lastOf(c) })) .map(c => ({ c, last: lastOf(c) }))
.sort((a, b) => { .sort((a, b) => {
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000; const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
@@ -58,7 +64,8 @@ export default function ChatsScreen() {
return kb - ka; return kb - ka;
}) })
.map(x => x.c); .map(x => x.c);
}, [contacts, messages]); return saved ? [saved, ...rest] : rest;
}, [contacts, messages, selfAddr]);
return ( return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
@@ -72,6 +79,7 @@ export default function ChatsScreen() {
<ChatTile <ChatTile
contact={item} contact={item}
lastMessage={lastOf(item)} lastMessage={lastOf(item)}
saved={item.address === selfAddr}
onPress={() => router.push(`/(app)/chats/${item.address}` as never)} onPress={() => router.push(`/(app)/chats/${item.address}` as never)}
/> />
)} )}

View File

@@ -37,11 +37,23 @@ import { useStore } from '@/lib/store';
import { Avatar } from '@/components/Avatar'; import { Avatar } from '@/components/Avatar';
import { publishAndCommit, formatFee } from '@/lib/feed'; import { publishAndCommit, formatFee } from '@/lib/feed';
import { humanizeTxError, getBalance } from '@/lib/api'; import { humanizeTxError, getBalance } from '@/lib/api';
import { safeBack } from '@/lib/utils';
const MAX_CONTENT_LENGTH = 4000; const MAX_CONTENT_LENGTH = 4000;
const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize
const IMAGE_MAX_DIM = 1080; const IMAGE_MAX_DIM = 1080;
const IMAGE_QUALITY = 0.5; // JPEG Q=50 — small, still readable // Match the server scrubber's JPEG quality (media/scrub.go:ImageJPEGQuality
// = 75). If the client re-encodes at a LOWER quality the server re-encode
// at 75 inflates the bytes, often 2-3× — so a 60 KiB upload silently blows
// past MaxPostSize = 256 KiB mid-flight and `/feed/publish` rejects with
// "post body exceeds maximum allowed size". Using the same Q for both
// passes keeps the final footprint ~the same as what the user sees in
// the composer.
const IMAGE_QUALITY = 0.75;
// Safety margin on the pre-upload check: the server pass is near-idempotent
// at matching Q but not exactly — reserve ~8 KiB for JPEG header / metadata
// scaffolding differences so we don't flirt with the hard cap.
const IMAGE_BUDGET_BYTES = MAX_POST_BYTES - 8 * 1024;
interface Attachment { interface Attachment {
uri: string; uri: string;
@@ -98,11 +110,11 @@ export default function ComposeScreen() {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) { if (!perm.granted) {
Alert.alert( Alert.alert(
'Нужен доступ к фото', 'Photo access required',
'Откройте настройки и разрешите доступ к галерее.', 'Please enable photo library access in Settings.',
[ [
{ text: 'Отмена' }, { text: 'Cancel' },
{ text: 'Настройки', onPress: () => Linking.openSettings() }, { text: 'Settings', onPress: () => Linking.openSettings() },
], ],
); );
return; return;
@@ -130,10 +142,10 @@ export default function ComposeScreen() {
}); });
const bytes = base64ToBytes(b64); const bytes = base64ToBytes(b64);
if (bytes.length > MAX_POST_BYTES - 512) { if (bytes.length > IMAGE_BUDGET_BYTES) {
Alert.alert( Alert.alert(
'Слишком большое', 'Image too large',
`Картинка ${Math.round(bytes.length / 1024)} KB — лимит ${MAX_POST_BYTES / 1024} KB. Попробуйте выбрать поменьше.`, `Image is ${Math.round(bytes.length / 1024)} KB but the post limit is ${MAX_POST_BYTES / 1024} KB (after server re-encode). Try a smaller picture.`,
); );
return; return;
} }
@@ -147,7 +159,7 @@ export default function ComposeScreen() {
height: manipulated.height, height: manipulated.height,
}); });
} catch (e: any) { } catch (e: any) {
Alert.alert('Не удалось', String(e?.message ?? e)); Alert.alert('Failed', String(e?.message ?? e));
} finally { } finally {
setPicking(false); setPicking(false);
} }
@@ -159,19 +171,19 @@ export default function ComposeScreen() {
// Balance guard. // Balance guard.
if (balance !== null && balance < estimatedFee) { if (balance !== null && balance < estimatedFee) {
Alert.alert( Alert.alert(
'Недостаточно средств', 'Insufficient balance',
`Нужно ${formatFee(estimatedFee)}, на балансе ${formatFee(balance)}.`, `Need ${formatFee(estimatedFee)}, have ${formatFee(balance)}.`,
); );
return; return;
} }
Alert.alert( Alert.alert(
'Опубликовать пост?', 'Publish post?',
`Цена: ${formatFee(estimatedFee)}\nРазмер: ${Math.round(totalBytes / 1024 * 10) / 10} KB`, `Cost: ${formatFee(estimatedFee)}\nSize: ${Math.round(totalBytes / 1024 * 10) / 10} KB`,
[ [
{ text: 'Отмена', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
{ {
text: 'Опубликовать', text: 'Publish',
onPress: async () => { onPress: async () => {
setBusy(true); setBusy(true);
try { try {
@@ -185,7 +197,7 @@ export default function ComposeScreen() {
// Close composer and open the new post. // Close composer and open the new post.
router.replace(`/(app)/feed/${postID}` as never); router.replace(`/(app)/feed/${postID}` as never);
} catch (e: any) { } catch (e: any) {
Alert.alert('Не удалось опубликовать', humanizeTxError(e)); Alert.alert('Failed to publish', humanizeTxError(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -212,7 +224,7 @@ export default function ComposeScreen() {
borderBottomColor: '#141414', borderBottomColor: '#141414',
}} }}
> >
<Pressable onPress={() => router.back()} hitSlop={8}> <Pressable onPress={() => safeBack()} hitSlop={8}>
<Ionicons name="close" size={26} color="#ffffff" /> <Ionicons name="close" size={26} color="#ffffff" />
</Pressable> </Pressable>
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
@@ -235,7 +247,7 @@ export default function ComposeScreen() {
fontSize: 14, fontSize: 14,
}} }}
> >
Опубликовать Publish
</Text> </Text>
)} )}
</Pressable> </Pressable>
@@ -251,7 +263,7 @@ export default function ComposeScreen() {
<TextInput <TextInput
value={content} value={content}
onChangeText={setContent} onChangeText={setContent}
placeholder="Что происходит?" placeholder="What's happening?"
placeholderTextColor="#5a5a5a" placeholderTextColor="#5a5a5a"
multiline multiline
maxLength={MAX_CONTENT_LENGTH} maxLength={MAX_CONTENT_LENGTH}
@@ -328,7 +340,7 @@ export default function ComposeScreen() {
</Pressable> </Pressable>
</View> </View>
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6 }}> <Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6 }}>
{Math.round(attach.size / 1024)} KB · метаданные удалят на сервере {Math.round(attach.size / 1024)} KB · metadata stripped on server
</Text> </Text>
</View> </View>
)} )}

View File

@@ -31,6 +31,7 @@ import {
fetchPost, fetchStats, bumpView, formatCount, formatFee, fetchPost, fetchStats, bumpView, formatCount, formatFee,
type FeedPostItem, type PostStats, type FeedPostItem, type PostStats,
} from '@/lib/feed'; } from '@/lib/feed';
import { safeBack } from '@/lib/utils';
export default function PostDetailScreen() { export default function PostDetailScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -71,15 +72,15 @@ export default function PostDetailScreen() {
const onDeleted = useCallback(() => { const onDeleted = useCallback(() => {
// Go back to feed — the post is gone. // Go back to feed — the post is gone.
router.back(); safeBack();
}, []); }, []);
return ( return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header <Header
divider divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title="Пост" title="Post"
/> />
{loading ? ( {loading ? (
@@ -94,7 +95,7 @@ export default function PostDetailScreen() {
<View style={{ padding: 24, alignItems: 'center' }}> <View style={{ padding: 24, alignItems: 'center' }}>
<Ionicons name="trash-outline" size={32} color="#6a6a6a" /> <Ionicons name="trash-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#8b8b8b', marginTop: 10 }}> <Text style={{ color: '#8b8b8b', marginTop: 10 }}>
Пост удалён или больше недоступен Post deleted or no longer available
</Text> </Text>
</View> </View>
) : ( ) : (
@@ -131,18 +132,18 @@ export default function PostDetailScreen() {
textTransform: 'uppercase', textTransform: 'uppercase',
marginBottom: 10, marginBottom: 10,
}}> }}>
Информация о посте Post details
</Text> </Text>
<DetailRow label="Просмотров" value={formatCount(stats?.views ?? post.views)} /> <DetailRow label="Views" value={formatCount(stats?.views ?? post.views)} />
<DetailRow label="Лайков" value={formatCount(stats?.likes ?? post.likes)} /> <DetailRow label="Likes" value={formatCount(stats?.likes ?? post.likes)} />
<DetailRow label="Размер" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} /> <DetailRow label="Size" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} />
<DetailRow <DetailRow
label="Стоимость публикации" label="Paid to publish"
value={formatFee(1000 + post.size)} value={formatFee(1000 + post.size)}
/> />
<DetailRow <DetailRow
label="Хостинг" label="Hosted on"
value={shortAddr(post.hosting_relay)} value={shortAddr(post.hosting_relay)}
mono mono
/> />
@@ -151,7 +152,7 @@ export default function PostDetailScreen() {
<> <>
<View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 10 }} /> <View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 10 }} />
<Text style={{ color: '#5a5a5a', fontSize: 11, marginBottom: 6 }}> <Text style={{ color: '#5a5a5a', fontSize: 11, marginBottom: 6 }}>
Хештеги Hashtags
</Text> </Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}> <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
{post.hashtags.map(tag => ( {post.hashtags.map(tag => (

View File

@@ -0,0 +1,249 @@
/**
* Author wall — timeline of every post by a single author, newest first.
*
* Route: /(app)/feed/author/[pub]
*
* Entry points:
* - Profile screen "View posts" button.
* - Tapping the author name/avatar inside a PostCard.
*
* Backend: GET /feed/author/{pub}?limit=N[&before=ts]
* — chain-authoritative, returns FeedPostItem[] ordered newest-first.
*
* Pagination: infinite-scroll via onEndReached → appends the next page
* anchored on the oldest timestamp we've seen. Safe to over-fetch because
* the relay caps `limit`.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
View, Text, FlatList, RefreshControl, ActivityIndicator, Pressable,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { Avatar } from '@/components/Avatar';
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import { fetchAuthorPosts, fetchStats, type FeedPostItem } from '@/lib/feed';
import { getIdentity, type IdentityInfo } from '@/lib/api';
import { safeBack } from '@/lib/utils';
const PAGE = 30;
function shortAddr(a: string, n = 6): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
export default function AuthorWallScreen() {
const insets = useSafeAreaInsets();
const { pub } = useLocalSearchParams<{ pub: string }>();
const keyFile = useStore(s => s.keyFile);
const contacts = useStore(s => s.contacts);
const contact = contacts.find(c => c.address === pub);
const isMe = !!keyFile && keyFile.pub_key === pub;
const [posts, setPosts] = useState<FeedPostItem[]>([]);
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [exhausted, setExhausted] = useState(false);
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
const seq = useRef(0);
// Identity — for the header's username / avatar seed. Best-effort; the
// screen still works without it.
useEffect(() => {
if (!pub) return;
let cancelled = false;
getIdentity(pub).then(id => { if (!cancelled) setIdentity(id); }).catch(() => {});
return () => { cancelled = true; };
}, [pub]);
const loadLikedFor = useCallback(async (items: FeedPostItem[]) => {
if (!keyFile) return new Set<string>();
const liked = new Set<string>();
for (const p of items) {
const s = await fetchStats(p.post_id, keyFile.pub_key);
if (s?.liked_by_me) liked.add(p.post_id);
}
return liked;
}, [keyFile]);
const load = useCallback(async (isRefresh = false) => {
if (!pub) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
const id = ++seq.current;
try {
const items = await fetchAuthorPosts(pub, { limit: PAGE });
if (id !== seq.current) return;
setPosts(items);
setExhausted(items.length < PAGE);
const liked = await loadLikedFor(items);
if (id !== seq.current) return;
setLikedSet(liked);
} catch {
if (id !== seq.current) return;
setPosts([]);
setExhausted(true);
} finally {
if (id !== seq.current) return;
setLoading(false);
setRefreshing(false);
}
}, [pub, loadLikedFor]);
useEffect(() => { load(false); }, [load]);
const loadMore = useCallback(async () => {
if (!pub || loadingMore || exhausted || loading) return;
const oldest = posts[posts.length - 1];
if (!oldest) return;
setLoadingMore(true);
try {
const more = await fetchAuthorPosts(pub, { limit: PAGE, before: oldest.created_at });
// De-dup by post_id — defensive against boundary overlap.
const known = new Set(posts.map(p => p.post_id));
const fresh = more.filter(p => !known.has(p.post_id));
if (fresh.length === 0) { setExhausted(true); return; }
setPosts(prev => [...prev, ...fresh]);
if (more.length < PAGE) setExhausted(true);
const liked = await loadLikedFor(fresh);
setLikedSet(set => {
const next = new Set(set);
liked.forEach(v => next.add(v));
return next;
});
} catch {
// Swallow — user can pull-to-refresh to recover.
} finally {
setLoadingMore(false);
}
}, [pub, posts, loadingMore, exhausted, loading, loadLikedFor]);
const onStatsChanged = useCallback(async (postID: string) => {
if (!keyFile) return;
const s = await fetchStats(postID, keyFile.pub_key);
if (!s) return;
setPosts(ps => ps.map(p => p.post_id === postID
? { ...p, likes: s.likes, views: s.views } : p));
setLikedSet(set => {
const next = new Set(set);
if (s.liked_by_me) next.add(postID); else next.delete(postID);
return next;
});
}, [keyFile]);
// "Saved Messages" is a messaging-app label and has no place on a public
// wall — for self we fall back to the real handle (@username if claimed,
// else short-addr), same as any other author.
const displayName = isMe
? (identity?.nickname ? `@${identity.nickname}` : 'You')
: contact?.username
? `@${contact.username}`
: (contact?.alias && contact.alias !== 'Saved Messages')
? contact.alias
: (identity?.nickname ? `@${identity.nickname}` : shortAddr(pub ?? '', 6));
const handle = identity?.nickname ? `@${identity.nickname}` : shortAddr(pub ?? '', 6);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={
<Pressable
onPress={() => pub && router.push(`/(app)/profile/${pub}` as never)}
hitSlop={4}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
>
<Avatar name={displayName} address={pub} size={28} />
<View style={{ minWidth: 0, flexShrink: 1 }}>
<Text
numberOfLines={1}
style={{
color: '#ffffff', fontSize: 15, fontWeight: '700', letterSpacing: -0.2,
}}
>
{displayName}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 11 }} numberOfLines={1}>
{handle !== displayName ? handle : 'Wall'}
</Text>
</View>
</Pressable>
}
/>
<FlatList
data={posts}
keyExtractor={p => p.post_id}
renderItem={({ item }) => (
<PostCard
post={item}
likedByMe={likedSet.has(item.post_id)}
onStatsChanged={onStatsChanged}
/>
)}
ItemSeparatorComponent={PostSeparator}
initialNumToRender={10}
maxToRenderPerBatch={8}
windowSize={7}
removeClippedSubviews
onEndReachedThreshold={0.6}
onEndReached={loadMore}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => load(true)}
tintColor="#1d9bf0"
/>
}
ListFooterComponent={
loadingMore ? (
<View style={{ paddingVertical: 18 }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : null
}
ListEmptyComponent={
loading ? (
<View style={{ paddingTop: 80, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : (
<View style={{
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, paddingVertical: 80,
}}>
<Ionicons name="document-text-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
{isMe ? "You haven't posted yet" : 'No posts yet'}
</Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}>
{isMe
? 'Tap the compose button on the feed tab to publish your first post.'
: 'This user hasn\'t published any posts on this chain.'}
</Text>
</View>
)
}
contentContainerStyle={
posts.length === 0
? { flexGrow: 1 }
: { paddingTop: 8, paddingBottom: 24 }
}
/>
</View>
);
}

View File

@@ -27,14 +27,13 @@ import {
fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView, fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView,
type FeedPostItem, type FeedPostItem,
} from '@/lib/feed'; } from '@/lib/feed';
import { getDevSeedFeed } from '@/lib/devSeedFeed';
type TabKey = 'following' | 'foryou' | 'trending'; type TabKey = 'following' | 'foryou' | 'trending';
const TAB_LABELS: Record<TabKey, string> = { const TAB_LABELS: Record<TabKey, string> = {
following: 'Подписки', following: 'Following',
foryou: 'Для вас', foryou: 'For you',
trending: 'В тренде', trending: 'Trending',
}; };
export default function FeedScreen() { export default function FeedScreen() {
@@ -78,12 +77,6 @@ export default function FeedScreen() {
} }
if (seq !== requestRef.current) return; // stale response if (seq !== requestRef.current) return; // stale response
// Dev-only fallback: if the node has no real posts yet, surface
// synthetic ones so we can scroll + tap. Stripped from production.
if (items.length === 0) {
items = getDevSeedFeed();
}
setPosts(items); setPosts(items);
// If the server returned fewer than PAGE_SIZE, we already have // If the server returned fewer than PAGE_SIZE, we already have
// everything — disable further paginated fetches. // everything — disable further paginated fetches.
@@ -105,11 +98,11 @@ export default function FeedScreen() {
} catch (e: any) { } catch (e: any) {
if (seq !== requestRef.current) return; if (seq !== requestRef.current) return;
const msg = String(e?.message ?? e); const msg = String(e?.message ?? e);
// Network / 404 is benign — node just unreachable or empty. In __DEV__ // Network / 404 is benign — node just unreachable or empty. Show
// fall back to synthetic seed posts so the scroll / tap UI stays // the empty-state; the catch block above already cleared error
// testable; in production this path shows the empty state. // on benign messages. Production treats this identically.
if (/Network request failed|→\s*404/.test(msg)) { if (/Network request failed|→\s*404/.test(msg)) {
setPosts(getDevSeedFeed()); setPosts([]);
setExhausted(true); setExhausted(true);
} else { } else {
setError(msg); setError(msg);
@@ -208,15 +201,15 @@ export default function FeedScreen() {
const emptyHint = useMemo(() => { const emptyHint = useMemo(() => {
switch (tab) { switch (tab) {
case 'following': return 'Подпишитесь на кого-нибудь, чтобы увидеть их посты здесь.'; case 'following': return 'Follow someone to see their posts here.';
case 'foryou': return 'Пока нет рекомендаций — возвращайтесь позже.'; case 'foryou': return 'No recommendations yet — come back later.';
case 'trending': return 'В этой ленте пока тихо.'; case 'trending': return 'Nothing trending yet.';
} }
}, [tab]); }, [tab]);
return ( return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Лента" /> <TabHeader title="Feed" />
{/* Tab strip — три таба, равномерно распределены по ширине {/* Tab strip — три таба, равномерно распределены по ширине
(justifyContent: space-between). Каждый Pressable hug'ает (justifyContent: space-between). Каждый Pressable hug'ает
@@ -312,14 +305,14 @@ export default function FeedScreen() {
) : error ? ( ) : error ? (
<EmptyState <EmptyState
icon="alert-circle-outline" icon="alert-circle-outline"
title="Не удалось загрузить ленту" title="Couldn't load feed"
subtitle={error} subtitle={error}
onRetry={() => loadPosts(false)} onRetry={() => loadPosts(false)}
/> />
) : ( ) : (
<EmptyState <EmptyState
icon="newspaper-outline" icon="newspaper-outline"
title="Здесь пока пусто" title="Nothing to show yet"
subtitle={emptyHint} subtitle={emptyHint}
/> />
) )
@@ -416,7 +409,7 @@ function EmptyState({
})} })}
> >
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}> <Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Попробовать снова Try again
</Text> </Text>
</Pressable> </Pressable>
)} )}

View File

@@ -17,6 +17,7 @@ import { IconButton } from '@/components/IconButton';
import { PostCard, PostSeparator } from '@/components/feed/PostCard'; import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed'; import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed';
import { safeBack } from '@/lib/utils';
export default function HashtagScreen() { export default function HashtagScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -80,7 +81,7 @@ export default function HashtagScreen() {
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header <Header
divider divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={`#${tag}`} title={`#${tag}`}
/> />
@@ -119,10 +120,10 @@ export default function HashtagScreen() {
}}> }}>
<Ionicons name="pricetag-outline" size={32} color="#6a6a6a" /> <Ionicons name="pricetag-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}> <Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
Пока нет постов с этим тегом No posts with this tag yet
</Text> </Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}> <Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}>
Будьте первым напишите пост с #{tag} Be the first write a post with #{tag}
</Text> </Text>
</View> </View>
) )

View File

@@ -18,7 +18,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { getIdentity, buildContactRequestTx, submitTx, resolveUsername, humanizeTxError } from '@/lib/api'; import { getIdentity, buildContactRequestTx, submitTx, resolveUsername, humanizeTxError } from '@/lib/api';
import { shortAddr } from '@/lib/crypto'; import { shortAddr } from '@/lib/crypto';
import { formatAmount } from '@/lib/utils'; import { formatAmount, safeBack } from '@/lib/utils';
import { Avatar } from '@/components/Avatar'; import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
@@ -64,9 +64,21 @@ export default function NewContactScreen() {
if (!addr) { setError(`@${name} is not registered on this chain`); return; } if (!addr) { setError(`@${name} is not registered on this chain`); return; }
address = addr; address = addr;
} }
// Self-lookup: skip the contact-request dance entirely and jump straight
// to Saved Messages (self-chat). No CONTACT_REQUEST tx is needed — the
// chat-with-self flow is purely local storage.
if (keyFile && address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
router.replace(`/(app)/chats/${keyFile.pub_key}` as never);
return;
}
const identity = await getIdentity(address); const identity = await getIdentity(address);
const resolvedAddr = identity?.pub_key ?? address;
if (keyFile && resolvedAddr.toLowerCase() === keyFile.pub_key.toLowerCase()) {
router.replace(`/(app)/chats/${keyFile.pub_key}` as never);
return;
}
setResolved({ setResolved({
address: identity?.pub_key ?? address, address: resolvedAddr,
nickname: identity?.nickname || undefined, nickname: identity?.nickname || undefined,
x25519: identity?.x25519_pub || undefined, x25519: identity?.x25519_pub || undefined,
}); });
@@ -79,8 +91,12 @@ export default function NewContactScreen() {
async function sendRequest() { async function sendRequest() {
if (!resolved || !keyFile) return; if (!resolved || !keyFile) return;
if (resolved.address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
Alert.alert('Can\'t message yourself', "This is your own address.");
return;
}
if (balance < fee + 1000) { if (balance < fee + 1000) {
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (fee + network).`); Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (request fee + network).`);
return; return;
} }
setSending(true); setError(null); setSending(true); setError(null);
@@ -96,7 +112,7 @@ export default function NewContactScreen() {
Alert.alert( Alert.alert(
'Request sent', 'Request sent',
`A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`, `A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
[{ text: 'OK', onPress: () => router.back() }], [{ text: 'OK', onPress: () => safeBack() }],
); );
} catch (e: any) { } catch (e: any) {
setError(humanizeTxError(e)); setError(humanizeTxError(e));
@@ -112,42 +128,65 @@ export default function NewContactScreen() {
return ( return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header <Header
title="New chat" title="Search"
divider={false} divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/> />
<ScrollView <ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: 120 }} contentContainerStyle={{ padding: 14, paddingBottom: 120 }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 14 }}>
Enter a <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text>, a
hex pubkey or a <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> address.
</Text>
<SearchBar <SearchBar
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
placeholder="@alice or hex / DC address" placeholder="@alice, hex pubkey or DC address"
onSubmitEditing={search} onSubmitEditing={search}
autoFocus
onClear={() => { setResolved(null); setError(null); }}
/> />
<Pressable {query.trim().length > 0 && (
onPress={search} <Pressable
disabled={searching || !query.trim()} onPress={search}
style={({ pressed }) => ({ disabled={searching}
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', style={({ pressed }) => ({
paddingVertical: 11, borderRadius: 999, marginTop: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
backgroundColor: !query.trim() || searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', paddingVertical: 11, borderRadius: 999, marginTop: 12,
})} backgroundColor: searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
> })}
{searching ? ( >
<ActivityIndicator color="#ffffff" size="small" /> {searching ? (
) : ( <ActivityIndicator color="#ffffff" size="small" />
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text> ) : (
)} <Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text>
</Pressable> )}
</Pressable>
)}
{/* Empty-state hint — показываем когда ничего не введено и нет результата */}
{query.trim().length === 0 && !resolved && (
<View style={{ marginTop: 28, alignItems: 'center', paddingHorizontal: 16 }}>
<View
style={{
width: 56, height: 56, borderRadius: 16,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginBottom: 12,
}}
>
<Ionicons name="person-add-outline" size={24} color="#6a6a6a" />
</View>
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700', marginBottom: 6 }}>
Find someone to message
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
Enter an <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text> if
the person registered one, or paste a full hex pubkey or <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> address.
</Text>
</View>
)}
{error && ( {error && (
<View style={{ <View style={{
@@ -195,7 +234,7 @@ export default function NewContactScreen() {
color: resolved.x25519 ? '#3ba55d' : '#f0b35a', color: resolved.x25519 ? '#3ba55d' : '#f0b35a',
fontSize: 11, fontWeight: '500', fontSize: 11, fontWeight: '500',
}}> }}>
{resolved.x25519 ? 'E2E-ready' : 'Key not published yet'} {resolved.x25519 ? 'E2E ready' : 'Key not published yet'}
</Text> </Text>
</View> </View>
</View> </View>
@@ -227,8 +266,16 @@ export default function NewContactScreen() {
{/* Fee tier */} {/* Fee tier */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 14, marginBottom: 6 }}> <Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 14, marginBottom: 6 }}>
Anti-spam fee (goes to recipient) Anti-spam fee (goes to the recipient)
</Text> </Text>
{/* Fee-tier pills.
Layout (background, border, padding) lives on a static
inner View — Pressable's dynamic style-function has been
observed to drop backgroundColor between renders on
some RN/Android versions, which is what made these
chips look like three bare text labels on black
instead of distinct pills. Press feedback via opacity
on the Pressable itself, which is stable. */}
<View style={{ flexDirection: 'row', gap: 8 }}> <View style={{ flexDirection: 'row', gap: 8 }}>
{FEE_TIERS.map(t => { {FEE_TIERS.map(t => {
const active = fee === t.value; const active = fee === t.value;
@@ -238,25 +285,32 @@ export default function NewContactScreen() {
onPress={() => setFee(t.value)} onPress={() => setFee(t.value)}
style={({ pressed }) => ({ style={({ pressed }) => ({
flex: 1, flex: 1,
alignItems: 'center', opacity: pressed ? 0.7 : 1,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: active ? '#ffffff' : pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: active ? '#ffffff' : '#1f1f1f',
})} })}
> >
<Text style={{ <View
color: active ? '#000' : '#ffffff', style={{
fontWeight: '700', fontSize: 13, alignItems: 'center',
}}> paddingVertical: 10,
{t.label} borderRadius: 10,
</Text> backgroundColor: active ? '#ffffff' : '#111111',
<Text style={{ borderWidth: 1,
color: active ? '#333' : '#8b8b8b', borderColor: active ? '#ffffff' : '#1f1f1f',
fontSize: 11, marginTop: 2, }}
}}> >
{formatAmount(t.value)} <Text style={{
</Text> color: active ? '#000' : '#ffffff',
fontWeight: '700', fontSize: 13,
}}>
{t.label}
</Text>
<Text style={{
color: active ? '#333' : '#8b8b8b',
fontSize: 11, marginTop: 2,
}}>
{formatAmount(t.value)}
</Text>
</View>
</Pressable> </Pressable>
); );
})} })}

View File

@@ -13,7 +13,7 @@
* push stack, so tapping Back returns the user to whatever screen * push stack, so tapping Back returns the user to whatever screen
* pushed them here (feed card tap, chat header tap, etc.). * pushed them here (feed card tap, chat header tap, etc.).
*/ */
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
View, Text, ScrollView, Pressable, ActivityIndicator, View, Text, ScrollView, Pressable, ActivityIndicator,
} from 'react-native'; } from 'react-native';
@@ -27,7 +27,11 @@ import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton'; import { IconButton } from '@/components/IconButton';
import { followUser, unfollowUser } from '@/lib/feed'; import { followUser, unfollowUser } from '@/lib/feed';
import { humanizeTxError } from '@/lib/api'; import {
humanizeTxError, getBalance, getIdentity, getRelayFor,
type IdentityInfo, type RegisteredRelayInfo,
} from '@/lib/api';
import { safeBack, formatAmount } from '@/lib/utils';
function shortAddr(a: string, n = 10): string { function shortAddr(a: string, n = 10): string {
if (!a) return '—'; if (!a) return '—';
@@ -45,10 +49,35 @@ export default function ProfileScreen() {
const [followingBusy, setFollowingBusy] = useState(false); const [followingBusy, setFollowingBusy] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// On-chain enrichment — fetched once per address mount.
const [balanceUT, setBalanceUT] = useState<number | null>(null);
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
const [relay, setRelay] = useState<RegisteredRelayInfo | null>(null);
const [loadingChain, setLoadingChain] = useState(true);
const isMe = !!keyFile && keyFile.pub_key === address; const isMe = !!keyFile && keyFile.pub_key === address;
const displayName = contact?.username
? `@${contact.username}` useEffect(() => {
: contact?.alias ?? (isMe ? 'Вы' : shortAddr(address ?? '', 6)); if (!address) return;
let cancelled = false;
setLoadingChain(true);
Promise.all([
getBalance(address).catch(() => 0),
getIdentity(address).catch(() => null),
getRelayFor(address).catch(() => null),
]).then(([bal, id, rel]) => {
if (cancelled) return;
setBalanceUT(bal);
setIdentity(id);
setRelay(rel);
}).finally(() => { if (!cancelled) setLoadingChain(false); });
return () => { cancelled = true; };
}, [address]);
const displayName = isMe
? 'Saved Messages'
: contact?.username
? `@${contact.username}`
: contact?.alias ?? shortAddr(address ?? '', 6);
const copyAddress = async () => { const copyAddress = async () => {
if (!address) return; if (!address) return;
@@ -85,15 +114,15 @@ export default function ProfileScreen() {
return ( return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header <Header
title="Профиль" title="Profile"
divider divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/> />
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}> <ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>
{/* ── Hero: avatar + Follow button ──────────────────────────── */} {/* ── Hero: avatar + Follow button ──────────────────────────── */}
<View style={{ flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12 }}> <View style={{ flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12 }}>
<Avatar name={displayName} address={address} size={72} /> <Avatar name={displayName} address={address} size={72} saved={isMe} />
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
{!isMe ? ( {!isMe ? (
<Pressable <Pressable
@@ -124,22 +153,24 @@ export default function ProfileScreen() {
fontSize: 13, fontSize: 13,
}} }}
> >
{following ? 'Вы подписаны' : 'Подписаться'} {following ? 'Following' : 'Follow'}
</Text> </Text>
)} )}
</Pressable> </Pressable>
) : ( ) : (
<Pressable <Pressable
onPress={() => router.push('/(app)/settings' as never)} onPress={() => keyFile && router.push(`/(app)/chats/${keyFile.pub_key}` as never)}
style={({ pressed }) => ({ style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 9, paddingHorizontal: 16, paddingVertical: 9,
borderRadius: 999, borderRadius: 999,
flexDirection: 'row', alignItems: 'center', gap: 6,
backgroundColor: pressed ? '#1a1a1a' : '#111111', backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f', borderWidth: 1, borderColor: '#1f1f1f',
})} })}
> >
<Ionicons name="bookmark" size={13} color="#f0b35a" />
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}> <Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Редактировать Saved Messages
</Text> </Text>
</Pressable> </Pressable>
)} )}
@@ -158,13 +189,14 @@ export default function ProfileScreen() {
)} )}
</View> </View>
{/* Open chat — single CTA, full width, icon inline with text. {/* Action row — View posts is universal (anyone can have a wall,
Only when we know this is a contact (direct chat exists). */} even non-contacts). Open chat appears alongside only when this
{!isMe && contact && ( address is already a direct-chat contact. */}
<View style={{ flexDirection: 'row', gap: 8, marginTop: 14 }}>
<Pressable <Pressable
onPress={openChat} onPress={() => address && router.push(`/(app)/feed/author/${address}` as never)}
style={({ pressed }) => ({ style={({ pressed }) => ({
marginTop: 14, flex: 1,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -175,12 +207,34 @@ export default function ProfileScreen() {
borderWidth: 1, borderColor: '#1f1f1f', borderWidth: 1, borderColor: '#1f1f1f',
})} })}
> >
<Ionicons name="chatbubble-outline" size={15} color="#ffffff" /> <Ionicons name="document-text-outline" size={15} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}> <Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
Открыть чат View posts
</Text> </Text>
</Pressable> </Pressable>
)}
{!isMe && contact && (
<Pressable
onPress={openChat}
style={({ pressed }) => ({
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 11,
borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Ionicons name="chatbubble-outline" size={15} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
Open chat
</Text>
</Pressable>
)}
</View>
{/* ── Info card ───────────────────────────────────────────────── */} {/* ── Info card ───────────────────────────────────────────────── */}
<View <View
@@ -203,7 +257,7 @@ export default function ProfileScreen() {
})} })}
> >
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}> <Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>
Адрес Address
</Text> </Text>
<Text <Text
style={{ style={{
@@ -214,7 +268,7 @@ export default function ProfileScreen() {
}} }}
numberOfLines={1} numberOfLines={1}
> >
{copied ? 'Скопировано' : shortAddr(address ?? '')} {copied ? 'Copied' : shortAddr(address ?? '')}
</Text> </Text>
<Ionicons <Ionicons
name={copied ? 'checkmark' : 'copy-outline'} name={copied ? 'checkmark' : 'copy-outline'}
@@ -224,22 +278,79 @@ export default function ProfileScreen() {
/> />
</Pressable> </Pressable>
{/* Username — shown if the on-chain identity record has one.
Different from contact.username (which may be a local alias). */}
{identity?.nickname ? (
<>
<Divider />
<InfoRow
label="Username"
value={`@${identity.nickname}`}
icon="at-outline"
/>
</>
) : null}
{/* DC address — the human-readable form of the pub key. */}
{identity?.address ? (
<>
<Divider />
<InfoRow
label="DC address"
value={identity.address}
icon="pricetag-outline"
/>
</>
) : null}
{/* Balance — always shown once fetched. */}
<Divider />
<InfoRow
label="Balance"
value={loadingChain
? '…'
: `${formatAmount(balanceUT ?? 0)} UT`}
icon="wallet-outline"
/>
{/* Relay node — shown only if this address is a registered relay. */}
{relay && (
<>
<Divider />
<InfoRow
label="Relay node"
value={`${formatAmount(relay.relay.fee_per_msg_ut)} UT / msg`}
icon="radio-outline"
/>
{relay.last_heartbeat ? (
<>
<Divider />
<InfoRow
label="Last seen"
value={new Date(relay.last_heartbeat * 1000).toLocaleString()}
icon="pulse-outline"
/>
</>
) : null}
</>
)}
{/* Encryption status */} {/* Encryption status */}
{contact && ( {contact && (
<> <>
<Divider /> <Divider />
<InfoRow <InfoRow
label="Шифрование" label="Encryption"
value={contact.x25519Pub value={contact.x25519Pub
? 'end-to-end (NaCl)' ? 'end-to-end (NaCl)'
: 'ключ ещё не опубликован'} : 'key not published yet'}
danger={!contact.x25519Pub} danger={!contact.x25519Pub}
icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'} icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'}
/> />
<Divider /> <Divider />
<InfoRow <InfoRow
label="Добавлен" label="Added"
value={new Date(contact.addedAt).toLocaleDateString()} value={new Date(contact.addedAt).toLocaleDateString()}
icon="calendar-outline" icon="calendar-outline"
/> />
@@ -251,7 +362,7 @@ export default function ProfileScreen() {
<> <>
<Divider /> <Divider />
<InfoRow <InfoRow
label="Участников" label="Members"
value="—" value="—"
icon="people-outline" icon="people-outline"
/> />
@@ -270,7 +381,7 @@ export default function ProfileScreen() {
paddingHorizontal: 24, paddingHorizontal: 24,
lineHeight: 17, lineHeight: 17,
}}> }}>
Этот пользователь пока не в ваших контактах. Нажмите «Подписаться», чтобы видеть его посты в ленте, или добавьте в чаты через @username. This user isn't in your contacts yet. Tap "Follow" to see their posts in your feed, or add them as a chat contact via @username.
</Text> </Text>
)} )}
</ScrollView> </ScrollView>

View File

@@ -91,7 +91,7 @@ export default function RequestsScreen() {
{name} {name}
</Text> </Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}> <Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
wants to message you · {relativeTime(req.timestamp)} wants to add you as a contact · {relativeTime(req.timestamp)}
</Text> </Text>
{req.intro ? ( {req.intro ? (
<Text <Text

View File

@@ -32,7 +32,7 @@ import {
humanizeTxError, humanizeTxError,
} from '@/lib/api'; } from '@/lib/api';
import { shortAddr } from '@/lib/crypto'; import { shortAddr } from '@/lib/crypto';
import { formatAmount } from '@/lib/utils'; import { formatAmount, safeBack } from '@/lib/utils';
import { Avatar } from '@/components/Avatar'; import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
@@ -335,7 +335,7 @@ export default function SettingsScreen() {
<Header <Header
title="Settings" title="Settings"
divider={false} divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/> />
<ScrollView <ScrollView
contentContainerStyle={{ paddingBottom: 120 }} contentContainerStyle={{ paddingBottom: 120 }}

View File

@@ -0,0 +1,427 @@
/**
* Transaction detail screen — shows everything the block explorer
* does for a single tx, so the user can audit any action they took
* (transfer, post, like, contact request) without leaving the app.
*
* Route: /(app)/tx/[id]
*
* Triggered from: wallet history (TxTile tap). Will also be reachable
* from post detail / profile timestamp once we wire those up (Phase
* v2.1 idea).
*
* Layout matches the style of the profile info card:
* [back] Transaction
*
* [ICON] <TYPE>
* <relative time> · <status>
*
* [amount pill, big, signed ± + tone colour] (for TRANSFER-ish)
*
* Info card rows:
* ID <hash> (tap → copy)
* From <addr> (tap → copy)
* To <addr> (tap → copy)
* Block #N
* Time <human>
* Fee 0.001 T
* Gas 1234 (if CALL_CONTRACT)
* Memo (if set)
*
* [payload section, collapsible — raw JSON or hex]
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
View, Text, ScrollView, ActivityIndicator, Pressable,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { getTxDetail, type TxDetail } from '@/lib/api';
import { useStore } from '@/lib/store';
import { safeBack, formatAmount } from '@/lib/utils';
function shortAddr(a: string, n = 8): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
// Copy of the tx-type metadata used by wallet.tsx — keeps the icon +
// label consistent whichever screen surfaces the tx.
function txMeta(type: string): {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
tone: 'in' | 'out' | 'neutral';
} {
switch (type) {
case 'TRANSFER': return { icon: 'swap-horizontal', label: 'Transfer', tone: 'neutral' };
case 'CONTACT_REQUEST': return { icon: 'person-add', label: 'Contact request', tone: 'out' };
case 'ACCEPT_CONTACT': return { icon: 'checkmark-circle', label: 'Accepted contact', tone: 'neutral' };
case 'BLOCK_CONTACT': return { icon: 'ban', label: 'Blocked contact', tone: 'neutral' };
case 'REGISTER_KEY': return { icon: 'key', label: 'Identity registered', tone: 'neutral' };
case 'REGISTER_RELAY': return { icon: 'globe', label: 'Relay registered', tone: 'neutral' };
case 'BIND_WALLET': return { icon: 'wallet', label: 'Wallet bound', tone: 'neutral' };
case 'RELAY_PROOF': return { icon: 'receipt', label: 'Relay proof', tone: 'in' };
case 'BLOCK_REWARD': return { icon: 'trophy', label: 'Block reward', tone: 'in' };
case 'HEARTBEAT': return { icon: 'pulse', label: 'Heartbeat', tone: 'neutral' };
case 'CREATE_POST': return { icon: 'newspaper', label: 'Post published', tone: 'out' };
case 'DELETE_POST': return { icon: 'trash', label: 'Post deleted', tone: 'neutral' };
case 'LIKE_POST': return { icon: 'heart', label: 'Like', tone: 'neutral' };
case 'UNLIKE_POST': return { icon: 'heart-dislike', label: 'Unlike', tone: 'neutral' };
case 'FOLLOW': return { icon: 'person-add', label: 'Follow', tone: 'neutral' };
case 'UNFOLLOW': return { icon: 'person-remove', label: 'Unfollow', tone: 'neutral' };
case 'CALL_CONTRACT': return { icon: 'terminal', label: 'Contract call', tone: 'neutral' };
case 'DEPLOY_CONTRACT': return { icon: 'cube', label: 'Contract deployed', tone: 'neutral' };
case 'STAKE': return { icon: 'lock-closed', label: 'Stake', tone: 'out' };
case 'UNSTAKE': return { icon: 'lock-open', label: 'Unstake', tone: 'in' };
default: return { icon: 'document', label: type || 'Transaction', tone: 'neutral' };
}
}
function toneColor(tone: 'in' | 'out' | 'neutral'): string {
if (tone === 'in') return '#3ba55d';
if (tone === 'out') return '#f4212e';
return '#ffffff';
}
export default function TxDetailScreen() {
const insets = useSafeAreaInsets();
const { id } = useLocalSearchParams<{ id: string }>();
const keyFile = useStore(s => s.keyFile);
const [tx, setTx] = useState<TxDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [payloadOpen, setPayloadOpen] = useState(false);
useEffect(() => {
if (!id) return;
let cancelled = false;
setLoading(true);
setError(null);
getTxDetail(id)
.then(res => { if (!cancelled) setTx(res); })
.catch(e => { if (!cancelled) setError(String(e?.message ?? e)); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [id]);
const copy = useCallback(async (field: string, value: string) => {
await Clipboard.setStringAsync(value);
setCopied(field);
setTimeout(() => setCopied(f => (f === field ? null : f)), 1500);
}, []);
const meta = tx ? txMeta(tx.type) : null;
const mine = keyFile?.pub_key ?? '';
const isMineOut = tx ? tx.from === mine && tx.to !== mine : false;
const isMineIn = tx ? tx.to === mine && tx.from !== mine : false;
const showAmount = tx ? tx.amount_ut > 0 : false;
// Sign based on perspective: money leaving my wallet → minus, coming in → plus.
const sign = isMineOut ? '' : isMineIn ? '+' : '';
const amountColor =
isMineOut ? '#f4212e'
: isMineIn ? '#3ba55d'
: '#ffffff';
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Transaction"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
{loading ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : error ? (
<View style={{ padding: 24 }}>
<Text style={{ color: '#f4212e', fontSize: 14 }}>{error}</Text>
</View>
) : !tx ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
<Ionicons name="help-circle-outline" size={40} color="#3a3a3a" />
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
Not found
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', marginTop: 6 }}>
No transaction with this ID on this chain.
</Text>
</View>
) : (
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
{/* ── Hero row: icon + type + time ───────────────────────── */}
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 16 }}>
<View
style={{
width: 48, height: 48, borderRadius: 14,
backgroundColor: '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginRight: 12,
}}
>
<Ionicons name={meta!.icon} size={22} color="#ffffff" />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}>
{meta!.label}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
{new Date(tx.time).toLocaleString()}
</Text>
</View>
</View>
{/* ── Amount big number — only for txs that move tokens ── */}
{showAmount && (
<View
style={{
alignItems: 'center',
paddingVertical: 18,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
marginBottom: 14,
}}
>
<Text style={{
color: amountColor,
fontSize: 32,
fontWeight: '800',
letterSpacing: -0.8,
}}>
{sign}{formatAmount(tx.amount_ut)}
</Text>
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
{tx.amount}
</Text>
</View>
)}
{/* ── Info card ───────────────────────────────────────────── */}
<View
style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}
>
<CopyRow
label="Tx ID"
value={shortAddr(tx.id, 8)}
rawValue={tx.id}
field="id"
copied={copied}
onCopy={copy}
mono
/>
<Divider />
<CopyRow
label="From"
value={shortAddr(tx.from, 8)}
rawValue={tx.from}
field="from"
copied={copied}
onCopy={copy}
mono
highlight={tx.from === mine ? 'you' : undefined}
/>
{tx.to && (
<>
<Divider />
<CopyRow
label="To"
value={shortAddr(tx.to, 8)}
rawValue={tx.to}
field="to"
copied={copied}
onCopy={copy}
mono
highlight={tx.to === mine ? 'you' : undefined}
/>
</>
)}
<Divider />
<InfoRow label="Block" value={`#${tx.block_index}`} />
<Divider />
<InfoRow label="Fee" value={formatAmount(tx.fee_ut)} />
{tx.gas_used ? (
<>
<Divider />
<InfoRow label="Gas used" value={String(tx.gas_used)} />
</>
) : null}
{tx.memo ? (
<>
<Divider />
<InfoRow label="Memo" value={tx.memo} />
</>
) : null}
</View>
{/* ── Payload expand ─────────────────────────────────────── */}
{(tx.payload || tx.payload_hex) && (
<View style={{ marginTop: 14 }}>
<Pressable
onPress={() => setPayloadOpen(o => !o)}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 14,
backgroundColor: pressed ? '#0f0f0f' : '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 14,
})}
>
<Ionicons name="code-slash" size={14} color="#8b8b8b" />
<Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '600', marginLeft: 8, flex: 1 }}>
Payload
</Text>
<Ionicons
name={payloadOpen ? 'chevron-up' : 'chevron-down'}
size={14}
color="#6a6a6a"
/>
</Pressable>
{payloadOpen && (
<View
style={{
marginTop: 8,
padding: 12,
borderRadius: 12,
backgroundColor: '#050505',
borderWidth: 1, borderColor: '#1f1f1f',
}}
>
<Text
selectable
style={{
color: '#d0d0d0',
fontSize: 11,
fontFamily: 'monospace',
lineHeight: 16,
}}
>
{tx.payload
? JSON.stringify(tx.payload, null, 2)
: `hex: ${tx.payload_hex}`}
</Text>
</View>
)}
</View>
)}
{/* Signature as a final copyable row, small */}
{tx.signature_hex && (
<View
style={{
marginTop: 14,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}
>
<CopyRow
label="Signature"
value={shortAddr(tx.signature_hex, 10)}
rawValue={tx.signature_hex}
field="signature"
copied={copied}
onCopy={copy}
mono
/>
</View>
)}
</ScrollView>
)}
</View>
);
}
// ── Rows ──────────────────────────────────────────────────────────────
function Divider() {
return <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />;
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 12,
}}
>
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
<Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '600' }} numberOfLines={1}>
{value}
</Text>
</View>
);
}
function CopyRow({
label, value, rawValue, field, copied, onCopy, mono, highlight,
}: {
label: string;
value: string;
rawValue: string;
field: string;
copied: string | null;
onCopy: (field: string, value: string) => void;
mono?: boolean;
highlight?: 'you';
}) {
const isCopied = copied === field;
return (
<Pressable
onPress={() => onCopy(field, rawValue)}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: pressed ? '#0f0f0f' : 'transparent',
})}
>
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
<Text
style={{
color: isCopied
? '#3ba55d'
: highlight === 'you'
? '#1d9bf0'
: '#ffffff',
fontSize: 13,
fontFamily: mono ? 'monospace' : undefined,
fontWeight: '600',
marginRight: 8,
}}
numberOfLines={1}
>
{isCopied
? 'Copied'
: highlight === 'you'
? `${value} (you)`
: value}
</Text>
<Ionicons
name={isCopied ? 'checkmark' : 'copy-outline'}
size={13}
color={isCopied ? '#3ba55d' : '#6a6a6a'}
/>
</Pressable>
);
}

View File

@@ -0,0 +1,19 @@
/**
* Tx detail layout — native Stack so router.back() pops back to the
* screen that pushed us (wallet history, chat tx link, etc.) instead
* of falling through to the outer Slot-level root.
*/
import React from 'react';
import { Stack } from 'expo-router';
export default function TxLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#000000' },
animation: 'slide_from_right',
}}
/>
);
}

View File

@@ -17,6 +17,7 @@ import {
View, Text, ScrollView, Modal, Alert, RefreshControl, Pressable, TextInput, ActivityIndicator, View, Text, ScrollView, Modal, Alert, RefreshControl, Pressable, TextInput, ActivityIndicator,
} from 'react-native'; } from 'react-native';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -346,7 +347,7 @@ function TxTile({
const color = toneColor(m.tone); const color = toneColor(m.tone);
return ( return (
<Pressable> <Pressable onPress={() => router.push(`/(app)/tx/${tx.hash}` as never)}>
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',

View File

@@ -10,6 +10,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { generateKeyFile } from '@/lib/crypto'; import { generateKeyFile } from '@/lib/crypto';
import { saveKeyFile } from '@/lib/storage'; import { saveKeyFile } from '@/lib/storage';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { safeBack } from '@/lib/utils';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton'; import { IconButton } from '@/components/IconButton';
@@ -37,7 +38,7 @@ export default function CreateAccountScreen() {
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header <Header
title="Create account" title="Create account"
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
/> />
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}> <ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700', marginBottom: 4 }}> <Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700', marginBottom: 4 }}>

View File

@@ -15,6 +15,7 @@ import * as DocumentPicker from 'expo-document-picker';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import { saveKeyFile } from '@/lib/storage'; import { saveKeyFile } from '@/lib/storage';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { safeBack } from '@/lib/utils';
import type { KeyFile } from '@/lib/types'; import type { KeyFile } from '@/lib/types';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
@@ -96,7 +97,7 @@ export default function ImportKeyScreen() {
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header <Header
title="Import key" title="Import key"
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
/> />
<ScrollView <ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: 40 }} contentContainerStyle={{ padding: 14, paddingBottom: 40 }}

View File

@@ -187,17 +187,17 @@ export default function WelcomeScreen() {
<FeatureRow <FeatureRow
icon="lock-closed" icon="lock-closed"
title="End-to-end encryption" title="End-to-end encryption"
text="X25519 + NaCl на каждом сообщении. Даже релей-нода не может прочитать переписку." text="X25519 + NaCl on every message. Not even the relay node can read your conversations."
/> />
<FeatureRow <FeatureRow
icon="key" icon="key"
title="Твои ключи — твой аккаунт" title="Your keys, your account"
text="Без телефона, email и серверных паролей. Ключи никогда не покидают устройство." text="No phone, email, or server passwords. Keys never leave your device."
/> />
<FeatureRow <FeatureRow
icon="git-network" icon="git-network"
title="Decentralised" title="Decentralised"
text="Любой может поднять свою ноду. Нет единой точки отказа и цензуры." text="Anyone can run a node. No single point of failure or censorship."
/> />
</ScrollView> </ScrollView>
@@ -206,7 +206,7 @@ export default function WelcomeScreen() {
flexDirection: 'row', justifyContent: 'flex-end', flexDirection: 'row', justifyContent: 'flex-end',
paddingHorizontal: 24, paddingBottom: 8, paddingHorizontal: 24, paddingBottom: 8,
}}> }}>
<CTAPrimary label="Продолжить" onPress={() => goToPage(1)} /> <CTAPrimary label="Continue" onPress={() => goToPage(1)} />
</View> </View>
</View> </View>
@@ -223,22 +223,22 @@ export default function WelcomeScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5 }}> <Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5 }}>
Как это работает How it works
</Text> </Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, marginBottom: 22 }}> <Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, marginBottom: 22 }}>
Сообщения проходят через релей-ноду в зашифрованном виде. Messages travel through a relay node in encrypted form.
Выбери публичную или подключи свою. Pick a public one or run your own.
</Text> </Text>
<OptionCard <OptionCard
icon="globe" icon="globe"
title="Публичная нода" title="Public node"
text="Удобно и быстро — нода хостится комьюнити, небольшая комиссия за каждое отправленное сообщение." text="Quick and easy — community-hosted relay, small fee per delivered message."
/> />
<OptionCard <OptionCard
icon="hardware-chip" icon="hardware-chip"
title="Своя нода" title="Self-hosted"
text="Максимальный контроль. Исходники открыты — подними на своём сервере за 5 минут." text="Maximum control. Source is open — spin up your own in five minutes."
/> />
<Text style={{ <Text style={{
@@ -302,11 +302,11 @@ export default function WelcomeScreen() {
paddingHorizontal: 24, paddingBottom: 8, paddingHorizontal: 24, paddingBottom: 8,
}}> }}>
<CTASecondary <CTASecondary
label="Исходники" label="Source"
icon="logo-github" icon="logo-github"
onPress={() => Linking.openURL(GITEA_URL).catch(() => {})} onPress={() => Linking.openURL(GITEA_URL).catch(() => {})}
/> />
<CTAPrimary label="Продолжить" onPress={() => goToPage(2)} /> <CTAPrimary label="Continue" onPress={() => goToPage(2)} />
</View> </View>
</View> </View>
@@ -334,11 +334,11 @@ export default function WelcomeScreen() {
<Ionicons name="key" size={44} color="#1d9bf0" /> <Ionicons name="key" size={44} color="#1d9bf0" />
</View> </View>
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5, textAlign: 'center' }}> <Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5, textAlign: 'center' }}>
Твой аккаунт Your account
</Text> </Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, textAlign: 'center', maxWidth: 280 }}> <Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, textAlign: 'center', maxWidth: 280 }}>
Создай новую пару ключей или импортируй существующую. Generate a fresh keypair or import an existing one.
Ключи хранятся только на этом устройстве. Keys stay on this device only.
</Text> </Text>
</View> </View>
</ScrollView> </ScrollView>
@@ -349,11 +349,11 @@ export default function WelcomeScreen() {
paddingHorizontal: 24, paddingBottom: 8, paddingHorizontal: 24, paddingBottom: 8,
}}> }}>
<CTASecondary <CTASecondary
label="Импорт" label="Import"
onPress={() => router.push('/(auth)/import' as never)} onPress={() => router.push('/(auth)/import' as never)}
/> />
<CTAPrimary <CTAPrimary
label="Создать аккаунт" label="Create account"
onPress={() => router.push('/(auth)/create' as never)} onPress={() => router.push('/(auth)/create' as never)}
/> />
</View> </View>

View File

@@ -6,6 +6,7 @@
*/ */
import React from 'react'; import React from 'react';
import { View, Text } from 'react-native'; import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export interface AvatarProps { export interface AvatarProps {
/** Имя / @username — берём первый символ для placeholder. */ /** Имя / @username — берём первый символ для placeholder. */
@@ -18,6 +19,11 @@ export interface AvatarProps {
dotColor?: string; dotColor?: string;
/** Класс для обёртки (position: relative кадр). */ /** Класс для обёртки (position: relative кадр). */
className?: string; className?: string;
/**
* Saved Messages variant — blue circle with a bookmark glyph, Telegram-style.
* When set, `name`/`address` are ignored for the visual.
*/
saved?: boolean;
} }
/** Простое хэширование имени → один из 6 оттенков серого для разнообразия. */ /** Простое хэширование имени → один из 6 оттенков серого для разнообразия. */
@@ -28,10 +34,10 @@ function pickBg(seed: string): string {
return shades[h % shades.length]; return shades[h % shades.length];
} }
export function Avatar({ name, address, size = 48, dotColor, className }: AvatarProps) { export function Avatar({ name, address, size = 48, dotColor, className, saved }: AvatarProps) {
const seed = (name ?? address ?? '?').replace(/^@/, ''); const seed = (name ?? address ?? '?').replace(/^@/, '');
const initial = seed.charAt(0).toUpperCase() || '?'; const initial = seed.charAt(0).toUpperCase() || '?';
const bg = pickBg(seed); const bg = saved ? '#1d9bf0' : pickBg(seed);
return ( return (
<View className={className} style={{ width: size, height: size, position: 'relative' }}> <View className={className} style={{ width: size, height: size, position: 'relative' }}>
@@ -45,16 +51,20 @@ export function Avatar({ name, address, size = 48, dotColor, className }: Avatar
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
<Text {saved ? (
style={{ <Ionicons name="bookmark" size={size * 0.5} color="#ffffff" />
color: '#d0d0d0', ) : (
fontSize: size * 0.4, <Text
fontWeight: '600', style={{
includeFontPadding: false, color: '#d0d0d0',
}} fontSize: size * 0.4,
> fontWeight: '600',
{initial} includeFontPadding: false,
</Text> }}
>
{initial}
</Text>
)}
</View> </View>
{dotColor && ( {dotColor && (
<View <View

View File

@@ -57,10 +57,12 @@ export interface ChatTileProps {
contact: Contact; contact: Contact;
lastMessage: Message | null; lastMessage: Message | null;
onPress: () => void; onPress: () => void;
/** Render as the Saved Messages tile (blue bookmark avatar, fixed name). */
saved?: boolean;
} }
export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) { export function ChatTile({ contact: c, lastMessage, onPress, saved }: ChatTileProps) {
const name = displayName(c); const name = saved ? 'Saved Messages' : displayName(c);
const last = lastMessage; const last = lastMessage;
// Визуальный маркер типа чата. // Визуальный маркер типа чата.
@@ -92,7 +94,8 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
name={name} name={name}
address={c.address} address={c.address}
size={44} size={44}
dotColor={c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined} saved={saved}
dotColor={!saved && c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined}
/> />
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}> <View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
@@ -143,9 +146,11 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
> >
{last {last
? lastPreview(last) ? lastPreview(last)
: c.x25519Pub : saved
? 'Tap to start encrypted chat' ? 'Your personal notes & files'
: 'Waiting for identity…'} : c.x25519Pub
? 'Tap to start encrypted chat'
: 'Waiting for identity…'}
</Text> </Text>
{unread !== null && ( {unread !== null && (

View File

@@ -1,12 +1,11 @@
/** /**
* SearchBar — серый блок, в состоянии idle текст с иконкой 🔍 отцентрированы. * SearchBar — single-TextInput pill. Icon + input в одном ряду, без
* * idle/focused двойного состояния (раньше был хак с невидимым
* Когда пользователь тапает/фокусирует — поле становится input-friendly, но * TextInput поверх отцентрированного Text — ломал focus и выравнивание
* визуально рестайл не нужен: при наличии текста placeholder скрыт и * на Android).
* пользовательский ввод выравнивается влево автоматически (multiline off).
*/ */
import React, { useState } from 'react'; import React from 'react';
import { View, TextInput, Text } from 'react-native'; import { View, TextInput, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
export interface SearchBarProps { export interface SearchBarProps {
@@ -15,73 +14,55 @@ export interface SearchBarProps {
placeholder?: string; placeholder?: string;
autoFocus?: boolean; autoFocus?: boolean;
onSubmitEditing?: () => void; onSubmitEditing?: () => void;
onClear?: () => void;
} }
export function SearchBar({ export function SearchBar({
value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing, value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing, onClear,
}: SearchBarProps) { }: SearchBarProps) {
const [focused, setFocused] = useState(false);
// Placeholder центрируется пока нет фокуса И нет значения.
// Как только юзер фокусируется или начинает печатать — иконка+текст
// прыгают к левому краю, чтобы не мешать вводу.
const centered = !focused && !value;
return ( return (
<View <View
style={{ style={{
backgroundColor: '#1a1a1a', flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#111111',
borderWidth: 1,
borderColor: '#1f1f1f',
borderRadius: 999, borderRadius: 999,
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 9, gap: 8,
minHeight: 36,
justifyContent: 'center',
}} }}
> >
{centered ? ( <Ionicons name="search" size={16} color="#6a6a6a" />
// ── Idle state — только текст+icon, отцентрированы. <TextInput
// Невидимый TextInput поверх ловит tap, чтобы не дергать focus вручную. value={value}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}> onChangeText={onChangeText}
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 6 }} /> placeholder={placeholder}
<Text style={{ color: '#8b8b8b', fontSize: 14 }}>{placeholder}</Text> placeholderTextColor="#5a5a5a"
<TextInput autoCapitalize="none"
value={value} autoCorrect={false}
onChangeText={onChangeText} autoFocus={autoFocus}
autoFocus={autoFocus} onSubmitEditing={onSubmitEditing}
onFocus={() => setFocused(true)} returnKeyType="search"
onSubmitEditing={onSubmitEditing} style={{
returnKeyType="search" flex: 1,
style={{ color: '#ffffff',
position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, fontSize: 14,
color: 'transparent', paddingVertical: 10,
// Скрываем cursor в idle-режиме; при focus компонент перерисуется. padding: 0,
}} includeFontPadding: false,
/> }}
</View> />
) : ( {value.length > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center' }}> <Pressable
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 8 }} /> onPress={() => {
<TextInput onChangeText('');
value={value} onClear?.();
onChangeText={onChangeText} }}
placeholder={placeholder} hitSlop={8}
placeholderTextColor="#8b8b8b" >
autoCapitalize="none" <Ionicons name="close-circle" size={16} color="#6a6a6a" />
autoCorrect={false} </Pressable>
autoFocus
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onSubmitEditing={onSubmitEditing}
returnKeyType="search"
style={{
flex: 1,
color: '#ffffff',
fontSize: 14,
padding: 0,
includeFontPadding: false,
}}
/>
</View>
)} )}
</View> </View>
); );

View File

@@ -90,7 +90,7 @@ export function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefC
letterSpacing: 1.2, letterSpacing: 1.2,
}} }}
> >
ПОСТ POST
</Text> </Text>
</View> </View>
@@ -132,7 +132,7 @@ export function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefC
> >
<Ionicons name="image-outline" size={11} color={subColor} /> <Ionicons name="image-outline" size={11} color={subColor} />
<Text style={{ color: subColor, fontSize: 11 }}> <Text style={{ color: subColor, fontSize: 11 }}>
с фото photo
</Text> </Text>
</View> </View>
)} )}

View File

@@ -80,11 +80,16 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
// Find a display-friendly name for the author. If it's a known contact // Find a display-friendly name for the author. If it's a known contact
// with @username, use that; otherwise short-addr. // with @username, use that; otherwise short-addr.
//
// `mine` takes precedence over the contact lookup: our own pub key has
// a self-contact entry with alias "Saved Messages" (that's how the
// self-chat tile is rendered), but that label is wrong in the feed —
// posts there should read as "You", not as a messaging-app affordance.
const displayName = useMemo(() => { const displayName = useMemo(() => {
if (mine) return 'You';
const c = contacts.find(x => x.address === post.author); const c = contacts.find(x => x.address === post.author);
if (c?.username) return `@${c.username}`; if (c?.username) return `@${c.username}`;
if (c?.alias) return c.alias; if (c?.alias) return c.alias;
if (mine) return 'You';
return shortAddr(post.author); return shortAddr(post.author);
}, [contacts, post.author, mine]); }, [contacts, post.author, mine]);
@@ -109,7 +114,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
// Roll back optimistic update. // Roll back optimistic update.
setLocalLiked(wasLiked); setLocalLiked(wasLiked);
setLocalLikeCount(c => c + (wasLiked ? 1 : -1)); setLocalLikeCount(c => c + (wasLiked ? 1 : -1));
Alert.alert('Не удалось', String(e?.message ?? e)); Alert.alert('Failed', String(e?.message ?? e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -128,13 +133,13 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
const options: Array<{ label: string; destructive?: boolean; onPress: () => void }> = []; const options: Array<{ label: string; destructive?: boolean; onPress: () => void }> = [];
if (mine) { if (mine) {
options.push({ options.push({
label: 'Удалить пост', label: 'Delete post',
destructive: true, destructive: true,
onPress: () => { onPress: () => {
Alert.alert('Удалить пост?', 'Это действие нельзя отменить.', [ Alert.alert('Delete post?', 'This action cannot be undone.', [
{ text: 'Отмена', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
{ {
text: 'Удалить', text: 'Delete',
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
try { try {
@@ -145,7 +150,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
}); });
onDeleted?.(post.post_id); onDeleted?.(post.post_id);
} catch (e: any) { } catch (e: any) {
Alert.alert('Ошибка', String(e?.message ?? e)); Alert.alert('Error', String(e?.message ?? e));
} }
}, },
}, },
@@ -160,9 +165,9 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
style: (o.destructive ? 'destructive' : 'default') as 'default' | 'destructive', style: (o.destructive ? 'destructive' : 'default') as 'default' | 'destructive',
onPress: o.onPress, onPress: o.onPress,
})), })),
{ text: 'Отмена', style: 'cancel' as const }, { text: 'Cancel', style: 'cancel' as const },
]; ];
Alert.alert('Действия', '', buttons); Alert.alert('Actions', '', buttons);
}, [keyFile, mine, post.post_id, onDeleted]); }, [keyFile, mine, post.post_id, onDeleted]);
// Attachment preview URL — native Image can stream straight from the // Attachment preview URL — native Image can stream straight from the

View File

@@ -74,14 +74,14 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
post, contacts: targets, keyFile, post, contacts: targets, keyFile,
}); });
if (failed > 0) { if (failed > 0) {
Alert.alert('Готово', `Отправлено в ${ok} из ${ok + failed} чат${plural(ok + failed)}.`); Alert.alert('Done', `Sent to ${ok} of ${ok + failed} ${plural(ok + failed)}.`);
} }
// Close + reset regardless — done is done. // Close + reset regardless — done is done.
setPicked(new Set()); setPicked(new Set());
setQuery(''); setQuery('');
onClose(); onClose();
} catch (e: any) { } catch (e: any) {
Alert.alert('Не удалось', String(e?.message ?? e)); Alert.alert('Failed', String(e?.message ?? e));
} finally { } finally {
setSending(false); setSending(false);
} }
@@ -136,7 +136,7 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
paddingHorizontal: 16, marginBottom: 10, paddingHorizontal: 16, marginBottom: 10,
}}> }}>
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}> <Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}>
Поделиться постом Share post
</Text> </Text>
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
<Pressable onPress={closeAndReset} hitSlop={8}> <Pressable onPress={closeAndReset} hitSlop={8}>
@@ -158,7 +158,7 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
<TextInput <TextInput
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
placeholder="Поиск по контактам" placeholder="Search contacts"
placeholderTextColor="#5a5a5a" placeholderTextColor="#5a5a5a"
style={{ style={{
flex: 1, flex: 1,
@@ -196,8 +196,8 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
<Ionicons name="people-outline" size={28} color="#5a5a5a" /> <Ionicons name="people-outline" size={28} color="#5a5a5a" />
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 10 }}> <Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 10 }}>
{query.length > 0 {query.length > 0
? 'Нет контактов по такому запросу' ? 'No contacts match this search'
: 'Контакты с ключами шифрования отсутствуют'} : 'No contacts with encryption keys yet'}
</Text> </Text>
</View> </View>
} }
@@ -227,8 +227,8 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
fontSize: 14, fontSize: 14,
}}> }}>
{picked.size === 0 {picked.size === 0
? 'Выберите контакты' ? 'Select contacts'
: `Отправить (${picked.size})`} : `Send (${picked.size})`}
</Text> </Text>
)} )}
</Pressable> </Pressable>
@@ -298,10 +298,5 @@ function shortAddr(a: string, n = 6): string {
} }
function plural(n: number): string { function plural(n: number): string {
const mod100 = n % 100; return n === 1 ? 'chat' : 'chats';
const mod10 = n % 10;
if (mod100 >= 11 && mod100 <= 19) return 'ов';
if (mod10 === 1) return '';
if (mod10 >= 2 && mod10 <= 4) return 'а';
return 'ов';
} }

View File

@@ -79,23 +79,23 @@ async function post<T>(path: string, body: unknown): Promise<T> {
export function humanizeTxError(e: unknown): string { export function humanizeTxError(e: unknown): string {
const raw = e instanceof Error ? e.message : String(e); const raw = e instanceof Error ? e.message : String(e);
if (raw.startsWith('429')) { if (raw.startsWith('429')) {
return 'Слишком много запросов к ноде. Подождите пару секунд и попробуйте снова.'; return 'Too many requests to the node. Wait a couple of seconds and try again.';
} }
if (raw.startsWith('400') && raw.includes('timestamp')) { if (raw.startsWith('400') && raw.includes('timestamp')) {
return 'Часы устройства не синхронизированы с нодой. Проверьте время на телефоне (±1 час).'; return 'Device clock is out of sync with the node. Check the time on your phone (±1 hour).';
} }
if (raw.startsWith('400') && raw.includes('signature')) { if (raw.startsWith('400') && raw.includes('signature')) {
return 'Подпись транзакции невалидна. Попробуйте ещё раз; если не помогает — вероятна несовместимость версий клиента и ноды.'; return 'Transaction signature is invalid. Try again; if this persists the client and node versions may be incompatible.';
} }
if (raw.startsWith('400')) { if (raw.startsWith('400')) {
return `Нода отклонила транзакцию: ${raw.replace(/^400:\s*/, '')}`; return `Node rejected transaction: ${raw.replace(/^400:\s*/, '')}`;
} }
if (raw.startsWith('5')) { if (raw.startsWith('5')) {
return `Ошибка ноды (${raw}). Попробуйте позже.`; return `Node error (${raw}). Please try again later.`;
} }
// Network-level // Network-level
if (raw.toLowerCase().includes('network request failed')) { if (raw.toLowerCase().includes('network request failed')) {
return 'Нет связи с нодой. Проверьте URL в настройках и доступность сервера.'; return 'Cannot reach the node. Check the URL in settings and that the server is online.';
} }
return raw; return raw;
} }
@@ -191,6 +191,43 @@ export async function submitTx(tx: RawTx): Promise<{ id: string; status: string
} }
} }
/**
* Full transaction detail as returned by GET /api/tx/{id}. Matches the
* explorer's txDetail wire format. Payload is JSON-decoded when the
* node recognises the tx type, otherwise payload_hex is set.
*/
export interface TxDetail {
id: string;
type: string;
memo?: string;
from: string;
from_addr?: string;
to?: string;
to_addr?: string;
amount_ut: number;
amount: string;
fee_ut: number;
fee: string;
time: string; // ISO-8601 UTC
block_index: number;
block_hash: string;
block_time: string; // ISO-8601 UTC
gas_used?: number;
payload?: unknown;
payload_hex?: string;
signature_hex?: string;
}
/** Fetch full tx detail by hash/id. Returns null on 404. */
export async function getTxDetail(txID: string): Promise<TxDetail | null> {
try {
return await get<TxDetail>(`/api/tx/${txID}`);
} catch (e: any) {
if (/→\s*404\b/.test(String(e?.message))) return null;
throw e;
}
}
export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord[]> { export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord[]> {
const data = await get<AddrResponse>(`/api/address/${pubkey}?limit=${limit}`); const data = await get<AddrResponse>(`/api/address/${pubkey}?limit=${limit}`);
return (data.transactions ?? []).map(tx => ({ return (data.transactions ?? []).map(tx => ({
@@ -302,7 +339,7 @@ export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
// ─── Contact requests (on-chain) ───────────────────────────────────────────── // ─── Contact requests (on-chain) ─────────────────────────────────────────────
/** /**
* Maps blockchain.ContactInfo returned by GET /api/relay/contacts?pub=... * Maps blockchain.ContactInfo returned by GET /relay/contacts?pub=...
* The response shape is { pub, count, contacts: ContactInfo[] }. * The response shape is { pub, count, contacts: ContactInfo[] }.
*/ */
export interface ContactRequestRaw { export interface ContactRequestRaw {
@@ -316,7 +353,7 @@ export interface ContactRequestRaw {
} }
export async function fetchContactRequests(edPubHex: string): Promise<ContactRequestRaw[]> { export async function fetchContactRequests(edPubHex: string): Promise<ContactRequestRaw[]> {
const data = await get<{ contacts: ContactRequestRaw[] }>(`/api/relay/contacts?pub=${edPubHex}`); const data = await get<{ contacts: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPubHex}`);
return data.contacts ?? []; return data.contacts ?? [];
} }
@@ -330,6 +367,39 @@ export interface IdentityInfo {
registered: boolean; registered: boolean;
} }
/**
* Relay registration info for a node pub key, as returned by
* /api/relays (which comes back as an array of RegisteredRelayInfo).
* We don't wrap the individual lookup on the server — just filter the
* full list client-side. It's bounded (N nodes in the network) and
* cached heavily enough that this is cheaper than a new endpoint.
*/
export interface RegisteredRelayInfo {
pub_key: string;
address: string;
relay: {
x25519_pub_key: string;
fee_per_msg_ut: number;
multiaddr?: string;
};
last_heartbeat?: number; // unix seconds
}
/** GET /api/relays — all relay nodes registered on-chain. */
export async function getRelays(): Promise<RegisteredRelayInfo[]> {
try {
return await get<RegisteredRelayInfo[]>('/api/relays');
} catch {
return [];
}
}
/** Find relay entry for a specific pub key. null if the address isn't a relay. */
export async function getRelayFor(pubKey: string): Promise<RegisteredRelayInfo | null> {
const all = await getRelays();
return all.find(r => r.pub_key === pubKey) ?? null;
}
/** Fetch identity info for any pubkey or DC address. Returns null on 404. */ /** Fetch identity info for any pubkey or DC address. Returns null on 404. */
export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> { export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> {
try { try {

View File

@@ -5,10 +5,10 @@
* который тоже в секундах). Для ms-таймштампов делаем нормализацию внутри. * который тоже в секундах). Для ms-таймштампов делаем нормализацию внутри.
*/ */
// ─── Русские месяцы (genitive для "17 июня 2025") ──────────────────────────── // English short month names ("Jun 17, 2025").
const RU_MONTHS_GEN = [ const MONTHS_SHORT = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
]; ];
function sameDay(a: Date, b: Date): boolean { function sameDay(a: Date, b: Date): boolean {
@@ -20,8 +20,8 @@ function sameDay(a: Date, b: Date): boolean {
} }
/** /**
* Day-bucket label для сепараторов внутри чата. * Day-bucket label for chat separators.
* "Сегодня" / "Вчера" / "17 июня 2025" * "Today" / "Yesterday" / "Jun 17, 2025"
* *
* @param ts unix-seconds * @param ts unix-seconds
*/ */
@@ -29,9 +29,9 @@ export function dateBucket(ts: number): string {
const d = new Date(ts * 1000); const d = new Date(ts * 1000);
const now = new Date(); const now = new Date();
const yday = new Date(); yday.setDate(now.getDate() - 1); const yday = new Date(); yday.setDate(now.getDate() - 1);
if (sameDay(d, now)) return 'Сегодня'; if (sameDay(d, now)) return 'Today';
if (sameDay(d, yday)) return 'Вчера'; if (sameDay(d, yday)) return 'Yesterday';
return `${d.getDate()} ${RU_MONTHS_GEN[d.getMonth()]} ${d.getFullYear()}`; return `${MONTHS_SHORT[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
} }
/** /**

View File

@@ -1,444 +0,0 @@
/**
* Dev seed — заполняет store фейковыми контактами и сообщениями для UI-теста.
*
* Запускается один раз при монтировании layout'а если store пустой
* (useDevSeed). Реальные контакты через WS/HTTP приходят позже —
* `upsertContact` перезаписывает mock'и по address'у.
*
* Цели seed'а:
* 1. Показать все три типа чатов (direct / group / channel) с разным
* поведением sender-meta.
* 2. Наполнить список чатов до скролла (15+ контактов).
* 3. В каждом чате — ≥15 сообщений для скролла в chat view.
* 4. Продемонстрировать "staircase" (run'ы одного отправителя
* внутри 1h-окна) и переключения между отправителями.
*/
import { useEffect } from 'react';
import { useStore } from './store';
import type { Contact, Message } from './types';
// ─── Детерминированные «pubkey» (64 hex символа) ───────────────────
function fakeHex(seed: number): string {
let h = '';
let x = seed;
for (let i = 0; i < 32; i++) {
x = (x * 1103515245 + 12345) & 0xffffffff;
h += (x & 0xff).toString(16).padStart(2, '0');
}
return h;
}
const now = () => Math.floor(Date.now() / 1000);
const MINE = fakeHex(9999);
// ─── Контакты ──────────────────────────────────────────────────────
// 16 штук: 5 DM + 6 групп + 5 каналов. Поле `addedAt` задаёт порядок в
// списке когда нет messages — ordering-fallback.
const mockContacts: Contact[] = [
// ── DM ──────────────────────────────────────────────────────────
{ address: fakeHex(1001), x25519Pub: fakeHex(2001),
username: 'jordan', addedAt: Date.now() - 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1002), x25519Pub: fakeHex(2002),
alias: 'Myles Wagner', addedAt: Date.now() - 2 * 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1010), x25519Pub: fakeHex(2010),
username: 'sarah_k', addedAt: Date.now() - 3 * 60 * 60 * 1_000, kind: 'direct',
unread: 2 },
{ address: fakeHex(1011), x25519Pub: fakeHex(2011),
alias: 'Mom', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1012), x25519Pub: fakeHex(2012),
username: 'alex_dev', addedAt: Date.now() - 6 * 60 * 60 * 1_000, kind: 'direct' },
// ── Groups ─────────────────────────────────────────────────────
{ address: fakeHex(1003), x25519Pub: fakeHex(2003),
alias: 'Tahoe weekend 🌲', addedAt: Date.now() - 4 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1004), x25519Pub: fakeHex(2004),
alias: 'Knicks tickets', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'group',
unread: 3 },
{ address: fakeHex(1020), x25519Pub: fakeHex(2020),
alias: 'Family', addedAt: Date.now() - 8 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1021), x25519Pub: fakeHex(2021),
alias: 'Work eng', addedAt: Date.now() - 12 * 60 * 60 * 1_000, kind: 'group',
unread: 7 },
{ address: fakeHex(1022), x25519Pub: fakeHex(2022),
alias: 'Book club', addedAt: Date.now() - 24 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1023), x25519Pub: fakeHex(2023),
alias: 'Tuesday D&D 🎲', addedAt: Date.now() - 30 * 60 * 60 * 1_000, kind: 'group' },
// (Channel seeds removed in v2.0.0 — channels replaced by the social feed.)
];
// ─── Генератор сообщений ───────────────────────────────────────────
// Альт-отправители для group-чатов — нужны только как идентификатор `from`.
const P_TYRA = fakeHex(3001);
const P_MYLES = fakeHex(3002);
const P_NATE = fakeHex(3003);
const P_TYLER = fakeHex(3004);
const P_MOM = fakeHex(3005);
const P_DAD = fakeHex(3006);
const P_SIS = fakeHex(3007);
const P_LEAD = fakeHex(3008);
const P_PM = fakeHex(3009);
const P_QA = fakeHex(3010);
const P_DESIGN= fakeHex(3011);
const P_ANNA = fakeHex(3012);
const P_DM_PEER = fakeHex(3013);
type Msg = Omit<Message, 'id'>;
function list(prefix: string, list: Msg[]): Message[] {
return list.map((m, i) => ({ ...m, id: `${prefix}_${i}` }));
}
function mockMessagesFor(contact: Contact): Message[] {
const peer = contact.x25519Pub;
// ── DM: @jordan ────────────────────────────────────────────────
if (contact.username === 'jordan') {
// Важно: id'ы сообщений используются в replyTo.id, поэтому
// указываем их явно где нужно сшить thread.
const msgs: Message[] = list('jordan', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 22, text: 'Hey, have a sec later today?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 21, read: true, text: 'yep around 4pm' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'cool, coffee at the corner spot?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 19, read: true, text: 'works' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'just parked 🚗' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'see you in 5' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 3, read: true, text: "that was a great catchup" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: "totally — thanks for the book rec" },
{ from: peer, mine: false, timestamp: now() - 60 * 40, text: 'Hey Jordan - Got tickets to the Knicks game tomorrow, let me know if you want to come!' },
{ from: peer, mine: false, timestamp: now() - 60 * 39, text: "we've got floor seats 🔥" },
{ from: peer, mine: false, timestamp: now() - 60 * 38, text: "starts at 7, pregame at the bar across the street" },
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: 'Ah sadly I already have plans' },
{ from: MINE, mine: true, timestamp: now() - 60 * 13, read: true, text: 'maybe next time?' },
{ from: peer, mine: false, timestamp: now() - 60 * 5, text: "no worries — enjoy whatever you're up to" },
{ from: peer, mine: false, timestamp: now() - 60 * 2, text: "wish you could make it tho 🏀" },
]);
// Пришьём reply: MINE-сообщение "Ah sadly…" отвечает на "Hey Jordan - Got tickets…"
const target = msgs.find(m => m.text.startsWith('Hey Jordan - Got tickets'));
const mine = msgs.find(m => m.text === 'Ah sadly I already have plans');
if (target && mine) {
mine.replyTo = {
id: target.id,
author: '@jordan',
text: target.text,
};
}
return msgs;
}
// ── DM: Myles Wagner ───────────────────────────────────────────
if (contact.alias === 'Myles Wagner') {
return list('myles', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'saw the draft, left a bunch of comments' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 29, read: true, text: 'thx, going through them now' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 29, text: 'no rush — tomorrow is fine' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'lunch today?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: "can't, stuck in reviews" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: 'tomorrow?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: '✅ tomorrow' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: '',
attachment: {
kind: 'voice',
uri: 'voice-demo://myles-1',
duration: 17,
},
},
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'the dchain repo finally built for me' },
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'docker weirdness was the issue' },
{ from: MINE, mine: true, timestamp: now() - 60 * 21, read: true, text: "nice, told you the WSL path would do it" },
{ from: peer, mine: false, timestamp: now() - 60 * 20, text: 'So good!' },
]);
}
// ── DM: @sarah_k (с unread=2) ──────────────────────────────────
if (contact.username === 'sarah_k') {
return list('sarah', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: "hey! been a while" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 28, read: true, text: 'yeah, finally surfaced after the launch crunch' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 27, text: 'how did it go?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 27, read: true, text: "pretty well actually 🙏" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'btw drinks on friday?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'that new wine bar' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'around 7 if you can make it' },
]);
}
// ── DM: Mom ────────────────────────────────────────────────────
if (contact.alias === 'Mom') {
return list('mom', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Did you see the photos from the trip?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 47, read: true, text: 'not yet, send them again?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 47, text: 'ok' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: 'wow, grandma looks great' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'she asked about you!' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'call later?' },
]);
}
// ── DM: @alex_dev ──────────────────────────────────────────────
if (contact.username === 'alex_dev') {
return list('alex', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'did you try the new WASM build?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 11, read: true, text: 'yeah, loader error on start' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 11, text: 'path encoding issue again?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 10, read: true, text: 'probably, checking now' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 8, read: true, text: 'yep, was the trailing slash' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 8, text: 'classic 😅' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'PR for that incoming tomorrow' },
]);
}
// ── Group: Tahoe weekend 🌲 ────────────────────────────────────
if (contact.alias === 'Tahoe weekend 🌲') {
const msgs: Message[] = list('tahoe', [
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: "who's in for Tahoe this weekend?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 49, text: "me!" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 48, read: true, text: "count me in" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: "woohoo 🎉" },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 47, text: "planning friday night → sunday evening yeah?" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 46, text: "yep, maybe leave friday after lunch" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "I made this itinerary with Grok, what do you think?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 1: Eagle Falls hike" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 2: Emerald bay kayak" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 3: lazy breakfast then drive back" },
{
from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: '',
attachment: {
kind: 'file',
uri: 'https://example.com/Lake_Tahoe_Itinerary.pdf',
name: 'Lake_Tahoe_Itinerary.pdf',
size: 97_280, // ~95 KB
mime: 'application/pdf',
},
},
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 24, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "Eagle falls was stunning last year!" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 31, text: "who's excited for Tahoe this weekend?" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 30, text: "I've been checking the forecast — sun all weekend 🌞" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 22, text: "I made this itinerary with Grok, what do you think?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 21, text: "Day 1 we can hit Eagle Falls" },
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
{
from: P_TYRA, mine: false, timestamp: now() - 60 * 3, text: 'pic from my last trip 😍',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1505245208761-ba872912fac0?w=800',
width: 800,
height: 1000,
mime: 'image/jpeg',
},
},
]);
// Thread: mine "Love it — Eagle falls looks insane" — ответ на
// Myles'овский itinerary-PDF. Берём ПЕРВЫЙ match "Day 1 we can hit
// Eagle Falls" и пришиваем его к первому mine-bubble'у.
const target = msgs.find(m => m.text === 'Day 1 we can hit Eagle Falls');
const reply = msgs.find(m => m.text === 'Love it — Eagle falls looks insane' && m.mine);
if (target && reply) {
reply.replyTo = {
id: target.id,
author: 'Myles Wagner',
text: target.text,
};
}
return msgs;
}
// ── Group: Knicks tickets ──────────────────────────────────────
if (contact.alias === 'Knicks tickets') {
return list('knicks', [
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 20, text: "quick group update — got 5 tickets for thursday" },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'wow nice' },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'where are we seated?' },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 19, text: 'section 102, row 12' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 18, read: true, text: 'thats a great spot' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 18, text: "can someone venmo nate 🙏" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 17, read: true, text: 'sending now' },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 32, text: "Ok who's in for tomorrow's game?" },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 31, text: 'Got 2 extra tickets, first-come-first-served' },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 27, text: "I'm in!" },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 26, text: 'What time does it start?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 20, read: true, text: "Let's meet at the bar around 6?" },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 15, text: 'Sounds good' },
]);
}
// ── Group: Family ──────────────────────────────────────────────
if (contact.alias === 'Family') {
return list('family', [
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 36, text: 'remember grandma birthday on sunday' },
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 36, text: 'noted 🎂' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 35, text: 'who is bringing the cake?' },
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 35, text: "I'll get it from the bakery" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 34, read: true, text: 'I can pick up flowers' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 34, text: 'perfect' },
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 8, text: 'forecast is rain sunday — backup plan?' },
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 8, text: "we'll move indoors, the living room works" },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 7, text: 'works!' },
]);
}
// ── Group: Work eng (unread=7) ─────────────────────────────────
if (contact.alias === 'Work eng') {
return list('work', [
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 16, text: 'standup at 10 moved to 11 today btw' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 16, text: 'thanks!' },
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 15, text: "the staging deploy broke again 🙃" },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 15, text: "ugh, looking" },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 14, text: 'fixed — migration was stuck' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 13, read: true, text: 'Worked for me now 👍' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 5, text: 'reminder: demo tomorrow, slides by eod' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Ill handle the technical half' },
{ from: P_DESIGN,mine: false,timestamp: now() - 60 * 60 * 4, text: 'just posted the v2 mocks in figma' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 3, text: 'chatting with sales — 3 new trials this week' },
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 2, text: 'flaky test on CI — investigating' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 30, text: 'okay seems like CI is green now' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 28, text: 'retry passed' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 20, text: "we're good for release" },
]);
}
// ── Group: Book club ───────────────────────────────────────────
if (contact.alias === 'Book club') {
return list('book', [
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 96, text: 'next month: "Project Hail Mary"?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 95, read: true, text: '👍' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 94, text: 'yes please' },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'halfway through — so good' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'love the linguistics angle' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: "rocky is my favourite character in years" },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 28, text: 'agreed' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "let's meet sunday 4pm?" },
]);
}
// ── Group: Tuesday D&D 🎲 ──────────────────────────────────────
if (contact.alias === 'Tuesday D&D 🎲') {
return list('dnd', [
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 72, text: 'Session 14 recap up on the wiki' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 72, text: '🙏' },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: 'can we start 30min late next tuesday? commute issue' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 50, text: 'sure' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 49, read: true, text: 'works for me' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 32, text: 'we pick up where we left — in the dragons cave' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 32, text: 'excited 🐉' },
]);
}
// ── Channel: dchain_updates ────────────────────────────────────
if (contact.username === 'dchain_updates') {
return list('dchain_updates', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 96, text: '🔨 v0.0.1-alpha tagged on Gitea' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 72, text: 'PBFT equivocation-detection тесты зелёные' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'New: /api/peers теперь включает peer-version info' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: '📘 Docs overhaul merged: docs/README.md' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 36, text: 'Schema migration scaffold landed (no-op для текущей версии)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: '🚀 v0.0.1 released' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 10, text: 'Includes: auto-update from Gitea, peer-version gossip, schema migrations' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 20, text: 'Check /api/well-known-version for the full feature list' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'Thanks to all testers — feedback drives the roadmap 🙏' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'v0.0.2 roadmap published: https://git.vsecoder.vodka/vsecoder/dchain' },
{ from: peer, mine: false, timestamp: now() - 60 * 30, text: 'quick heads-up: nightly builds switching to new docker-slim base' },
]);
}
// ── Channel: Relay broadcasts ──────────────────────────────────
if (contact.alias === '⚡ Relay broadcasts') {
return list('relay_bc', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Relay fleet snapshot: 12 active, 3 inactive' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Relay #3 came online in US-east' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Validator set updated: 3→4' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'PBFT view-change детектирован и отработан на блоке 184120' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 15, text: 'Mailbox eviction ran — 42 stale envelopes' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'Relay #8 slashed for equivocation — evidence at block 184202' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'Relay #12 came online in EU-west, registering now…' },
]);
}
// ── Channel: Tech news ────────────────────────────────────────
if (contact.alias === '📰 Tech news') {
return list('tech_news', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Rust 1.78 released — new lints for raw pointers' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Go 1.23 ships range-over-func officially' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Expo SDK 54 drops — new-architecture default' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'CVE-2026-1337 patched in libsodium (update your keys)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Matrix protocol adds post-quantum handshakes' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Data-center aerial view — new hyperscaler in Iceland',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'IETF draft: "DNS-over-blockchain"' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 6, text: 'GitHub tightens 2FA defaults for orgs' },
]);
}
// ── Channel: Design inspo (unread=12) ──────────────────────────
if (contact.alias === '🎨 Design inspo') {
return list('design_inspo', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Weekly pick: Linear UI v3 breakdown' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 140, text: 'Figma file of the week: "Command bar patterns"' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Motion study: Stripe checkout shake-error animation' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: "10 great empty-state illustrations (blogpost)" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Tool: Hatch — colour-palette extractor from photos' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: '🔮 Trend watch: glassmorphism is back (again)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Twitter thread: why rounded buttons are the default' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'Framer templates — black friday sale' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'New typeface: "Grotesk Pro" — free for personal use' },
]);
}
// ── Channel: NBA scores ────────────────────────────────────────
if (contact.alias === '🏀 NBA scores') {
return list('nba', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Lakers 112 — Warriors 108 (OT)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 130, text: 'Celtics 128 — Heat 115' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Nuggets 119 — Thunder 102' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 70, text: "Knicks 101 — Bulls 98" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Mavericks 130 — Kings 127' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 24, text: 'Bucks 114 — Sixers 110' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Live: Lakers leading 78-72 at half' },
]);
}
return [];
}
// ─── Hook ──────────────────────────────────────────────────────────
export function useDevSeed() {
const contacts = useStore(s => s.contacts);
const setContacts = useStore(s => s.setContacts);
const setMessages = useStore(s => s.setMessages);
useEffect(() => {
if (contacts.length > 0) return;
setContacts(mockContacts);
for (const c of mockContacts) {
const msgs = mockMessagesFor(c);
if (msgs.length > 0) setMessages(c.address, msgs);
}
}, [contacts.length, setContacts, setMessages]);
}

View File

@@ -1,180 +0,0 @@
/**
* Dev-only mock posts for the feed.
*
* Why: in __DEV__ before any real posts exist on the node, the timeline/
* for-you/trending tabs come back empty. Empty state is fine visually but
* doesn't let you test scrolling, like animations, view-counter bumps,
* navigation to post detail, etc. This module injects a small set of
* synthetic posts so the UI has something to chew on.
*
* Gating:
* - Only active when __DEV__ === true (stripped from production builds).
* - Only surfaces when the REAL API returns an empty array. If the node
* is returning actual posts, we trust those and skip the mocks.
*
* These posts have made-up post_ids — tapping on them to open detail
* WILL 404 against the real backend. That's intentional — the mock is
* purely for scroll / tap-feedback testing.
*/
import type { FeedPostItem } from './feed';
// Fake hex-like pubkeys so Avatar's colour hash still looks varied.
function fakeAddr(seed: number): string {
const h = (seed * 2654435761).toString(16).padStart(8, '0');
return (h + h + h + h).slice(0, 64);
}
function fakePostID(n: number): string {
return `dev${String(n).padStart(29, '0')}`;
}
const NOW = Math.floor(Date.now() / 1000);
// Small curated pool of posts covering the render surface we care about:
// plain text, hashtag variety, different lengths, likes / views spread,
// reply/quote references, one with an attachment marker.
const SEED_POSTS: FeedPostItem[] = [
{
post_id: fakePostID(1),
author: fakeAddr(1),
content: 'Добро пожаловать в ленту DChain. Это #DEV-посты — они видны только пока реальная лента пустая.',
created_at: NOW - 60,
size: 200,
hosting_relay: fakeAddr(100),
views: 127, likes: 42,
has_attachment: false,
hashtags: ['dev'],
},
{
post_id: fakePostID(2),
author: fakeAddr(2),
content: 'Пробую новую ленту #twitter-style. Лайки, просмотры, подписки — всё on-chain, тела постов — off-chain в mailbox релея.',
created_at: NOW - 540,
size: 310,
hosting_relay: fakeAddr(100),
views: 89, likes: 23,
has_attachment: false,
hashtags: ['twitter'],
},
{
post_id: fakePostID(3),
author: fakeAddr(3),
content: 'Сжатие изображений — максимальное на клиенте (WebP Q=50 @1080p), плюс серверный EXIF-скраб через stdlib re-encode. GPS-координаты из EXIF больше никогда не утекают. #privacy',
created_at: NOW - 1200,
size: 420,
hosting_relay: fakeAddr(100),
views: 312, likes: 78,
has_attachment: true,
hashtags: ['privacy'],
},
{
post_id: fakePostID(4),
author: fakeAddr(4),
content: 'Короткий пост.',
created_at: NOW - 3600,
size: 128,
hosting_relay: fakeAddr(100),
views: 12, likes: 3,
has_attachment: false,
},
{
post_id: fakePostID(5),
author: fakeAddr(1),
content: 'Отвечаю сам себе — фича threads пока через reply_to только, без UI thread-виджета.',
created_at: NOW - 7200,
size: 220,
hosting_relay: fakeAddr(100),
views: 45, likes: 11,
has_attachment: false,
reply_to: fakePostID(1),
},
{
post_id: fakePostID(6),
author: fakeAddr(5),
content: '#golang + #badgerdb + #libp2p = DChain бэкенд. Пять package в test suite, все зелёные.',
created_at: NOW - 10800,
size: 180,
hosting_relay: fakeAddr(100),
views: 201, likes: 66,
has_attachment: false,
hashtags: ['golang', 'badgerdb', 'libp2p'],
},
{
post_id: fakePostID(7),
author: fakeAddr(6),
content: 'Feed-mailbox хранит тела постов до 30 дней (настраиваемо через DCHAIN_FEED_TTL_DAYS). Потом BadgerDB выселяет автоматически — chain-метаданные остаются навсегда.',
created_at: NOW - 14400,
size: 380,
hosting_relay: fakeAddr(100),
views: 156, likes: 48,
has_attachment: false,
},
{
post_id: fakePostID(8),
author: fakeAddr(7),
content: 'Pricing: BasePostFee = 1000 µT (0.001 T) + 1 µT за каждый байт. Уходит владельцу релея, принявшего пост.',
created_at: NOW - 21600,
size: 250,
hosting_relay: fakeAddr(100),
views: 78, likes: 22,
has_attachment: false,
},
{
post_id: fakePostID(9),
author: fakeAddr(8),
content: 'Twitter-like, но без миллиардов долларов на инфраструктуру — каждый оператор ноды платит за свой кусок хостинга и зарабатывает на публикациях. #decentralised #messaging',
created_at: NOW - 43200,
size: 340,
hosting_relay: fakeAddr(100),
views: 412, likes: 103,
has_attachment: false,
hashtags: ['decentralised', 'messaging'],
},
{
post_id: fakePostID(10),
author: fakeAddr(9),
content: 'Короче. Лайк = on-chain tx с fee 1000 µT. Дорого для спама, дёшево для реального лайка. Пока без батчинга, но в плане. #design',
created_at: NOW - 64800,
size: 200,
hosting_relay: fakeAddr(100),
views: 92, likes: 29,
has_attachment: false,
hashtags: ['design'],
},
{
post_id: fakePostID(11),
author: fakeAddr(2),
content: 'Follow граф на chain: двусторонний индекс (forward + inbound), так что Followers() и Following() — оба O(M).',
created_at: NOW - 86400 - 1000,
size: 230,
hosting_relay: fakeAddr(100),
views: 61, likes: 14,
has_attachment: false,
},
{
post_id: fakePostID(12),
author: fakeAddr(10),
content: 'Рекомендации (For You): берём последние 48ч постов, фильтруем подписки + уже лайкнутые + свои, ранжируем по likes × 3 + views. Версия 1 — будет умнее. #recsys',
created_at: NOW - 129600,
size: 290,
hosting_relay: fakeAddr(100),
views: 189, likes: 58,
has_attachment: false,
hashtags: ['recsys'],
},
];
/**
* Returns the dev-seed post list. Only returns actual items in dev
* builds; release bundles return an empty array so fake posts never
* appear in production.
*
* We use the runtime `globalThis.__DEV__` lookup rather than the typed
* `__DEV__` global because some builds can have the TS typing
* out-of-sync with the actual injected value.
*/
export function getDevSeedFeed(): FeedPostItem[] {
const g = globalThis as unknown as { __DEV__?: boolean };
if (g.__DEV__ !== true) return [];
return SEED_POSTS;
}

View File

@@ -1,5 +1,23 @@
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { router } from 'expo-router';
/**
* Navigate back, or fall back to a sensible default route if there's
* no screen to pop to.
*
* Without this, an opened route entered via deep link / direct push
* (profile, feed/[id], etc.) would emit the "action 'GO_BACK' was not
* handled by any navigator" dev warning and do nothing — user ends up
* stuck. Default fallback is the chats list (root of the app).
*/
export function safeBack(fallback: string = '/(app)/chats'): void {
if (router.canGoBack()) {
router.back();
} else {
router.replace(fallback as never);
}
}
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));

View File

@@ -29,6 +29,8 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime"
"runtime/debug"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -114,6 +116,16 @@ func main() {
// only for intentional migrations (e.g. importing data from another chain // only for intentional migrations (e.g. importing data from another chain
// into this network) — very dangerous. // into this network) — very dangerous.
allowGenesisMismatch := flag.Bool("allow-genesis-mismatch", false, "skip the safety check that aborts when the local genesis hash differs from the seed's. Use only for explicit chain migration.") allowGenesisMismatch := flag.Bool("allow-genesis-mismatch", false, "skip the safety check that aborts when the local genesis hash differs from the seed's. Use only for explicit chain migration.")
// ── Resource caps ───────────────────────────────────────────────────────
// All four accept 0 meaning "no limit". Enforcement model:
// * CPU — runtime.GOMAXPROCS(n): Go runtime won't use more than n OS threads for Go code.
// * RAM — debug.SetMemoryLimit: soft limit, the GC works harder as the heap approaches it.
// * Feed disk — hard refuse of new post bodies once the cap is crossed (existing posts keep serving).
// * Chain disk — warn-only periodic check; we can't hard-reject new blocks without breaking consensus.
maxCPU := flag.Int("max-cpu", int(envUint64Or("DCHAIN_MAX_CPU", 0)), "max CPU cores the node may use (GOMAXPROCS). 0 = all (env: DCHAIN_MAX_CPU)")
maxRAMMB := flag.Uint64("max-ram-mb", envUint64Or("DCHAIN_MAX_RAM_MB", 0), "soft Go heap limit in MiB (GOMEMLIMIT). 0 = unlimited (env: DCHAIN_MAX_RAM_MB)")
feedDiskMB := flag.Uint64("feed-disk-limit-mb", envUint64Or("DCHAIN_FEED_DISK_LIMIT_MB", 0), "disk quota for post bodies in MiB; new posts are refused with 507 once crossed. 0 = unlimited (env: DCHAIN_FEED_DISK_LIMIT_MB)")
chainDiskMB := flag.Uint64("chain-disk-limit-mb", envUint64Or("DCHAIN_CHAIN_DISK_LIMIT_MB", 0), "advisory disk cap for the chain DB dir in MiB; exceeding it logs a loud WARN every minute. 0 = unlimited (env: DCHAIN_CHAIN_DISK_LIMIT_MB)")
showVersion := flag.Bool("version", false, "print version info and exit") showVersion := flag.Bool("version", false, "print version info and exit")
flag.Parse() flag.Parse()
@@ -128,6 +140,10 @@ func main() {
// so subsequent logs inherit the format. // so subsequent logs inherit the format.
setupLogging(*logFormat) setupLogging(*logFormat)
// Apply CPU / RAM caps before anything else spins up so the runtime
// picks them up at first goroutine/heap allocation.
applyResourceCaps(*maxCPU, *maxRAMMB)
// Wire API access-control. A non-empty token gates writes; adding // Wire API access-control. A non-empty token gates writes; adding
// --api-private also gates reads. Logged up-front so the operator // --api-private also gates reads. Logged up-front so the operator
// sees what mode they're in. // sees what mode they're in.
@@ -641,12 +657,24 @@ func main() {
// --- Feed mailbox (social-feed post bodies, v2.0.0) --- // --- Feed mailbox (social-feed post bodies, v2.0.0) ---
feedTTL := time.Duration(*feedTTLDays) * 24 * time.Hour feedTTL := time.Duration(*feedTTLDays) * 24 * time.Hour
feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL) feedQuotaBytes := int64(*feedDiskMB) * 1024 * 1024
feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL, feedQuotaBytes)
if err != nil { if err != nil {
log.Fatalf("[NODE] feed mailbox: %v", err) log.Fatalf("[NODE] feed mailbox: %v", err)
} }
defer feedMailbox.Close() defer feedMailbox.Close()
log.Printf("[NODE] feed mailbox: %s (TTL %d days)", *feedDB, *feedTTLDays) if feedQuotaBytes > 0 {
log.Printf("[NODE] feed mailbox: %s (TTL %d days, disk quota %d MiB)", *feedDB, *feedTTLDays, *feedDiskMB)
} else {
log.Printf("[NODE] feed mailbox: %s (TTL %d days, no disk quota)", *feedDB, *feedTTLDays)
}
// Advisory chain-disk watcher. We can't refuse new blocks (consensus
// would stall), so instead we walk the chain DB dir every minute and
// log a loud WARN if the operator's budget is exceeded. Zero = disabled.
if *chainDiskMB > 0 {
go watchChainDisk(*dbPath, int64(*chainDiskMB)*1024*1024)
}
// Push-notify bus consumers whenever a fresh envelope lands in the // Push-notify bus consumers whenever a fresh envelope lands in the
// mailbox. Clients subscribed to `inbox:<my_x25519>` (via WS) get the // mailbox. Clients subscribed to `inbox:<my_x25519>` (via WS) get the
@@ -1291,23 +1319,53 @@ type keyJSON struct {
} }
func loadOrCreateIdentity(keyFile string) *identity.Identity { func loadOrCreateIdentity(keyFile string) *identity.Identity {
if data, err := os.ReadFile(keyFile); err == nil { // Key-file handling has a silent-failure mode that cost a genesis
// validator 21M tokens in the wild: if the file exists but we can't
// read it (e.g. mounted read-only under a different UID), ReadFile
// returns an error, we fall through to "generate", and the operator
// ends up with an ephemeral key whose pubkey doesn't match what's in
// keys/node.json on disk. Genesis allocation then lands on the
// ephemeral key that vanishes on restart.
//
// Distinguish "file doesn't exist" (normal — first boot, create)
// from "file exists but unreadable" (operator error — fail loudly).
if info, err := os.Stat(keyFile); err == nil {
// File is there. Any read failure now is an operator problem,
// not a bootstrap case.
_ = info
data, err := os.ReadFile(keyFile)
if err != nil {
log.Fatalf("[NODE] key file %s exists but can't be read: %v\n"+
"\thint: check file perms (should be readable by the node user) "+
"and that the mount isn't unexpectedly read-only.",
keyFile, err)
}
var kj keyJSON var kj keyJSON
if err := json.Unmarshal(data, &kj); err == nil { if err := json.Unmarshal(data, &kj); err != nil {
if id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv); err == nil { log.Fatalf("[NODE] key file %s is not valid JSON: %v", keyFile, err)
// If the file is missing X25519 keys, backfill and re-save. }
if kj.X25519Pub == "" { id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv)
kj.X25519Pub = id.X25519PubHex() if err != nil {
kj.X25519Priv = id.X25519PrivHex() log.Fatalf("[NODE] key file %s is valid JSON but identity decode failed: %v",
if out, err2 := json.MarshalIndent(kj, "", " "); err2 == nil { keyFile, err)
_ = os.WriteFile(keyFile, out, 0600) }
} // If the file is missing X25519 keys, backfill and re-save (best-effort,
} // ignore write failure on read-only mounts).
log.Printf("[NODE] loaded identity from %s", keyFile) if kj.X25519Pub == "" {
return id kj.X25519Pub = id.X25519PubHex()
kj.X25519Priv = id.X25519PrivHex()
if out, err2 := json.MarshalIndent(kj, "", " "); err2 == nil {
_ = os.WriteFile(keyFile, out, 0600)
} }
} }
log.Printf("[NODE] loaded identity from %s", keyFile)
return id
} else if !os.IsNotExist(err) {
// Something other than "file not found" — permission on the
// containing directory, broken symlink, etc. Also fail loudly.
log.Fatalf("[NODE] stat %s: %v", keyFile, err)
} }
// File genuinely doesn't exist — first boot. Generate + save.
id, err := identity.Generate() id, err := identity.Generate()
if err != nil { if err != nil {
log.Fatalf("generate identity: %v", err) log.Fatalf("generate identity: %v", err)
@@ -1320,7 +1378,9 @@ func loadOrCreateIdentity(keyFile string) *identity.Identity {
} }
data, _ := json.MarshalIndent(kj, "", " ") data, _ := json.MarshalIndent(kj, "", " ")
if err := os.WriteFile(keyFile, data, 0600); err != nil { if err := os.WriteFile(keyFile, data, 0600); err != nil {
log.Printf("[NODE] warning: could not save key: %v", err) log.Printf("[NODE] warning: could not save key to %s: %v "+
"(ephemeral key in use — this node's identity will change on restart!)",
keyFile, err)
} else { } else {
log.Printf("[NODE] new identity saved to %s", keyFile) log.Printf("[NODE] new identity saved to %s", keyFile)
} }
@@ -1440,6 +1500,61 @@ func shortKeys(keys []string) []string {
// "text" (default) is handler-default human-readable format, same as bare // "text" (default) is handler-default human-readable format, same as bare
// log.Printf. "json" emits one JSON object per line with `time/level/msg` // log.Printf. "json" emits one JSON object per line with `time/level/msg`
// + any key=value attrs — what Loki/ELK ingest natively. // + any key=value attrs — what Loki/ELK ingest natively.
// applyResourceCaps wires the --max-cpu and --max-ram-mb flags into the Go
// runtime. Both are soft-ish: CPU clamps GOMAXPROCS (Go scheduler won't use
// more OS threads for Go code, though blocking syscalls can still spawn
// more); RAM sets GOMEMLIMIT (the GC tightens its collection schedule as
// the heap approaches the cap but cannot *force* a kernel OOM-free). Use
// container limits (cgroup / Docker --memory / --cpus) alongside these
// for a real ceiling — this is "please play nice", not "hard sandbox".
func applyResourceCaps(maxCPU int, maxRAMMB uint64) {
if maxCPU > 0 {
prev := runtime.GOMAXPROCS(maxCPU)
log.Printf("[NODE] CPU cap: GOMAXPROCS %d → %d", prev, maxCPU)
}
if maxRAMMB > 0 {
bytes := int64(maxRAMMB) * 1024 * 1024
debug.SetMemoryLimit(bytes)
log.Printf("[NODE] RAM cap: GOMEMLIMIT = %d MiB (soft, GC-enforced)", maxRAMMB)
}
}
// watchChainDisk periodically walks the chain BadgerDB directory and logs
// a WARN line whenever its size exceeds `limitBytes`. Runs forever — the
// process lifetime bounds it. We deliberately do *not* stop block
// production when the cap is crossed: a validator that refuses to apply
// blocks stalls consensus for everyone on the chain, which is worse than
// using more disk than the operator wanted. Treat this as a monitoring
// signal, e.g. feed it to Prometheus via an alertmanager scrape.
func watchChainDisk(dir string, limitBytes int64) {
tick := time.NewTicker(60 * time.Second)
defer tick.Stop()
for ; ; <-tick.C {
used := dirSize(dir)
if used > limitBytes {
log.Printf("[NODE] WARN chain disk over quota: %d MiB used > %d MiB limit at %s",
used>>20, limitBytes>>20, dir)
}
}
}
// dirSize returns the total byte size of all regular files under root,
// recursively. Errors on individual entries are ignored — this is an
// advisory metric, not a filesystem audit.
func dirSize(root string) int64 {
var total int64
_ = filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total
}
func setupLogging(format string) { func setupLogging(format string) {
var handler slog.Handler var handler slog.Handler
switch strings.ToLower(format) { switch strings.ToLower(format) {

View File

@@ -13,7 +13,7 @@
# testnet validator. # testnet validator.
# ---- build stage ---- # ---- build stage ----
FROM golang:1.24-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -46,7 +46,15 @@ RUN apk add --no-cache ca-certificates tzdata
# Run as unprivileged user by default. Operators can override with --user root # Run as unprivileged user by default. Operators can override with --user root
# if they need to bind privileged ports (shouldn't be necessary behind Caddy). # if they need to bind privileged ports (shouldn't be necessary behind Caddy).
RUN addgroup -S dchain && adduser -S -G dchain dchain #
# IMPORTANT: /data must exist + be owned by dchain BEFORE the VOLUME
# directive. Docker copies the directory ownership of the mount point
# into any fresh named volume at first-attach time; skip this and
# operators get "mkdir: permission denied" when the node tries to
# create /data/chain as the dchain user.
RUN addgroup -S dchain && adduser -S -G dchain dchain \
&& mkdir -p /data \
&& chown dchain:dchain /data
COPY --from=builder /bin/node /usr/local/bin/node COPY --from=builder /bin/node /usr/local/bin/node
COPY --from=builder /bin/client /usr/local/bin/client COPY --from=builder /bin/client /usr/local/bin/client

View File

@@ -5,7 +5,7 @@
# Compose: see docker-compose.yml; node points DCHAIN_MEDIA_SIDECAR_URL at it. # Compose: see docker-compose.yml; node points DCHAIN_MEDIA_SIDECAR_URL at it.
# #
# Stage 1 — build a tiny static Go binary. # Stage 1 — build a tiny static Go binary.
FROM golang:1.22-alpine AS build FROM golang:1.25-alpine AS build
WORKDIR /src WORKDIR /src
# Copy only what we need (the sidecar main is self-contained, no module # Copy only what we need (the sidecar main is self-contained, no module
# deps on the rest of the repo, so this is a cheap, cache-friendly build). # deps on the rest of the repo, so this is a cheap, cache-friendly build).

View File

@@ -34,6 +34,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -249,10 +250,16 @@ func feedPublish(cfg FeedConfig) http.HandlerFunc {
} }
hashtags, err := cfg.Mailbox.Store(post, req.Ts) hashtags, err := cfg.Mailbox.Store(post, req.Ts)
if err != nil { if err != nil {
if err == relay.ErrPostTooLarge { if errors.Is(err, relay.ErrPostTooLarge) {
jsonErr(w, err, 413) jsonErr(w, err, 413)
return return
} }
if errors.Is(err, relay.ErrFeedQuotaExceeded) {
// 507 Insufficient Storage — the client should try
// another relay (or wait for TTL-driven eviction here).
jsonErr(w, err, 507)
return
}
jsonErr(w, err, 500) jsonErr(w, err, 500)
return return
} }

View File

@@ -72,7 +72,7 @@ func newFeedHarness(t *testing.T) *feedHarness {
if err != nil { if err != nil {
t.Fatalf("NewChain: %v", err) t.Fatalf("NewChain: %v", err)
} }
fm, err := relay.OpenFeedMailbox(feedDir, 24*time.Hour) fm, err := relay.OpenFeedMailbox(feedDir, 24*time.Hour, 0)
if err != nil { if err != nil {
t.Fatalf("OpenFeedMailbox: %v", err) t.Fatalf("OpenFeedMailbox: %v", err)
} }

View File

@@ -81,11 +81,11 @@ func newTwoNodeHarness(t *testing.T) *twoNodeHarness {
if err != nil { if err != nil {
t.Fatalf("chain B: %v", err) t.Fatalf("chain B: %v", err)
} }
h.aMailbox, err = relay.OpenFeedMailbox(h.aFeedDir, 24*time.Hour) h.aMailbox, err = relay.OpenFeedMailbox(h.aFeedDir, 24*time.Hour, 0)
if err != nil { if err != nil {
t.Fatalf("feed A: %v", err) t.Fatalf("feed A: %v", err)
} }
h.bMailbox, err = relay.OpenFeedMailbox(h.bFeedDir, 24*time.Hour) h.bMailbox, err = relay.OpenFeedMailbox(h.bFeedDir, 24*time.Hour, 0)
if err != nil { if err != nil {
t.Fatalf("feed B: %v", err) t.Fatalf("feed B: %v", err)
} }

View File

@@ -97,24 +97,35 @@ type FeedPost struct {
// ErrPostTooLarge is returned by Store when the post body exceeds MaxPostBodySize. // ErrPostTooLarge is returned by Store when the post body exceeds MaxPostBodySize.
var ErrPostTooLarge = errors.New("post body exceeds maximum allowed size") var ErrPostTooLarge = errors.New("post body exceeds maximum allowed size")
// ErrFeedQuotaExceeded is returned by Store when the on-disk footprint
// (LSM + value log) plus the incoming post would exceed the operator-set
// disk quota. Ops set this via --feed-disk-limit-mb. Zero = unlimited.
var ErrFeedQuotaExceeded = errors.New("feed mailbox disk quota exceeded")
// FeedMailbox stores feed post bodies. // FeedMailbox stores feed post bodies.
type FeedMailbox struct { type FeedMailbox struct {
db *badger.DB db *badger.DB
ttl time.Duration ttl time.Duration
quotaBytes int64 // 0 = unlimited
} }
// NewFeedMailbox wraps an already-open Badger DB. TTL controls how long // NewFeedMailbox wraps an already-open Badger DB. TTL controls how long
// post bodies live before auto-eviction (on-chain metadata persists // post bodies live before auto-eviction (on-chain metadata persists
// forever independently). // forever independently). quotaBytes caps the on-disk footprint; 0 or
func NewFeedMailbox(db *badger.DB, ttl time.Duration) *FeedMailbox { // negative means unlimited.
func NewFeedMailbox(db *badger.DB, ttl time.Duration, quotaBytes int64) *FeedMailbox {
if ttl <= 0 { if ttl <= 0 {
ttl = time.Duration(FeedPostDefaultTTLDays) * 24 * time.Hour ttl = time.Duration(FeedPostDefaultTTLDays) * 24 * time.Hour
} }
return &FeedMailbox{db: db, ttl: ttl} if quotaBytes < 0 {
quotaBytes = 0
}
return &FeedMailbox{db: db, ttl: ttl, quotaBytes: quotaBytes}
} }
// OpenFeedMailbox opens (or creates) a dedicated BadgerDB at dbPath. // OpenFeedMailbox opens (or creates) a dedicated BadgerDB at dbPath.
func OpenFeedMailbox(dbPath string, ttl time.Duration) (*FeedMailbox, error) { // quotaBytes caps the total on-disk footprint (LSM + vlog); 0 = unlimited.
func OpenFeedMailbox(dbPath string, ttl time.Duration, quotaBytes int64) (*FeedMailbox, error) {
opts := badger.DefaultOptions(dbPath). opts := badger.DefaultOptions(dbPath).
WithLogger(nil). WithLogger(nil).
WithValueLogFileSize(128 << 20). WithValueLogFileSize(128 << 20).
@@ -124,9 +135,19 @@ func OpenFeedMailbox(dbPath string, ttl time.Duration) (*FeedMailbox, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("open feed mailbox db: %w", err) return nil, fmt.Errorf("open feed mailbox db: %w", err)
} }
return NewFeedMailbox(db, ttl), nil return NewFeedMailbox(db, ttl, quotaBytes), nil
} }
// DiskUsage returns the current on-disk footprint (LSM + value log) in
// bytes. Cheap — Badger tracks these counters internally.
func (fm *FeedMailbox) DiskUsage() int64 {
lsm, vlog := fm.db.Size()
return lsm + vlog
}
// Quota returns the configured disk quota in bytes. 0 = unlimited.
func (fm *FeedMailbox) Quota() int64 { return fm.quotaBytes }
// Close releases the underlying Badger handle. // Close releases the underlying Badger handle.
func (fm *FeedMailbox) Close() error { return fm.db.Close() } func (fm *FeedMailbox) Close() error { return fm.db.Close() }
@@ -139,7 +160,23 @@ func (fm *FeedMailbox) Close() error { return fm.db.Close() }
func (fm *FeedMailbox) Store(post *FeedPost, createdAt int64) ([]string, error) { func (fm *FeedMailbox) Store(post *FeedPost, createdAt int64) ([]string, error) {
size := estimatePostSize(post) size := estimatePostSize(post)
if size > MaxPostBodySize { if size > MaxPostBodySize {
return nil, ErrPostTooLarge // Wrap the sentinel so the HTTP layer can still errors.Is() on it
// while the operator / client sees the actual offending numbers.
// This catches the common case where the client's pre-scrub
// estimate is below the cap but the server re-encode (quality=75
// JPEG) inflates past it.
return nil, fmt.Errorf("%w: size %d > max %d (after server scrub)",
ErrPostTooLarge, size, MaxPostBodySize)
}
// Disk quota: refuse new bodies once we're already over the cap.
// `size` is a post-body estimate, not the exact BadgerDB write-amp
// cost; we accept that slack — the goal is a coarse guard-rail so
// an operator's disk doesn't blow up unnoticed. Exceeding nodes
// still serve existing posts; only new Store() calls are refused.
if fm.quotaBytes > 0 {
if fm.DiskUsage()+int64(size) > fm.quotaBytes {
return nil, ErrFeedQuotaExceeded
}
} }
post.CreatedAt = createdAt post.CreatedAt = createdAt

View File

@@ -1,6 +1,7 @@
package relay package relay
import ( import (
"errors"
"os" "os"
"testing" "testing"
"time" "time"
@@ -12,7 +13,7 @@ func newTestFeedMailbox(t *testing.T) *FeedMailbox {
if err != nil { if err != nil {
t.Fatalf("MkdirTemp: %v", err) t.Fatalf("MkdirTemp: %v", err)
} }
fm, err := OpenFeedMailbox(dir, 24*time.Hour) fm, err := OpenFeedMailbox(dir, 24*time.Hour, 0)
if err != nil { if err != nil {
_ = os.RemoveAll(dir) _ = os.RemoveAll(dir)
t.Fatalf("OpenFeedMailbox: %v", err) t.Fatalf("OpenFeedMailbox: %v", err)
@@ -75,7 +76,7 @@ func TestFeedMailboxTooLarge(t *testing.T) {
Author: "a", Author: "a",
Attachment: big, Attachment: big,
} }
if _, err := fm.Store(post, 0); err != ErrPostTooLarge { if _, err := fm.Store(post, 0); !errors.Is(err, ErrPostTooLarge) {
t.Fatalf("Store huge post: got %v, want ErrPostTooLarge", err) t.Fatalf("Store huge post: got %v, want ErrPostTooLarge", err)
} }
} }