Compare commits
37 Commits
78d97281f0
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a75cbcd224 | ||
|
|
e6f3d2bcf8 | ||
|
|
e62b72b5be | ||
|
|
f7a849ddcb | ||
|
|
060ac6c2c9 | ||
|
|
516940fa8e | ||
|
|
3e9ddc1a43 | ||
|
|
6ed4e7ca50 | ||
|
|
f726587ac6 | ||
|
|
1e7f4d8da4 | ||
|
|
29e95485fa | ||
|
|
1a8731f479 | ||
|
|
6425b5cffb | ||
|
|
1c6622e809 | ||
|
|
ab98f21aac | ||
|
|
9be1b60ef1 | ||
|
|
0bb5780a5d | ||
|
|
50aacced0b | ||
|
|
38ae80f57a | ||
|
|
c5ca7a0612 | ||
|
|
7bfd8c7dea | ||
|
|
f688f45739 | ||
|
|
a248c540d5 | ||
|
|
51bc0a1850 | ||
|
|
93040a0684 | ||
|
|
98a0a4b8ba | ||
|
|
5728cfc85a | ||
|
|
0ff2760a11 | ||
|
|
5b64ef2560 | ||
|
|
9e86c93fda | ||
|
|
f885264d23 | ||
|
|
126658f294 | ||
|
|
88848efa63 | ||
|
|
f2cb5586ca | ||
|
|
15d0ed306b | ||
|
|
8082dd0bf7 | ||
|
|
32eec62ba4 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -60,4 +60,7 @@ Thumbs.db
|
|||||||
# Not part of the release bundle — tracked separately
|
# Not part of the release bundle — tracked separately
|
||||||
CONTEXT.md
|
CONTEXT.md
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
client-app/
|
|
||||||
|
# Client app sources are tracked from v2.0.0 onwards (feed feature made
|
||||||
|
# the client a first-class part of the release). Local state (node_modules,
|
||||||
|
# build artifacts, Expo cache) is ignored via client-app/.gitignore.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
193
README.md
193
README.md
@@ -1,6 +1,6 @@
|
|||||||
# DChain
|
# DChain
|
||||||
|
|
||||||
Блокчейн-стек для децентрализованного мессенджера:
|
Блокчейн-стек для децентрализованного мессенджера + социальной ленты:
|
||||||
|
|
||||||
- **PBFT** консенсус с multi-sig validator governance и equivocation slashing
|
- **PBFT** консенсус с multi-sig validator governance и equivocation slashing
|
||||||
- **Native Go контракты** рядом с WASM (wazero) — нулевая задержка для
|
- **Native Go контракты** рядом с WASM (wazero) — нулевая задержка для
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
- **WebSocket push API** — клиент не опрашивает, все события прилетают
|
- **WebSocket push API** — клиент не опрашивает, все события прилетают
|
||||||
на соединение
|
на соединение
|
||||||
- **E2E-шифрованный relay mailbox** на libp2p gossipsub с TTL live-detection
|
- **E2E-шифрованный relay mailbox** на libp2p gossipsub с TTL live-detection
|
||||||
|
(1:1 чаты через NaCl box; посты в ленте — plaintext-публичные)
|
||||||
|
- **Социальная лента v2.0.0** (заменила каналы): публичные посты
|
||||||
|
с оплатой за размер (автор платит, хостящая релей-нода получает);
|
||||||
|
on-chain граф подписок + лайки; off-chain просмотры + хэштеги;
|
||||||
|
мандаторный server-side scrubber метаданных (EXIF/GPS-стрип + FFmpeg
|
||||||
|
sidecar для видео); share-to-chat c embedded post-карточкой
|
||||||
- **Система обновлений:** build-time версия → `/api/well-known-version`,
|
- **Система обновлений:** build-time версия → `/api/well-known-version`,
|
||||||
peer-version gossip, `/api/update-check` против Gitea releases,
|
peer-version gossip, `/api/update-check` против Gitea releases,
|
||||||
`update.sh` с semver guard
|
`update.sh` с semver guard
|
||||||
@@ -16,6 +22,7 @@
|
|||||||
## Содержание
|
## Содержание
|
||||||
|
|
||||||
- [Быстрый старт](#быстрый-старт)
|
- [Быстрый старт](#быстрый-старт)
|
||||||
|
- [Поднятие ноды — пошагово](#поднятие-ноды--пошагово)
|
||||||
- [Продакшен деплой](#продакшен-деплой)
|
- [Продакшен деплой](#продакшен-деплой)
|
||||||
- [Архитектура](#архитектура)
|
- [Архитектура](#архитектура)
|
||||||
- [REST / WebSocket API](#rest--websocket-api)
|
- [REST / WebSocket API](#rest--websocket-api)
|
||||||
@@ -60,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).
|
||||||
|
|
||||||
## Продакшен деплой
|
## Продакшен деплой
|
||||||
|
|
||||||
Два варианта, по масштабу.
|
Два варианта, по масштабу.
|
||||||
@@ -142,7 +303,8 @@ sudo systemctl enable --now dchain-update.timer
|
|||||||
| `node/` | HTTP + WS API, SSE, metrics, access control |
|
| `node/` | HTTP + WS API, SSE, metrics, access control |
|
||||||
| `node/version/` | Build-time version metadata (ldflags-инжектимый) |
|
| `node/version/` | Build-time version metadata (ldflags-инжектимый) |
|
||||||
| `vm/` | wazero runtime для WASM-контрактов + gas model |
|
| `vm/` | wazero runtime для WASM-контрактов + gas model |
|
||||||
| `relay/` | E2E mailbox с NaCl-envelopes |
|
| `relay/` | E2E mailbox (1:1 envelopes) + public feed-mailbox (post bodies, view counter, hashtag index) |
|
||||||
|
| `media/` | Server-side metadata scrubber (EXIF strip + FFmpeg sidecar client) |
|
||||||
| `identity/` | Ed25519 + X25519 keypair, tx signing |
|
| `identity/` | Ed25519 + X25519 keypair, tx signing |
|
||||||
| `economy/` | Fee model, rewards |
|
| `economy/` | Fee model, rewards |
|
||||||
| `wallet/` | Optional payout wallet (отдельный ключ) |
|
| `wallet/` | Optional payout wallet (отдельный ключ) |
|
||||||
@@ -179,6 +341,33 @@ sudo systemctl enable --now dchain-update.timer
|
|||||||
Scoped WS-топики (`addr:`, `inbox:`, `typing:`) требуют auth через
|
Scoped WS-топики (`addr:`, `inbox:`, `typing:`) требуют auth через
|
||||||
Ed25519-nonce; публичные (`blocks`, `tx`, `contract_log`) — без.
|
Ed25519-nonce; публичные (`blocks`, `tx`, `contract_log`) — без.
|
||||||
|
|
||||||
|
### Relay (E2E messaging)
|
||||||
|
| Endpoint | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| `POST /relay/broadcast` | Опубликовать pre-sealed envelope (E2E-путь, рекомендован) |
|
||||||
|
| `GET /relay/inbox?pub=<x25519>` | Прочитать входящие конверты |
|
||||||
|
| `DELETE /relay/inbox/{id}` | Удалить envelope (требует Ed25519-подписи владельца) |
|
||||||
|
|
||||||
|
Детали — [`docs/api/relay.md`](docs/api/relay.md). `/relay/send` оставлен
|
||||||
|
для backward-compat, но ломает E2E (nod-релей запечатывает своим ключом)
|
||||||
|
и помечен как non-recommended.
|
||||||
|
|
||||||
|
### Social feed (v2.0.0)
|
||||||
|
| Endpoint | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| `POST /feed/publish` | Загрузить тело поста + EXIF-скраб + вернуть fee |
|
||||||
|
| `GET /feed/post/{id}` | Тело поста |
|
||||||
|
| `GET /feed/post/{id}/attachment` | Сырые байты картинки/видео (cache'able) |
|
||||||
|
| `GET /feed/post/{id}/stats?me=<pub>` | `{views, likes, liked_by_me?}` |
|
||||||
|
| `POST /feed/post/{id}/view` | Бамп off-chain счётчика просмотров |
|
||||||
|
| `GET /feed/author/{pub}?before=<ts>&limit=N` | Посты автора (пагинация `before`) |
|
||||||
|
| `GET /feed/timeline?follower=<pub>&before=<ts>&limit=N` | Merged лента подписок |
|
||||||
|
| `GET /feed/trending?window=24&limit=N` | Топ по `likes × 3 + views` за окно |
|
||||||
|
| `GET /feed/foryou?pub=<pub>&limit=N` | Рекомендации (неподписанные авторы) |
|
||||||
|
| `GET /feed/hashtag/{tag}?limit=N` | Посты по хэштегу |
|
||||||
|
|
||||||
|
Детали + спецификация — [`docs/api/feed.md`](docs/api/feed.md).
|
||||||
|
|
||||||
### Docs / UI
|
### Docs / UI
|
||||||
- `GET /swagger` — **Swagger UI** (рендерится через swagger-ui-dist).
|
- `GET /swagger` — **Swagger UI** (рендерится через swagger-ui-dist).
|
||||||
- `GET /swagger/openapi.json` — сырая OpenAPI 3.0 спека.
|
- `GET /swagger/openapi.json` — сырая OpenAPI 3.0 спека.
|
||||||
|
|||||||
@@ -40,13 +40,20 @@ const (
|
|||||||
prefixHeight = "height" // height → uint64
|
prefixHeight = "height" // height → uint64
|
||||||
prefixBalance = "balance:" // balance:<pubkey> → uint64
|
prefixBalance = "balance:" // balance:<pubkey> → uint64
|
||||||
prefixIdentity = "id:" // id:<pubkey> → RegisterKeyPayload JSON
|
prefixIdentity = "id:" // id:<pubkey> → RegisterKeyPayload JSON
|
||||||
prefixChannel = "chan:" // chan:<channelID> → CreateChannelPayload JSON
|
|
||||||
prefixChanMember = "chan-member:" // chan-member:<channelID>:<memberPubKey> → "" (presence = member)
|
// Social feed (v2.0.0). Replaced the old channel keys (chan:, chan-member:).
|
||||||
|
prefixPost = "post:" // post:<postID> → PostRecord JSON
|
||||||
|
prefixPostByAuthor = "postbyauthor:" // postbyauthor:<author>:<ts_20d>:<postID> → "" (chrono index)
|
||||||
|
prefixFollow = "follow:" // follow:<follower>:<target> → "" (presence = follows)
|
||||||
|
prefixFollowInbound = "followin:" // followin:<target>:<follower> → "" (reverse index — counts followers)
|
||||||
|
prefixLike = "like:" // like:<postID>:<liker> → "" (presence = liked)
|
||||||
|
prefixLikeCount = "likecount:" // likecount:<postID> → uint64 (cached count)
|
||||||
prefixWalletBind = "walletbind:" // walletbind:<node_pubkey> → wallet_pubkey (string)
|
prefixWalletBind = "walletbind:" // walletbind:<node_pubkey> → wallet_pubkey (string)
|
||||||
prefixReputation = "rep:" // rep:<pubkey> → RepStats JSON
|
prefixReputation = "rep:" // rep:<pubkey> → RepStats JSON
|
||||||
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
|
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
|
||||||
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
|
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
|
||||||
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
|
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
|
||||||
|
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
|
||||||
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
|
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
|
||||||
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
|
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
|
||||||
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
|
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
|
||||||
@@ -537,11 +544,13 @@ func (c *Chain) Identity(pubKeyHex string) (*RegisterKeyPayload, error) {
|
|||||||
return &p, err
|
return &p, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel returns the CreateChannelPayload for a channel ID, or nil.
|
// ── Feed queries (v2.0.0) ──────────────────────────────────────────────────
|
||||||
func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) {
|
|
||||||
var p CreateChannelPayload
|
// Post returns the PostRecord for a post ID, or nil if not found.
|
||||||
|
func (c *Chain) Post(postID string) (*PostRecord, error) {
|
||||||
|
var p PostRecord
|
||||||
err := c.db.View(func(txn *badger.Txn) error {
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
item, err := txn.Get([]byte(prefixChannel + channelID))
|
item, err := txn.Get([]byte(prefixPost + postID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -552,13 +561,69 @@ func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) {
|
|||||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return &p, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelMembers returns the public keys of all members added to channelID.
|
// PostsByAuthor returns the last `limit` posts by the given author, newest
|
||||||
func (c *Chain) ChannelMembers(channelID string) ([]string, error) {
|
// first. Iterates `postbyauthor:<author>:...` in reverse order. If limit
|
||||||
prefix := []byte(fmt.Sprintf("%s%s:", prefixChanMember, channelID))
|
// ≤ 0, defaults to 50; capped at 200.
|
||||||
var members []string
|
//
|
||||||
|
// If beforeTs > 0, skip posts with CreatedAt >= beforeTs — used by the
|
||||||
|
// timeline/author endpoints to paginate older results. Pass 0 for the
|
||||||
|
// first page (everything, newest first).
|
||||||
|
func (c *Chain) PostsByAuthor(authorPub string, beforeTs int64, limit int) ([]*PostRecord, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
prefix := []byte(prefixPostByAuthor + authorPub + ":")
|
||||||
|
var out []*PostRecord
|
||||||
|
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.Prefix = prefix
|
||||||
|
opts.Reverse = true // newest (higher ts) first — reverse iteration
|
||||||
|
opts.PrefetchValues = false
|
||||||
|
// For reverse iteration Badger requires seeking past the prefix range.
|
||||||
|
seek := append([]byte{}, prefix...)
|
||||||
|
seek = append(seek, 0xff)
|
||||||
|
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
for it.Seek(seek); it.ValidForPrefix(prefix) && len(out) < limit; it.Next() {
|
||||||
|
key := string(it.Item().Key())
|
||||||
|
// key = "postbyauthor:<author>:<ts_20d>:<postID>"
|
||||||
|
parts := strings.Split(key, ":")
|
||||||
|
if len(parts) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
postID := parts[len(parts)-1]
|
||||||
|
rec, err := c.postInTxn(txn, postID)
|
||||||
|
if err != nil || rec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rec.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if beforeTs > 0 && rec.CreatedAt >= beforeTs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Following returns the Ed25519 pubkeys that `follower` subscribes to.
|
||||||
|
func (c *Chain) Following(followerPub string) ([]string, error) {
|
||||||
|
prefix := []byte(prefixFollow + followerPub + ":")
|
||||||
|
var out []string
|
||||||
err := c.db.View(func(txn *badger.Txn) error {
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
opts := badger.DefaultIteratorOptions
|
opts := badger.DefaultIteratorOptions
|
||||||
opts.PrefetchValues = false
|
opts.PrefetchValues = false
|
||||||
@@ -567,15 +632,95 @@ func (c *Chain) ChannelMembers(channelID string) ([]string, error) {
|
|||||||
defer it.Close()
|
defer it.Close()
|
||||||
for it.Rewind(); it.Valid(); it.Next() {
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
key := string(it.Item().Key())
|
key := string(it.Item().Key())
|
||||||
// key = "chan-member:<channelID>:<memberPubKey>"
|
// key = "follow:<follower>:<target>"
|
||||||
parts := strings.SplitN(key, ":", 3)
|
parts := strings.SplitN(key, ":", 3)
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
members = append(members, parts[2])
|
out = append(out, parts[2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return members, err
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Followers returns the Ed25519 pubkeys that follow `target`.
|
||||||
|
func (c *Chain) Followers(targetPub string) ([]string, error) {
|
||||||
|
prefix := []byte(prefixFollowInbound + targetPub + ":")
|
||||||
|
var out []string
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.PrefetchValues = false
|
||||||
|
opts.Prefix = prefix
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
key := string(it.Item().Key())
|
||||||
|
parts := strings.SplitN(key, ":", 3)
|
||||||
|
if len(parts) == 3 {
|
||||||
|
out = append(out, parts[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LikeCount returns the cached count of likes for a post (O(1)).
|
||||||
|
func (c *Chain) LikeCount(postID string) (uint64, error) {
|
||||||
|
var count uint64
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
item, err := txn.Get([]byte(prefixLikeCount + postID))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return item.Value(func(val []byte) error {
|
||||||
|
if len(val) == 8 {
|
||||||
|
count = binary.BigEndian.Uint64(val)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasLiked reports whether `liker` has liked the given post.
|
||||||
|
func (c *Chain) HasLiked(postID, likerPub string) (bool, error) {
|
||||||
|
key := []byte(prefixLike + postID + ":" + likerPub)
|
||||||
|
var ok bool
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
_, err := txn.Get(key)
|
||||||
|
if err == nil {
|
||||||
|
ok = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// postInTxn is the internal helper used by iteration paths to fetch a full
|
||||||
|
// PostRecord without opening a new View transaction.
|
||||||
|
func (c *Chain) postInTxn(txn *badger.Txn, postID string) (*PostRecord, error) {
|
||||||
|
item, err := txn.Get([]byte(prefixPost + postID))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var p PostRecord
|
||||||
|
if err := item.Value(func(val []byte) error {
|
||||||
|
return json.Unmarshal(val, &p)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WalletBinding returns the payout wallet pub key bound to a node, or "" if none.
|
// WalletBinding returns the payout wallet pub key bound to a node, or "" if none.
|
||||||
@@ -740,41 +885,197 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
case EventCreateChannel:
|
// ── Feed events (v2.0.0) ──────────────────────────────────────────
|
||||||
var p CreateChannelPayload
|
case EventCreatePost:
|
||||||
|
var p CreatePostPayload
|
||||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
return 0, fmt.Errorf("%w: CREATE_CHANNEL bad payload: %v", ErrTxFailed, err)
|
return 0, fmt.Errorf("%w: CREATE_POST bad payload: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
if p.PostID == "" {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: post_id required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if len(p.ContentHash) != 32 {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: content_hash must be 32 bytes", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if p.HostingRelay == "" {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: hosting_relay required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if p.Size == 0 || p.Size > MaxPostSize {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: size %d out of range (0, %d]",
|
||||||
|
ErrTxFailed, p.Size, MaxPostSize)
|
||||||
|
}
|
||||||
|
if p.ReplyTo != "" && p.QuoteOf != "" {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: reply_to and quote_of are mutually exclusive", ErrTxFailed)
|
||||||
|
}
|
||||||
|
// Duplicate check — same post_id may only commit once.
|
||||||
|
if _, err := txn.Get([]byte(prefixPost + p.PostID)); err == nil {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: post %s already exists", ErrTxFailed, p.PostID)
|
||||||
|
}
|
||||||
|
// Fee formula: BasePostFee + size × PostByteFee. tx.Fee carries the
|
||||||
|
// full amount; we validate it matches and the sender can afford it.
|
||||||
|
expectedFee := BasePostFee + p.Size*PostByteFee
|
||||||
|
if tx.Fee < expectedFee {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: fee %d < required %d (base %d + %d × %d bytes)",
|
||||||
|
ErrTxFailed, tx.Fee, expectedFee, BasePostFee, PostByteFee, p.Size)
|
||||||
}
|
}
|
||||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
return 0, fmt.Errorf("CREATE_CHANNEL debit: %w", err)
|
return 0, fmt.Errorf("CREATE_POST debit: %w", err)
|
||||||
}
|
}
|
||||||
val, _ := json.Marshal(p)
|
// Full fee goes to the hosting relay (storage compensation). No
|
||||||
if err := txn.Set([]byte(prefixChannel+p.ChannelID), val); err != nil {
|
// validator cut on posts — validators earn from other tx types. This
|
||||||
|
// incentivises nodes to actually host posts.
|
||||||
|
relayTarget, err := c.resolveRewardTarget(txn, p.HostingRelay)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := c.creditBalance(txn, relayTarget, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("credit hosting relay: %w", err)
|
||||||
|
}
|
||||||
|
rec := PostRecord{
|
||||||
|
PostID: p.PostID,
|
||||||
|
Author: tx.From,
|
||||||
|
ContentHash: p.ContentHash,
|
||||||
|
Size: p.Size,
|
||||||
|
HostingRelay: p.HostingRelay,
|
||||||
|
ReplyTo: p.ReplyTo,
|
||||||
|
QuoteOf: p.QuoteOf,
|
||||||
|
CreatedAt: tx.Timestamp.Unix(),
|
||||||
|
FeeUT: tx.Fee,
|
||||||
|
}
|
||||||
|
recBytes, _ := json.Marshal(rec)
|
||||||
|
if err := txn.Set([]byte(prefixPost+p.PostID), recBytes); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Chrono index — allows PostsByAuthor to list newest-first in O(N).
|
||||||
|
idxKey := fmt.Sprintf("%s%s:%020d:%s", prefixPostByAuthor, tx.From, rec.CreatedAt, p.PostID)
|
||||||
|
if err := txn.Set([]byte(idxKey), []byte{}); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
case EventAddMember:
|
case EventDeletePost:
|
||||||
var p AddMemberPayload
|
var p DeletePostPayload
|
||||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
return 0, fmt.Errorf("%w: ADD_MEMBER bad payload: %v", ErrTxFailed, err)
|
return 0, fmt.Errorf("%w: DELETE_POST bad payload: %v", ErrTxFailed, err)
|
||||||
}
|
}
|
||||||
if p.ChannelID == "" {
|
if p.PostID == "" {
|
||||||
return 0, fmt.Errorf("%w: ADD_MEMBER: channel_id required", ErrTxFailed)
|
return 0, fmt.Errorf("%w: DELETE_POST: post_id required", ErrTxFailed)
|
||||||
}
|
}
|
||||||
if _, err := txn.Get([]byte(prefixChannel + p.ChannelID)); err != nil {
|
item, err := txn.Get([]byte(prefixPost + p.PostID))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return 0, fmt.Errorf("%w: DELETE_POST: post %s not found", ErrTxFailed, p.PostID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var rec PostRecord
|
||||||
|
if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if rec.Author != tx.From {
|
||||||
|
return 0, fmt.Errorf("%w: DELETE_POST: only author can delete", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("DELETE_POST debit: %w", err)
|
||||||
|
}
|
||||||
|
rec.Deleted = true
|
||||||
|
val, _ := json.Marshal(rec)
|
||||||
|
if err := txn.Set([]byte(prefixPost+p.PostID), val); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventFollow:
|
||||||
|
if tx.To == "" {
|
||||||
|
return 0, fmt.Errorf("%w: FOLLOW: target (to) is required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if tx.To == tx.From {
|
||||||
|
return 0, fmt.Errorf("%w: FOLLOW: cannot follow yourself", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("FOLLOW debit: %w", err)
|
||||||
|
}
|
||||||
|
// follow:<follower>:<target> + reverse index followin:<target>:<follower>
|
||||||
|
fKey := []byte(prefixFollow + tx.From + ":" + tx.To)
|
||||||
|
if _, err := txn.Get(fKey); err == nil {
|
||||||
|
return 0, fmt.Errorf("%w: FOLLOW: already following", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if err := txn.Set(fKey, []byte{}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := txn.Set([]byte(prefixFollowInbound+tx.To+":"+tx.From), []byte{}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventUnfollow:
|
||||||
|
if tx.To == "" {
|
||||||
|
return 0, fmt.Errorf("%w: UNFOLLOW: target (to) is required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("UNFOLLOW debit: %w", err)
|
||||||
|
}
|
||||||
|
fKey := []byte(prefixFollow + tx.From + ":" + tx.To)
|
||||||
|
if _, err := txn.Get(fKey); err != nil {
|
||||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
return 0, fmt.Errorf("%w: ADD_MEMBER: channel %q not found", ErrTxFailed, p.ChannelID)
|
return 0, fmt.Errorf("%w: UNFOLLOW: not following", ErrTxFailed)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := txn.Delete(fKey); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := txn.Delete([]byte(prefixFollowInbound + tx.To + ":" + tx.From)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventLikePost:
|
||||||
|
var p LikePostPayload
|
||||||
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: LIKE_POST bad payload: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
if p.PostID == "" {
|
||||||
|
return 0, fmt.Errorf("%w: LIKE_POST: post_id required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if _, err := txn.Get([]byte(prefixPost + p.PostID)); err != nil {
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return 0, fmt.Errorf("%w: LIKE_POST: post %s not found", ErrTxFailed, p.PostID)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
lKey := []byte(prefixLike + p.PostID + ":" + tx.From)
|
||||||
|
if _, err := txn.Get(lKey); err == nil {
|
||||||
|
return 0, fmt.Errorf("%w: LIKE_POST: already liked", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("LIKE_POST debit: %w", err)
|
||||||
|
}
|
||||||
|
if err := txn.Set(lKey, []byte{}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := bumpLikeCount(txn, p.PostID, +1); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventUnlikePost:
|
||||||
|
var p UnlikePostPayload
|
||||||
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: UNLIKE_POST bad payload: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
if p.PostID == "" {
|
||||||
|
return 0, fmt.Errorf("%w: UNLIKE_POST: post_id required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
lKey := []byte(prefixLike + p.PostID + ":" + tx.From)
|
||||||
|
if _, err := txn.Get(lKey); err != nil {
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return 0, fmt.Errorf("%w: UNLIKE_POST: not liked", ErrTxFailed)
|
||||||
}
|
}
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
return 0, fmt.Errorf("ADD_MEMBER debit: %w", err)
|
return 0, fmt.Errorf("UNLIKE_POST debit: %w", err)
|
||||||
}
|
}
|
||||||
member := tx.To
|
if err := txn.Delete(lKey); err != nil {
|
||||||
if member == "" {
|
return 0, err
|
||||||
member = tx.From
|
|
||||||
}
|
}
|
||||||
if err := txn.Set([]byte(fmt.Sprintf("%s%s:%s", prefixChanMember, p.ChannelID, member)), []byte{}); err != nil {
|
if err := bumpLikeCount(txn, p.PostID, -1); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,9 +1096,21 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
|||||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
return 0, fmt.Errorf("%w: RELAY_PROOF bad payload: %v", ErrTxFailed, err)
|
return 0, fmt.Errorf("%w: RELAY_PROOF bad payload: %v", ErrTxFailed, err)
|
||||||
}
|
}
|
||||||
|
if p.EnvelopeID == "" {
|
||||||
|
return 0, fmt.Errorf("%w: RELAY_PROOF: envelope_id is required", ErrTxFailed)
|
||||||
|
}
|
||||||
if p.SenderPubKey == "" || p.FeeUT == 0 || len(p.FeeSig) == 0 {
|
if p.SenderPubKey == "" || p.FeeUT == 0 || len(p.FeeSig) == 0 {
|
||||||
return 0, fmt.Errorf("%w: relay proof missing fee authorization fields", ErrTxFailed)
|
return 0, fmt.Errorf("%w: relay proof missing fee authorization fields", ErrTxFailed)
|
||||||
}
|
}
|
||||||
|
// Per-envelope dedup — only one relay may claim the fee for a given
|
||||||
|
// envelope. Without this check, every relay that saw the gossipsub
|
||||||
|
// re-broadcast could extract the sender's FeeSig and submit its own
|
||||||
|
// RELAY_PROOF, draining the sender's balance by N× for one message.
|
||||||
|
proofKey := []byte(prefixRelayProof + p.EnvelopeID)
|
||||||
|
if _, err := txn.Get(proofKey); err == nil {
|
||||||
|
return 0, fmt.Errorf("%w: RELAY_PROOF: envelope %s already claimed",
|
||||||
|
ErrTxFailed, p.EnvelopeID)
|
||||||
|
}
|
||||||
authBytes := FeeAuthBytes(p.EnvelopeID, p.FeeUT)
|
authBytes := FeeAuthBytes(p.EnvelopeID, p.FeeUT)
|
||||||
ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig)
|
ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
@@ -818,6 +1131,10 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
// Mark envelope as claimed — prevents replay by other relays.
|
||||||
|
if err := txn.Set(proofKey, []byte(p.RelayPubKey)); err != nil {
|
||||||
|
return 0, fmt.Errorf("mark relay proof: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
case EventBindWallet:
|
case EventBindWallet:
|
||||||
var p BindWalletPayload
|
var p BindWalletPayload
|
||||||
@@ -956,6 +1273,19 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
|||||||
return 0, fmt.Errorf("%w: CONTACT_REQUEST: amount %d < MinContactFee %d",
|
return 0, fmt.Errorf("%w: CONTACT_REQUEST: amount %d < MinContactFee %d",
|
||||||
ErrTxFailed, tx.Amount, MinContactFee)
|
ErrTxFailed, tx.Amount, MinContactFee)
|
||||||
}
|
}
|
||||||
|
// Sticky block — if recipient previously blocked this sender, refuse
|
||||||
|
// the new request instead of silently overwriting the blocked status
|
||||||
|
// back to pending. Prevents unblock-via-respam.
|
||||||
|
key := prefixContactIn + tx.To + ":" + tx.From
|
||||||
|
if item, err := txn.Get([]byte(key)); err == nil {
|
||||||
|
var prev contactRecord
|
||||||
|
if verr := item.Value(func(val []byte) error {
|
||||||
|
return json.Unmarshal(val, &prev)
|
||||||
|
}); verr == nil && prev.Status == string(ContactBlocked) {
|
||||||
|
return 0, fmt.Errorf("%w: CONTACT_REQUEST: recipient has blocked sender",
|
||||||
|
ErrTxFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil {
|
if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil {
|
||||||
return 0, fmt.Errorf("CONTACT_REQUEST debit: %w", err)
|
return 0, fmt.Errorf("CONTACT_REQUEST debit: %w", err)
|
||||||
}
|
}
|
||||||
@@ -970,7 +1300,6 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
|||||||
CreatedAt: tx.Timestamp.Unix(),
|
CreatedAt: tx.Timestamp.Unix(),
|
||||||
}
|
}
|
||||||
val, _ := json.Marshal(rec)
|
val, _ := json.Marshal(rec)
|
||||||
key := prefixContactIn + tx.To + ":" + tx.From
|
|
||||||
if err := txn.Set([]byte(key), val); err != nil {
|
if err := txn.Set([]byte(key), val); err != nil {
|
||||||
return 0, fmt.Errorf("store contact record: %w", err)
|
return 0, fmt.Errorf("store contact record: %w", err)
|
||||||
}
|
}
|
||||||
@@ -2307,6 +2636,35 @@ func (c *Chain) isValidatorTxn(txn *badger.Txn, pubKey string) (bool, error) {
|
|||||||
|
|
||||||
// verifyEd25519 verifies an Ed25519 signature without importing the identity package
|
// verifyEd25519 verifies an Ed25519 signature without importing the identity package
|
||||||
// (which would create a circular dependency).
|
// (which would create a circular dependency).
|
||||||
|
// bumpLikeCount adjusts the cached like counter for a post. delta = ±1.
|
||||||
|
// Clamps at zero so a corrupt unlike without prior like can't underflow.
|
||||||
|
func bumpLikeCount(txn *badger.Txn, postID string, delta int64) error {
|
||||||
|
key := []byte(prefixLikeCount + postID)
|
||||||
|
var cur uint64
|
||||||
|
item, err := txn.Get(key)
|
||||||
|
if err == nil {
|
||||||
|
if verr := item.Value(func(val []byte) error {
|
||||||
|
if len(val) == 8 {
|
||||||
|
cur = binary.BigEndian.Uint64(val)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); verr != nil {
|
||||||
|
return verr
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case delta < 0 && cur > 0:
|
||||||
|
cur--
|
||||||
|
case delta > 0:
|
||||||
|
cur++
|
||||||
|
}
|
||||||
|
var buf [8]byte
|
||||||
|
binary.BigEndian.PutUint64(buf[:], cur)
|
||||||
|
return txn.Set(key, buf[:])
|
||||||
|
}
|
||||||
|
|
||||||
func verifyEd25519(pubKeyHex string, msg, sig []byte) (bool, error) {
|
func verifyEd25519(pubKeyHex string, msg, sig []byte) (bool, error) {
|
||||||
pubBytes, err := hex.DecodeString(pubKeyHex)
|
pubBytes, err := hex.DecodeString(pubKeyHex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -794,3 +794,248 @@ var _ = identity.Generate
|
|||||||
// Ensure ed25519 and hex are used directly (they may be used via helpers).
|
// Ensure ed25519 and hex are used directly (they may be used via helpers).
|
||||||
var _ = ed25519.PublicKey(nil)
|
var _ = ed25519.PublicKey(nil)
|
||||||
var _ = hex.EncodeToString
|
var _ = hex.EncodeToString
|
||||||
|
|
||||||
|
// ── Feed (v2.0.0) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestFeedCreatePost: post commits, indexes, credits the hosting relay.
|
||||||
|
func TestFeedCreatePost(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t) // post author
|
||||||
|
host := newIdentity(t) // hosting relay pubkey
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
// Fund alice + host.
|
||||||
|
const postSize = uint64(200)
|
||||||
|
expectedFee := blockchain.BasePostFee + postSize*blockchain.PostByteFee
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
expectedFee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
time.Sleep(2 * time.Millisecond) // ensure distinct txID (nanosec clock)
|
||||||
|
fundHost := makeTx(blockchain.EventTransfer, val.PubKeyHex(), host.PubKeyHex(),
|
||||||
|
blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundHost})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
hostBalBefore, _ := c.Balance(host.PubKeyHex())
|
||||||
|
|
||||||
|
h := sha256.Sum256([]byte("hello world post body"))
|
||||||
|
postPayload := blockchain.CreatePostPayload{
|
||||||
|
PostID: "post1",
|
||||||
|
ContentHash: h[:],
|
||||||
|
Size: postSize,
|
||||||
|
HostingRelay: host.PubKeyHex(),
|
||||||
|
}
|
||||||
|
postTx := makeTx(
|
||||||
|
blockchain.EventCreatePost,
|
||||||
|
alice.PubKeyHex(), "",
|
||||||
|
0, expectedFee, // Fee = base + size*byte_fee; amount = 0
|
||||||
|
mustJSON(postPayload),
|
||||||
|
)
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
|
||||||
|
mustAddBlock(t, c, b2)
|
||||||
|
|
||||||
|
rec, err := c.Post("post1")
|
||||||
|
if err != nil || rec == nil {
|
||||||
|
t.Fatalf("Post(\"post1\") = %v, %v; want record", rec, err)
|
||||||
|
}
|
||||||
|
if rec.Author != alice.PubKeyHex() {
|
||||||
|
t.Errorf("author: got %q want %q", rec.Author, alice.PubKeyHex())
|
||||||
|
}
|
||||||
|
if rec.Size != postSize {
|
||||||
|
t.Errorf("size: got %d want %d", rec.Size, postSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host should have been credited the full fee.
|
||||||
|
hostBalAfter, _ := c.Balance(host.PubKeyHex())
|
||||||
|
if hostBalAfter != hostBalBefore+expectedFee {
|
||||||
|
t.Errorf("host balance: got %d, want %d (delta %d)",
|
||||||
|
hostBalAfter, hostBalBefore, expectedFee)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostsByAuthor should list it.
|
||||||
|
posts, err := c.PostsByAuthor(alice.PubKeyHex(), 0, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PostsByAuthor: %v", err)
|
||||||
|
}
|
||||||
|
if len(posts) != 1 || posts[0].PostID != "post1" {
|
||||||
|
t.Errorf("PostsByAuthor: got %v, want [post1]", posts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFeedInsufficientFee: size-based fee is enforced.
|
||||||
|
func TestFeedInsufficientFee(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t)
|
||||||
|
host := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
10*blockchain.Token, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
const postSize = uint64(1000)
|
||||||
|
h := sha256.Sum256([]byte("body"))
|
||||||
|
postPayload := blockchain.CreatePostPayload{
|
||||||
|
PostID: "underpaid",
|
||||||
|
ContentHash: h[:],
|
||||||
|
Size: postSize,
|
||||||
|
HostingRelay: host.PubKeyHex(),
|
||||||
|
}
|
||||||
|
// Fee too low — base alone without the size component.
|
||||||
|
// (Must still be ≥ MinFee so the chain-level block validation passes;
|
||||||
|
// the per-event CREATE_POST check is what should reject it.)
|
||||||
|
postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "",
|
||||||
|
0, blockchain.MinFee, mustJSON(postPayload))
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
|
||||||
|
mustAddBlock(t, c, b2) // block commits, the tx is skipped (logged)
|
||||||
|
|
||||||
|
if rec, _ := c.Post("underpaid"); rec != nil {
|
||||||
|
t.Fatalf("post was stored despite insufficient fee: %+v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFeedFollowUnfollow: follow graph round-trips via indices.
|
||||||
|
func TestFeedFollowUnfollow(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t)
|
||||||
|
bob := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
followTx := makeTx(blockchain.EventFollow, alice.PubKeyHex(), bob.PubKeyHex(),
|
||||||
|
0, blockchain.MinFee, mustJSON(blockchain.FollowPayload{}))
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{followTx})
|
||||||
|
mustAddBlock(t, c, b2)
|
||||||
|
|
||||||
|
following, _ := c.Following(alice.PubKeyHex())
|
||||||
|
if len(following) != 1 || following[0] != bob.PubKeyHex() {
|
||||||
|
t.Errorf("Following: got %v, want [%s]", following, bob.PubKeyHex())
|
||||||
|
}
|
||||||
|
followers, _ := c.Followers(bob.PubKeyHex())
|
||||||
|
if len(followers) != 1 || followers[0] != alice.PubKeyHex() {
|
||||||
|
t.Errorf("Followers: got %v, want [%s]", followers, alice.PubKeyHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfollow.
|
||||||
|
unfollowTx := makeTx(blockchain.EventUnfollow, alice.PubKeyHex(), bob.PubKeyHex(),
|
||||||
|
0, blockchain.MinFee, mustJSON(blockchain.UnfollowPayload{}))
|
||||||
|
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{unfollowTx})
|
||||||
|
mustAddBlock(t, c, b3)
|
||||||
|
|
||||||
|
following, _ = c.Following(alice.PubKeyHex())
|
||||||
|
if len(following) != 0 {
|
||||||
|
t.Errorf("Following after unfollow: got %v, want []", following)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFeedLikeUnlike: like toggles + cached count stays consistent.
|
||||||
|
func TestFeedLikeUnlike(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t) // author
|
||||||
|
bob := newIdentity(t) // liker
|
||||||
|
host := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
const postSize = uint64(100)
|
||||||
|
expectedPostFee := blockchain.BasePostFee + postSize*blockchain.PostByteFee
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
expectedPostFee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
fundBob := makeTx(blockchain.EventTransfer, val.PubKeyHex(), bob.PubKeyHex(),
|
||||||
|
5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundBob})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
h := sha256.Sum256([]byte("likeable"))
|
||||||
|
postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "",
|
||||||
|
0, expectedPostFee,
|
||||||
|
mustJSON(blockchain.CreatePostPayload{
|
||||||
|
PostID: "p1", ContentHash: h[:], Size: postSize, HostingRelay: host.PubKeyHex(),
|
||||||
|
}))
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
|
||||||
|
mustAddBlock(t, c, b2)
|
||||||
|
|
||||||
|
likeTx := makeTx(blockchain.EventLikePost, bob.PubKeyHex(), "",
|
||||||
|
0, blockchain.MinFee, mustJSON(blockchain.LikePostPayload{PostID: "p1"}))
|
||||||
|
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{likeTx})
|
||||||
|
mustAddBlock(t, c, b3)
|
||||||
|
|
||||||
|
n, _ := c.LikeCount("p1")
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("LikeCount after like: got %d, want 1", n)
|
||||||
|
}
|
||||||
|
liked, _ := c.HasLiked("p1", bob.PubKeyHex())
|
||||||
|
if !liked {
|
||||||
|
t.Errorf("HasLiked after like: got false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate like — tx is skipped; counter stays at 1.
|
||||||
|
dupTx := makeTx(blockchain.EventLikePost, bob.PubKeyHex(), "",
|
||||||
|
0, blockchain.MinFee, mustJSON(blockchain.LikePostPayload{PostID: "p1"}))
|
||||||
|
b4 := buildBlock(t, b3, val, []*blockchain.Transaction{dupTx})
|
||||||
|
mustAddBlock(t, c, b4)
|
||||||
|
if n2, _ := c.LikeCount("p1"); n2 != 1 {
|
||||||
|
t.Errorf("LikeCount after duplicate: got %d, want 1 (tx should have been skipped)", n2)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlikeTx := makeTx(blockchain.EventUnlikePost, bob.PubKeyHex(), "",
|
||||||
|
0, blockchain.MinFee, mustJSON(blockchain.UnlikePostPayload{PostID: "p1"}))
|
||||||
|
b5 := buildBlock(t, b4, val, []*blockchain.Transaction{unlikeTx})
|
||||||
|
mustAddBlock(t, c, b5)
|
||||||
|
|
||||||
|
n, _ = c.LikeCount("p1")
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("LikeCount after unlike: got %d, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFeedDeletePostByOther: only the author may delete their post.
|
||||||
|
func TestFeedDeletePostByOther(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t)
|
||||||
|
mallory := newIdentity(t) // tries to delete alice's post
|
||||||
|
host := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
const postSize = uint64(100)
|
||||||
|
fee := blockchain.BasePostFee + postSize*blockchain.PostByteFee
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
fee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
fundMallory := makeTx(blockchain.EventTransfer, val.PubKeyHex(), mallory.PubKeyHex(),
|
||||||
|
5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundMallory})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
h := sha256.Sum256([]byte("body"))
|
||||||
|
postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "", 0, fee,
|
||||||
|
mustJSON(blockchain.CreatePostPayload{
|
||||||
|
PostID: "p1", ContentHash: h[:], Size: postSize, HostingRelay: host.PubKeyHex(),
|
||||||
|
}))
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
|
||||||
|
mustAddBlock(t, c, b2)
|
||||||
|
|
||||||
|
// Mallory tries to delete alice's post — block commits, tx is skipped.
|
||||||
|
delTx := makeTx(blockchain.EventDeletePost, mallory.PubKeyHex(), "", 0, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.DeletePostPayload{PostID: "p1"}))
|
||||||
|
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{delTx})
|
||||||
|
mustAddBlock(t, c, b3)
|
||||||
|
rec, _ := c.Post("p1")
|
||||||
|
if rec == nil || rec.Deleted {
|
||||||
|
t.Fatalf("post was deleted by non-author: %+v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused-import lint if fmt ever gets trimmed from the feed tests.
|
||||||
|
var _ = fmt.Sprintf
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ type EventType string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
EventRegisterKey EventType = "REGISTER_KEY"
|
EventRegisterKey EventType = "REGISTER_KEY"
|
||||||
EventCreateChannel EventType = "CREATE_CHANNEL"
|
|
||||||
EventAddMember EventType = "ADD_MEMBER"
|
|
||||||
EventOpenPayChan EventType = "OPEN_PAY_CHAN"
|
EventOpenPayChan EventType = "OPEN_PAY_CHAN"
|
||||||
EventClosePayChan EventType = "CLOSE_PAY_CHAN"
|
EventClosePayChan EventType = "CLOSE_PAY_CHAN"
|
||||||
EventTransfer EventType = "TRANSFER"
|
EventTransfer EventType = "TRANSFER"
|
||||||
@@ -37,6 +35,17 @@ const (
|
|||||||
EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token
|
EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token
|
||||||
EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership
|
EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership
|
||||||
EventBurnNFT EventType = "BURN_NFT" // burn (destroy) an NFT
|
EventBurnNFT EventType = "BURN_NFT" // burn (destroy) an NFT
|
||||||
|
|
||||||
|
// ── Social feed (v2.0.0) ──────────────────────────────────────────────
|
||||||
|
// Replaces the old channel model with a VK/Twitter-style timeline.
|
||||||
|
// Posts are plaintext, publicly readable, size-priced. Bodies live in
|
||||||
|
// the relay feed-mailbox; on-chain we only keep metadata + author.
|
||||||
|
EventCreatePost EventType = "CREATE_POST" // author publishes a post
|
||||||
|
EventDeletePost EventType = "DELETE_POST" // author soft-deletes their post
|
||||||
|
EventFollow EventType = "FOLLOW" // follow another author's feed
|
||||||
|
EventUnfollow EventType = "UNFOLLOW" // unfollow an author
|
||||||
|
EventLikePost EventType = "LIKE_POST" // like a post
|
||||||
|
EventUnlikePost EventType = "UNLIKE_POST" // remove a previous like
|
||||||
)
|
)
|
||||||
|
|
||||||
// Token amounts are stored in micro-tokens (µT).
|
// Token amounts are stored in micro-tokens (µT).
|
||||||
@@ -64,6 +73,31 @@ const (
|
|||||||
// MinContactFee is the minimum amount a sender must pay the recipient when
|
// MinContactFee is the minimum amount a sender must pay the recipient when
|
||||||
// submitting an EventContactRequest (anti-spam; goes directly to recipient).
|
// submitting an EventContactRequest (anti-spam; goes directly to recipient).
|
||||||
MinContactFee uint64 = 5_000 // 0.005 T
|
MinContactFee uint64 = 5_000 // 0.005 T
|
||||||
|
|
||||||
|
// ── Feed pricing (v2.0.0) ─────────────────────────────────────────────
|
||||||
|
// A post's on-chain fee is BasePostFee + bytes_on_disk × PostByteFee.
|
||||||
|
// The fee is paid by the author and credited in full to the hosting
|
||||||
|
// relay (the node that received POST /feed/publish and stored the body).
|
||||||
|
// Size-based pricing is what aligns incentives: a 200-byte tweet is
|
||||||
|
// cheap, a 256 KB video costs ~0.26 T — node operators' storage cost
|
||||||
|
// is covered.
|
||||||
|
//
|
||||||
|
// Note: BasePostFee is set to MinFee (1000 µT) because chain-level block
|
||||||
|
// validation requires every tx's Fee ≥ MinFee. So the true minimum a
|
||||||
|
// post can cost is MinFee + size × PostByteFee. A 0-byte post is
|
||||||
|
// rejected (Size must be > 0), so in practice a ~50-byte text post
|
||||||
|
// costs ~1050 µT (~$0.001 depending on token price).
|
||||||
|
BasePostFee uint64 = 1_000 // 0.001 T flat per post — aligned with MinFee floor
|
||||||
|
PostByteFee uint64 = 1 // 1 µT per byte of stored content
|
||||||
|
|
||||||
|
// MaxPostSize caps a single post's on-wire size (text + attachment, post
|
||||||
|
// compression). Hard limit — node refuses larger envelopes to protect
|
||||||
|
// storage and bandwidth.
|
||||||
|
MaxPostSize uint64 = 256 * 1024 // 256 KiB
|
||||||
|
|
||||||
|
// LikeFee / FollowFee / UnlikeFee / UnfollowFee / DeletePostFee all use
|
||||||
|
// MinFee (1000 µT) — standard tx fee paid to the validator. No extra
|
||||||
|
// cost; these events carry no body.
|
||||||
)
|
)
|
||||||
|
|
||||||
// Transaction is the atomic unit recorded in a block.
|
// Transaction is the atomic unit recorded in a block.
|
||||||
@@ -90,11 +124,66 @@ type RegisterKeyPayload struct {
|
|||||||
X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging
|
X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateChannelPayload is embedded in EventCreateChannel transactions.
|
// ── Feed payloads (v2.0.0) ─────────────────────────────────────────────────
|
||||||
type CreateChannelPayload struct {
|
|
||||||
ChannelID string `json:"channel_id"`
|
// CreatePostPayload is embedded in EventCreatePost transactions. The body
|
||||||
Title string `json:"title"`
|
// itself is NOT stored on-chain — it lives in the relay feed-mailbox keyed
|
||||||
IsPublic bool `json:"is_public"`
|
// by PostID. On-chain we only keep author, size, hash, timestamp and any
|
||||||
|
// reply/quote reference for ordering and proof of authorship.
|
||||||
|
//
|
||||||
|
// PostID is computed client-side as hex(sha256(author || content_hash || ts)[:16])
|
||||||
|
// — same scheme as envelope IDs. Clients include it so the relay can store
|
||||||
|
// the body under a stable key before the chain commit lands.
|
||||||
|
//
|
||||||
|
// HostingRelay is the node pubkey (Ed25519 hex) that accepted the POST
|
||||||
|
// /feed/publish call and holds the body. Readers resolve it via the chain
|
||||||
|
// and fetch the body directly from that relay (or via gossipsub replicas).
|
||||||
|
// The fee is credited to this pub.
|
||||||
|
//
|
||||||
|
// QuoteOf / ReplyTo are mutually exclusive; set at most one. ReplyTo makes
|
||||||
|
// the post a reply in a thread; QuoteOf creates a link/reference block.
|
||||||
|
type CreatePostPayload struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
ContentHash []byte `json:"content_hash"` // sha256 of body-bytes, 32 B
|
||||||
|
Size uint64 `json:"size"` // bytes on disk (compressed)
|
||||||
|
HostingRelay string `json:"hosting_relay"` // hex Ed25519 of storing node
|
||||||
|
ReplyTo string `json:"reply_to,omitempty"` // parent post ID
|
||||||
|
QuoteOf string `json:"quote_of,omitempty"` // referenced post ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePostPayload — author soft-deletes their own post. Stored marker
|
||||||
|
// lets clients hide the post; relay can GC the body on the next sweep.
|
||||||
|
type DeletePostPayload struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FollowPayload / UnfollowPayload — follow graph. tx.From = follower,
|
||||||
|
// tx.To = target. No body.
|
||||||
|
type FollowPayload struct{}
|
||||||
|
type UnfollowPayload struct{}
|
||||||
|
|
||||||
|
// LikePostPayload / UnlikePostPayload — per-post like indicator. tx.From
|
||||||
|
// = liker. The counter is derived on read.
|
||||||
|
type LikePostPayload struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
}
|
||||||
|
type UnlikePostPayload struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostRecord is what we store on-chain under post:<postID>. Consumers of
|
||||||
|
// PostsByAuthor / query endpoints decode this.
|
||||||
|
type PostRecord struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
Author string `json:"author"` // hex Ed25519
|
||||||
|
ContentHash []byte `json:"content_hash"`
|
||||||
|
Size uint64 `json:"size"`
|
||||||
|
HostingRelay string `json:"hosting_relay"`
|
||||||
|
ReplyTo string `json:"reply_to,omitempty"`
|
||||||
|
QuoteOf string `json:"quote_of,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"` // unix seconds (tx timestamp)
|
||||||
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
|
FeeUT uint64 `json:"fee_ut"` // total fee paid
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRelayPayload is embedded in EventRegisterRelay transactions.
|
// RegisterRelayPayload is embedded in EventRegisterRelay transactions.
|
||||||
@@ -241,24 +330,6 @@ type BlockContactPayload struct {
|
|||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelMember records a participant in a channel together with their
|
|
||||||
// X25519 public key. The key is cached on-chain (written during ADD_MEMBER)
|
|
||||||
// so channel senders don't have to fan out a separate /api/identity lookup
|
|
||||||
// per recipient on every message — they GET /api/channels/:id/members
|
|
||||||
// once and seal N envelopes in a loop.
|
|
||||||
type ChannelMember struct {
|
|
||||||
PubKey string `json:"pub_key"` // Ed25519 hex
|
|
||||||
X25519PubKey string `json:"x25519_pub_key"` // optional; empty if member hasn't registered
|
|
||||||
Address string `json:"address"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddMemberPayload is embedded in EventAddMember transactions.
|
|
||||||
// tx.From adds tx.To as a member of the specified channel.
|
|
||||||
// If tx.To is empty, tx.From is added (self-join for public channels).
|
|
||||||
type AddMemberPayload struct {
|
|
||||||
ChannelID string `json:"channel_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddValidatorPayload is embedded in EventAddValidator transactions.
|
// AddValidatorPayload is embedded in EventAddValidator transactions.
|
||||||
// tx.From must already be a validator; tx.To is the new validator's pub key.
|
// tx.From must already be a validator; tx.To is the new validator's pub key.
|
||||||
//
|
//
|
||||||
|
|||||||
42
client-app/.gitignore
vendored
Normal file
42
client-app/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ── Client-app local state ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Dependencies (install via npm ci)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo / Metro caches
|
||||||
|
.expo/
|
||||||
|
.expo-shared/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ipa
|
||||||
|
|
||||||
|
# TypeScript incremental build
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Native prebuild output (Expo managed)
|
||||||
|
/android
|
||||||
|
/ios
|
||||||
93
client-app/README.md
Normal file
93
client-app/README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# DChain Messenger — React Native Client
|
||||||
|
|
||||||
|
E2E-encrypted mobile/desktop messenger built on the DChain blockchain stack.
|
||||||
|
|
||||||
|
**Stack:** React Native · Expo · NativeWind (Tailwind) · TweetNaCl · Zustand
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client-app
|
||||||
|
npm install
|
||||||
|
npx expo start # opens Expo Dev Tools
|
||||||
|
# Press 'i' for iOS simulator, 'a' for Android, 'w' for web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- [Expo Go](https://expo.dev/client) on your phone (for Expo tunnel), or iOS/Android emulator
|
||||||
|
- A running DChain node (see root README for `docker compose up --build -d`)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
client-app/
|
||||||
|
├── app/
|
||||||
|
│ ├── _layout.tsx # Root layout — loads keys, sets up nav
|
||||||
|
│ ├── index.tsx # Welcome / onboarding
|
||||||
|
│ ├── (auth)/
|
||||||
|
│ │ ├── create.tsx # Generate new Ed25519 + X25519 keys
|
||||||
|
│ │ ├── created.tsx # Key created — export reminder
|
||||||
|
│ │ └── import.tsx # Import existing key.json
|
||||||
|
│ └── (app)/
|
||||||
|
│ ├── _layout.tsx # Tab bar — Chats · Wallet · Settings
|
||||||
|
│ ├── chats/
|
||||||
|
│ │ ├── index.tsx # Chat list with contacts
|
||||||
|
│ │ └── [id].tsx # Individual chat with E2E encryption
|
||||||
|
│ ├── requests.tsx # Incoming contact requests
|
||||||
|
│ ├── new-contact.tsx # Add contact by @username or address
|
||||||
|
│ ├── wallet.tsx # Balance + TX history + send
|
||||||
|
│ └── settings.tsx # Node URL, key export, profile
|
||||||
|
├── components/ui/ # shadcn-style components (Button, Card, Input…)
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useMessages.ts # Poll relay inbox, decrypt messages
|
||||||
|
│ ├── useBalance.ts # Poll token balance
|
||||||
|
│ └── useContacts.ts # Load contacts + poll contact requests
|
||||||
|
└── lib/
|
||||||
|
├── api.ts # REST client for all DChain endpoints
|
||||||
|
├── crypto.ts # NaCl box encrypt/decrypt, Ed25519 sign
|
||||||
|
├── storage.ts # SecureStore (keys) + AsyncStorage (data)
|
||||||
|
├── store.ts # Zustand global state
|
||||||
|
├── types.ts # TypeScript interfaces
|
||||||
|
└── utils.ts # cn(), formatAmount(), relativeTime()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cryptography
|
||||||
|
|
||||||
|
| Operation | Algorithm | Library |
|
||||||
|
|-----------|-----------|---------|
|
||||||
|
| Transaction signing | Ed25519 | TweetNaCl `sign` |
|
||||||
|
| Key exchange | X25519 (Curve25519) | TweetNaCl `box` |
|
||||||
|
| Message encryption | NaCl box (XSalsa20-Poly1305) | TweetNaCl `box` |
|
||||||
|
| Key storage | Device secure enclave | expo-secure-store |
|
||||||
|
|
||||||
|
Messages are encrypted as:
|
||||||
|
```
|
||||||
|
Envelope {
|
||||||
|
sender_pub: <X25519 hex> // sender's public key
|
||||||
|
recipient_pub: <X25519 hex> // recipient's public key
|
||||||
|
nonce: <24-byte hex> // random per message
|
||||||
|
ciphertext: <hex> // NaCl box(plaintext, nonce, sender_priv, recipient_pub)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connect to your node
|
||||||
|
|
||||||
|
1. Start the DChain node: `docker compose up --build -d`
|
||||||
|
2. Open the app → Settings → Node URL → `http://YOUR_IP:8081`
|
||||||
|
3. If using Expo Go on physical device: your PC and phone must be on the same network, or use `npx expo start --tunnel`
|
||||||
|
|
||||||
|
## Key File Format
|
||||||
|
|
||||||
|
The `key.json` exported/imported by the app:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pub_key": "26018d40...", // Ed25519 public key (64 hex chars)
|
||||||
|
"priv_key": "...", // Ed25519 private key (128 hex chars)
|
||||||
|
"x25519_pub": "...", // X25519 public key (64 hex chars)
|
||||||
|
"x25519_priv": "..." // X25519 private key (64 hex chars)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the same format as the Go node's `--key` flag.
|
||||||
69
client-app/app.json
Normal file
69
client-app/app.json
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "DChain Messenger",
|
||||||
|
"slug": "dchain-messenger",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"backgroundColor": "#000000",
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": false,
|
||||||
|
"bundleIdentifier": "com.dchain.messenger",
|
||||||
|
"infoPlist": {
|
||||||
|
"NSMicrophoneUsageDescription": "Allow DChain to record voice messages and video.",
|
||||||
|
"NSCameraUsageDescription": "Allow DChain to record video messages and scan QR codes.",
|
||||||
|
"NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.dchain.messenger",
|
||||||
|
"softwareKeyboardLayoutMode": "pan",
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.RECORD_AUDIO",
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.READ_EXTERNAL_STORAGE",
|
||||||
|
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||||
|
"android.permission.MODIFY_AUDIO_SETTINGS"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Allow DChain to record video messages and scan QR codes.",
|
||||||
|
"microphonePermission": "Allow DChain to record audio with video."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"photosPermission": "Allow DChain to attach photos and videos.",
|
||||||
|
"cameraPermission": "Allow DChain to take photos."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-audio",
|
||||||
|
{
|
||||||
|
"microphonePermission": "Allow DChain to record voice messages."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-video"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": false
|
||||||
|
},
|
||||||
|
"scheme": "dchain",
|
||||||
|
"extra": {
|
||||||
|
"router": {},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "28d7743e-6745-460f-8ce5-c971c5c297b6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
client-app/app/(app)/_layout.tsx
Normal file
99
client-app/app/(app)/_layout.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Main app layout — кастомный `<AnimatedSlot>` + `<NavBar>`.
|
||||||
|
*
|
||||||
|
* AnimatedSlot — обёртка над Slot'ом, анимирующая переход при смене
|
||||||
|
* pathname'а. Направление анимации вычисляется по TAB_ORDER: если
|
||||||
|
* целевой tab "справа" — слайд из правой стороны, "слева" — из левой.
|
||||||
|
*
|
||||||
|
* Intra-tab навигация (chats/index → chats/[id]) обслуживается вложенным
|
||||||
|
* Stack'ом в chats/_layout.tsx — там остаётся нативная slide-from-right
|
||||||
|
* анимация, чтобы chat detail "выезжал" поверх списка.
|
||||||
|
*
|
||||||
|
* Side-effects (balance, contacts, WS auth, dev seed) — монтируются здесь
|
||||||
|
* один раз; переходы между tab'ами их не перезапускают.
|
||||||
|
*/
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { router, usePathname } from 'expo-router';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { useBalance } from '@/hooks/useBalance';
|
||||||
|
import { useContacts } from '@/hooks/useContacts';
|
||||||
|
import { useWellKnownContracts } from '@/hooks/useWellKnownContracts';
|
||||||
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
|
import { useGlobalInbox } from '@/hooks/useGlobalInbox';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
import { NavBar } from '@/components/NavBar';
|
||||||
|
import { AnimatedSlot } from '@/components/AnimatedSlot';
|
||||||
|
import { saveContact } from '@/lib/storage';
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const requests = useStore(s => s.requests);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// NavBar прячется на full-screen экранах:
|
||||||
|
// - chat detail
|
||||||
|
// - compose (new post modal)
|
||||||
|
// - feed sub-routes (post detail, hashtag search)
|
||||||
|
// - tx detail
|
||||||
|
const hideNav =
|
||||||
|
/^\/chats\/[^/]+/.test(pathname) ||
|
||||||
|
pathname === '/compose' ||
|
||||||
|
/^\/feed\/.+/.test(pathname) ||
|
||||||
|
/^\/tx\/.+/.test(pathname);
|
||||||
|
|
||||||
|
useBalance();
|
||||||
|
useContacts();
|
||||||
|
useWellKnownContracts();
|
||||||
|
useNotifications(); // permission + tap-handler
|
||||||
|
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(() => {
|
||||||
|
const ws = getWSClient();
|
||||||
|
if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });
|
||||||
|
else ws.setAuthCreds(null);
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (keyFile === null) {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
if (!useStore.getState().keyFile) router.replace('/');
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000' }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<AnimatedSlot />
|
||||||
|
</View>
|
||||||
|
{!hideNav && (
|
||||||
|
<NavBar
|
||||||
|
bottomInset={insets.bottom}
|
||||||
|
requestCount={requests.length}
|
||||||
|
notifCount={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
554
client-app/app/(app)/chats/[id].tsx
Normal file
554
client-app/app/(app)/chats/[id].tsx
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
/**
|
||||||
|
* Chat detail screen — верстка по референсу (X-style Messages).
|
||||||
|
*
|
||||||
|
* Структура:
|
||||||
|
* [Header: back + avatar + name + typing-status | ⋯]
|
||||||
|
* [FlatList: MessageBubble + DaySeparator, group-aware]
|
||||||
|
* [Composer: floating, supports edit/reply banner]
|
||||||
|
*
|
||||||
|
* Весь presentational код вынесен в components/chat/*:
|
||||||
|
* - MessageBubble (own/peer rendering)
|
||||||
|
* - DaySeparator (day label между группами)
|
||||||
|
* - buildRows (чистая функция группировки)
|
||||||
|
* Date-форматирование — lib/dates.ts.
|
||||||
|
*/
|
||||||
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, FlatList, KeyboardAvoidingView, Platform, Alert, Pressable,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { useMessages } from '@/hooks/useMessages';
|
||||||
|
import { encryptMessage } from '@/lib/crypto';
|
||||||
|
import { sendEnvelope } from '@/lib/api';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||||
|
import { randomId, safeBack } from '@/lib/utils';
|
||||||
|
import type { Message } from '@/lib/types';
|
||||||
|
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
import { Composer, ComposerMode } from '@/components/Composer';
|
||||||
|
import { AttachmentMenu } from '@/components/chat/AttachmentMenu';
|
||||||
|
import { VideoCircleRecorder } from '@/components/chat/VideoCircleRecorder';
|
||||||
|
import { clearContactNotifications } from '@/hooks/useNotifications';
|
||||||
|
import { MessageBubble } from '@/components/chat/MessageBubble';
|
||||||
|
import { DaySeparator } from '@/components/chat/DaySeparator';
|
||||||
|
import { buildRows, Row } from '@/components/chat/rows';
|
||||||
|
import type { Attachment } from '@/lib/types';
|
||||||
|
|
||||||
|
function shortAddr(a: string, n = 6) {
|
||||||
|
if (!a) return '—';
|
||||||
|
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatScreen() {
|
||||||
|
const { id: contactAddress } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const messages = useStore(s => s.messages);
|
||||||
|
const setMsgs = useStore(s => s.setMessages);
|
||||||
|
const appendMsg = useStore(s => s.appendMessage);
|
||||||
|
const clearUnread = useStore(s => s.clearUnread);
|
||||||
|
|
||||||
|
// При открытии чата: сбрасываем unread-счётчик и dismiss'им банер.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contactAddress) return;
|
||||||
|
clearUnread(contactAddress);
|
||||||
|
clearContactNotifications(contactAddress);
|
||||||
|
}, [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 chatMsgs = messages[contactAddress ?? ''] ?? [];
|
||||||
|
const listRef = useRef<FlatList>(null);
|
||||||
|
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [peerTyping, setPeerTyping] = useState(false);
|
||||||
|
const [composeMode, setComposeMode] = useState<ComposerMode>({ kind: 'new' });
|
||||||
|
const [pendingAttach, setPendingAttach] = useState<Attachment | null>(null);
|
||||||
|
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
|
||||||
|
const [videoCircleOpen, setVideoCircleOpen] = useState(false);
|
||||||
|
/**
|
||||||
|
* ID сообщения, которое сейчас подсвечено (после jump-to-reply). На
|
||||||
|
* ~2 секунды backgroundColor bubble'а мерцает accent-цветом.
|
||||||
|
* `null` — ничего не подсвечено.
|
||||||
|
*/
|
||||||
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||||
|
const highlightClearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// ── Selection mode ───────────────────────────────────────────────────
|
||||||
|
// Активируется первым long-press'ом на bubble'е. Header меняется на
|
||||||
|
// toolbar с Forward/Delete/Cancel. Tap по bubble'у в selection mode
|
||||||
|
// toggle'ит принадлежность к выборке. Cancel сбрасывает всё.
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const selectionMode = selectedIds.size > 0;
|
||||||
|
|
||||||
|
useMessages(contact?.x25519Pub ?? '');
|
||||||
|
|
||||||
|
// ── Typing indicator от peer'а ─────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile?.x25519_pub) return;
|
||||||
|
const ws = getWSClient();
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const off = ws.subscribe('typing:' + keyFile.x25519_pub, (frame) => {
|
||||||
|
if (frame.event !== 'typing') return;
|
||||||
|
const d = frame.data as { from?: string } | undefined;
|
||||||
|
if (!contact?.x25519Pub || d?.from !== contact.x25519Pub) return;
|
||||||
|
setPeerTyping(true);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => setPeerTyping(false), 3_000);
|
||||||
|
});
|
||||||
|
return () => { off(); if (timer) clearTimeout(timer); };
|
||||||
|
}, [keyFile?.x25519_pub, contact?.x25519Pub]);
|
||||||
|
|
||||||
|
// Throttled типinginisi-ping собеседнику.
|
||||||
|
const lastTypingSent = useRef(0);
|
||||||
|
const onChange = useCallback((t: string) => {
|
||||||
|
setText(t);
|
||||||
|
if (!contact?.x25519Pub || !t.trim()) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTypingSent.current < 2_000) return;
|
||||||
|
lastTypingSent.current = now;
|
||||||
|
getWSClient().sendTyping(contact.x25519Pub);
|
||||||
|
}, [contact?.x25519Pub]);
|
||||||
|
|
||||||
|
// Восстановить сообщения из persistent-storage при первом заходе в чат.
|
||||||
|
//
|
||||||
|
// Важно: НЕ перезаписываем store пустым массивом — это стёрло бы
|
||||||
|
// содержимое, которое уже лежит в zustand (только что полученные по
|
||||||
|
// WS сообщения пока монтировались). Если в кэше что-то есть — мержим:
|
||||||
|
// берём max(cached, in-store) по id.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contactAddress) return;
|
||||||
|
loadMessages(contactAddress).then(cached => {
|
||||||
|
if (!cached || cached.length === 0) return; // кэш пуст → оставляем store
|
||||||
|
const existing = useStore.getState().messages[contactAddress] ?? [];
|
||||||
|
const byId = new Map<string, Message>();
|
||||||
|
for (const m of cached as Message[]) byId.set(m.id, m);
|
||||||
|
for (const m of existing) byId.set(m.id, m); // store-версия свежее
|
||||||
|
const merged = Array.from(byId.values()).sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
setMsgs(contactAddress, merged);
|
||||||
|
});
|
||||||
|
}, [contactAddress, setMsgs]);
|
||||||
|
|
||||||
|
const name = isSavedMessages
|
||||||
|
? 'Saved Messages'
|
||||||
|
: contact?.username
|
||||||
|
? `@${contact.username}`
|
||||||
|
: contact?.alias ?? shortAddr(contactAddress ?? '');
|
||||||
|
|
||||||
|
// ── Compose actions ────────────────────────────────────────────────────
|
||||||
|
const cancelCompose = useCallback(() => {
|
||||||
|
setComposeMode({ kind: 'new' });
|
||||||
|
setText('');
|
||||||
|
setPendingAttach(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// buildRows выдаёт chronological [old → new]. FlatList работает
|
||||||
|
// inverted, поэтому reverse'им: newest = data[0] = снизу экрана.
|
||||||
|
// Определено тут (не позже) чтобы handlers типа onJumpToReply могли
|
||||||
|
// искать индексы по id без forward-declaration.
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const chrono = buildRows(chatMsgs);
|
||||||
|
return [...chrono].reverse();
|
||||||
|
}, [chatMsgs]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core send logic. Принимает явные text + attachment чтобы избегать
|
||||||
|
* race'а со state updates при моментальной отправке голоса/видео.
|
||||||
|
* Если передано null/undefined — берём из текущего state.
|
||||||
|
*/
|
||||||
|
const sendCore = useCallback(async (
|
||||||
|
textArg: string | null = null,
|
||||||
|
attachArg: Attachment | null | undefined = undefined,
|
||||||
|
) => {
|
||||||
|
if (!keyFile || !contact) return;
|
||||||
|
const actualText = textArg !== null ? textArg : text;
|
||||||
|
const actualAttach = attachArg !== undefined ? attachArg : pendingAttach;
|
||||||
|
const hasText = !!actualText.trim();
|
||||||
|
const hasAttach = !!actualAttach;
|
||||||
|
if (!hasText && !hasAttach) return;
|
||||||
|
if (!isSavedMessages && !contact.x25519Pub) {
|
||||||
|
Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (composeMode.kind === 'edit') {
|
||||||
|
const target = chatMsgs.find(m => m.text === composeMode.text && m.mine);
|
||||||
|
if (!target) { cancelCompose(); return; }
|
||||||
|
const updated: Message = { ...target, text: actualText.trim(), edited: true };
|
||||||
|
setMsgs(contact.address, chatMsgs.map(m => m.id === target.id ? updated : m));
|
||||||
|
cancelCompose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
// 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(
|
||||||
|
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
|
||||||
|
);
|
||||||
|
await sendEnvelope({
|
||||||
|
senderPub: keyFile.x25519_pub,
|
||||||
|
recipientPub: contact.x25519Pub,
|
||||||
|
senderEd25519Pub: keyFile.pub_key,
|
||||||
|
nonce, ciphertext,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg: Message = {
|
||||||
|
id: randomId(),
|
||||||
|
from: keyFile.x25519_pub,
|
||||||
|
text: actualText.trim(),
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
mine: true,
|
||||||
|
read: false,
|
||||||
|
edited: false,
|
||||||
|
attachment: actualAttach ?? undefined,
|
||||||
|
replyTo: composeMode.kind === 'reply'
|
||||||
|
? { id: composeMode.msgId, text: composeMode.preview, author: composeMode.author }
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
appendMsg(contact.address, msg);
|
||||||
|
await appendMessage(contact.address, msg);
|
||||||
|
setText('');
|
||||||
|
setPendingAttach(null);
|
||||||
|
setComposeMode({ kind: 'new' });
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Send failed', e?.message ?? 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
text, keyFile, contact, composeMode, chatMsgs, isSavedMessages,
|
||||||
|
setMsgs, cancelCompose, appendMsg, pendingAttach,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// UI send button
|
||||||
|
const send = useCallback(() => sendCore(), [sendCore]);
|
||||||
|
|
||||||
|
// ── Selection handlers ───────────────────────────────────────────────
|
||||||
|
// Long-press — входим в selection mode и сразу отмечаем это сообщение.
|
||||||
|
const onMessageLongPress = useCallback((m: Message) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(m.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Tap в selection mode — toggle принадлежности.
|
||||||
|
const onMessageTap = useCallback((m: Message) => {
|
||||||
|
if (!selectionMode) return;
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(m.id)) next.delete(m.id); else next.add(m.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [selectionMode]);
|
||||||
|
|
||||||
|
const cancelSelection = useCallback(() => setSelectedIds(new Set()), []);
|
||||||
|
|
||||||
|
// ── Swipe-to-reply ──────────────────────────────────────────────────
|
||||||
|
const onMessageReply = useCallback((m: Message) => {
|
||||||
|
if (selectionMode) return;
|
||||||
|
setComposeMode({
|
||||||
|
kind: 'reply',
|
||||||
|
msgId: m.id,
|
||||||
|
author: m.mine ? 'You' : name,
|
||||||
|
preview: m.text || (m.attachment ? `(${m.attachment.kind})` : ''),
|
||||||
|
});
|
||||||
|
}, [name, selectionMode]);
|
||||||
|
|
||||||
|
// ── Profile navigation (tap на аватарке / имени peer'а) ──────────────
|
||||||
|
const onOpenPeerProfile = useCallback(() => {
|
||||||
|
if (!contactAddress) return;
|
||||||
|
router.push(`/(app)/profile/${contactAddress}` as never);
|
||||||
|
}, [contactAddress]);
|
||||||
|
|
||||||
|
// ── Jump to reply: tap по quoted-блоку в bubble'е ────────────────────
|
||||||
|
// Скроллим FlatList к оригинальному сообщению и зажигаем highlight
|
||||||
|
// на ~2 секунды (highlightedId state + useEffect-driven анимация в
|
||||||
|
// MessageBubble.highlightAnim).
|
||||||
|
const onJumpToReply = useCallback((originalId: string) => {
|
||||||
|
const idx = rows.findIndex(r => r.kind === 'msg' && r.msg.id === originalId);
|
||||||
|
if (idx < 0) {
|
||||||
|
// Сообщение не найдено (возможно удалено или ушло за пагинацию).
|
||||||
|
// Silently no-op.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
listRef.current?.scrollToIndex({
|
||||||
|
index: idx,
|
||||||
|
animated: true,
|
||||||
|
viewPosition: 0.3, // оригинал — чуть выше середины экрана, не прямо в центре
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// scrollToIndex может throw'нуть если индекс за пределами рендера;
|
||||||
|
// fallback: scrollToOffset на приблизительную позицию.
|
||||||
|
}
|
||||||
|
setHighlightedId(originalId);
|
||||||
|
if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current);
|
||||||
|
highlightClearTimer.current = setTimeout(() => {
|
||||||
|
setHighlightedId(null);
|
||||||
|
highlightClearTimer.current = null;
|
||||||
|
}, 2000);
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Selection actions ────────────────────────────────────────────────
|
||||||
|
const deleteSelected = useCallback(() => {
|
||||||
|
if (selectedIds.size === 0 || !contact) return;
|
||||||
|
Alert.alert(
|
||||||
|
`Delete ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}?`,
|
||||||
|
'This removes them from your device. Other participants keep their copies.',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
setMsgs(contact.address, chatMsgs.filter(m => !selectedIds.has(m.id)));
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [selectedIds, contact, chatMsgs, setMsgs]);
|
||||||
|
|
||||||
|
const forwardSelected = useCallback(() => {
|
||||||
|
// Forward UI ещё не реализован — показываем stub. Пример потока:
|
||||||
|
// 1. открыть "Forward to…" screen со списком контактов
|
||||||
|
// 2. для каждого выбранного контакта — sendEnvelope с оригинальным
|
||||||
|
// текстом, timestamp=now
|
||||||
|
Alert.alert(
|
||||||
|
`Forward ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}`,
|
||||||
|
'Contact-picker screen is coming in the next iteration. For now, copy the text and paste.',
|
||||||
|
[{ text: 'OK' }],
|
||||||
|
);
|
||||||
|
}, [selectedIds]);
|
||||||
|
|
||||||
|
// Copy доступен только когда выделено ровно одно сообщение.
|
||||||
|
const copySelected = useCallback(async () => {
|
||||||
|
if (selectedIds.size !== 1) return;
|
||||||
|
const id = [...selectedIds][0];
|
||||||
|
const msg = chatMsgs.find(m => m.id === id);
|
||||||
|
if (!msg) return;
|
||||||
|
await Clipboard.setStringAsync(msg.text);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, [selectedIds, chatMsgs]);
|
||||||
|
|
||||||
|
// В group-чатах над peer-сообщениями рисуется имя отправителя и его
|
||||||
|
// аватар (group = несколько участников). В DM (direct) и каналах
|
||||||
|
// отправитель ровно один, поэтому имя/аватар не нужны — убираем.
|
||||||
|
const withSenderMeta = contact?.kind === 'group';
|
||||||
|
|
||||||
|
const renderRow = ({ item }: { item: Row }) => {
|
||||||
|
if (item.kind === 'sep') return <DaySeparator label={item.label} />;
|
||||||
|
return (
|
||||||
|
<MessageBubble
|
||||||
|
msg={item.msg}
|
||||||
|
peerName={name}
|
||||||
|
peerAddress={contactAddress}
|
||||||
|
withSenderMeta={withSenderMeta}
|
||||||
|
showName={item.showName}
|
||||||
|
showAvatar={item.showAvatar}
|
||||||
|
onReply={onMessageReply}
|
||||||
|
onLongPress={onMessageLongPress}
|
||||||
|
onTap={onMessageTap}
|
||||||
|
onOpenProfile={onOpenPeerProfile}
|
||||||
|
onJumpToReply={onJumpToReply}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
selected={selectedIds.has(item.msg.id)}
|
||||||
|
highlighted={highlightedId === item.msg.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1, backgroundColor: '#000000' }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
// Увеличенный offset: composer поднимается выше клавиатуры с заметным
|
||||||
|
// зазором (20px на iOS, 10px на Android) — пользователь не видит
|
||||||
|
// прилипания к верхнему краю клавиатуры.
|
||||||
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 20 : 10}
|
||||||
|
>
|
||||||
|
{/* Header — использует общий компонент <Header>, чтобы соблюдать
|
||||||
|
правила шапки приложения (left slot / centered title / right slot). */}
|
||||||
|
<View style={{ paddingTop: insets.top, backgroundColor: '#000000' }}>
|
||||||
|
{selectionMode ? (
|
||||||
|
<Header
|
||||||
|
divider
|
||||||
|
left={<IconButton icon="close" size={36} onPress={cancelSelection} />}
|
||||||
|
title={`${selectedIds.size} selected`}
|
||||||
|
right={
|
||||||
|
<>
|
||||||
|
{selectedIds.size === 1 && (
|
||||||
|
<IconButton icon="copy-outline" size={36} onPress={copySelected} />
|
||||||
|
)}
|
||||||
|
<IconButton icon="arrow-redo-outline" size={36} onPress={forwardSelected} />
|
||||||
|
<IconButton icon="trash-outline" size={36} onPress={deleteSelected} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Header
|
||||||
|
divider
|
||||||
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
|
title={
|
||||||
|
<Pressable
|
||||||
|
onPress={onOpenPeerProfile}
|
||||||
|
hitSlop={4}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
|
||||||
|
>
|
||||||
|
<Avatar name={name} address={contactAddress} size={28} saved={isSavedMessages} />
|
||||||
|
<View style={{ minWidth: 0, flexShrink: 1 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{peerTyping && (
|
||||||
|
<Text style={{ color: '#1d9bf0', fontSize: 11, fontWeight: '500' }}>
|
||||||
|
typing…
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!peerTyping && !isSavedMessages && !contact?.x25519Pub && (
|
||||||
|
<Text style={{ color: '#f0b35a', fontSize: 11 }}>
|
||||||
|
waiting for key
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
right={<IconButton icon="ellipsis-horizontal" size={36} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Messages — inverted: data[0] рендерится снизу, последующее —
|
||||||
|
выше. Это стандартный chat-паттерн: FlatList сразу монтируется
|
||||||
|
с "scroll position at bottom" без ручного scrollToEnd, и новые
|
||||||
|
сообщения (добавляемые в начало reversed-массива) появляются
|
||||||
|
внизу естественно. Никаких jerk'ов при открытии. */}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
// Empty state is rendered as a plain View instead of
|
||||||
|
// ListEmptyComponent on an inverted FlatList — the previous
|
||||||
|
// `transform: [{ scaleY: -1 }]` un-flip trick was rendering
|
||||||
|
// text mirrored on some Android builds (RTL-aware layout),
|
||||||
|
// giving us the "say hi…" backwards bug.
|
||||||
|
<View style={{
|
||||||
|
flex: 1, alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingHorizontal: 32, gap: 10,
|
||||||
|
}}>
|
||||||
|
<Avatar
|
||||||
|
name={name}
|
||||||
|
address={contactAddress}
|
||||||
|
size={72}
|
||||||
|
saved={isSavedMessages}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
|
||||||
|
{isSavedMessages ? 'Notes to self' : `Say hi to ${name}`}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||||
|
{isSavedMessages
|
||||||
|
? 'Anything you send here stays on your device — use it as a scratchpad for links, drafts, or files.'
|
||||||
|
: 'Your messages are end-to-end encrypted.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
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, прибит к низу. */}
|
||||||
|
<View style={{ paddingBottom: Math.max(insets.bottom, 4) + 6, backgroundColor: '#000000' }}>
|
||||||
|
<Composer
|
||||||
|
mode={composeMode}
|
||||||
|
onCancelMode={cancelCompose}
|
||||||
|
text={text}
|
||||||
|
onChangeText={onChange}
|
||||||
|
onSend={send}
|
||||||
|
sending={sending}
|
||||||
|
onAttach={() => setAttachMenuOpen(true)}
|
||||||
|
attachment={pendingAttach}
|
||||||
|
onClearAttach={() => setPendingAttach(null)}
|
||||||
|
onFinishVoice={(att) => {
|
||||||
|
// Voice отправляется сразу — sendCore получает attachment
|
||||||
|
// явным аргументом, минуя state-задержку.
|
||||||
|
sendCore('', att);
|
||||||
|
}}
|
||||||
|
onStartVideoCircle={() => setVideoCircleOpen(true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<AttachmentMenu
|
||||||
|
visible={attachMenuOpen}
|
||||||
|
onClose={() => setAttachMenuOpen(false)}
|
||||||
|
onPick={(att) => setPendingAttach(att)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VideoCircleRecorder
|
||||||
|
visible={videoCircleOpen}
|
||||||
|
onClose={() => setVideoCircleOpen(false)}
|
||||||
|
onFinish={(att) => {
|
||||||
|
// Video-circle тоже отправляется сразу.
|
||||||
|
sendCore('', att);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
client-app/app/(app)/chats/_layout.tsx
Normal file
28
client-app/app/(app)/chats/_layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* chats/_layout — вложенный Stack для chats/index и chats/[id].
|
||||||
|
*
|
||||||
|
* animation: 'none' — переходы между index и [id] анимирует родительский
|
||||||
|
* AnimatedSlot (140ms, Easing.out cubic), обеспечивая единую скорость и
|
||||||
|
* кривую между:
|
||||||
|
* - chat open/close (index ↔ [id])
|
||||||
|
* - tab switches (chats ↔ wallet и т.д.)
|
||||||
|
* - sub-route open/close (settings, profile)
|
||||||
|
*
|
||||||
|
* gestureEnabled: true оставлен на случай если пользователь использует
|
||||||
|
* нативный iOS edge-swipe — он вызовет router.back(), анимация пройдёт
|
||||||
|
* через AnimatedSlot.
|
||||||
|
*/
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function ChatsLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
animation: 'none',
|
||||||
|
contentStyle: { backgroundColor: '#000000' },
|
||||||
|
gestureEnabled: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
client-app/app/(app)/chats/index.tsx
Normal file
113
client-app/app/(app)/chats/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Messages screen — список чатов в стиле референса.
|
||||||
|
*
|
||||||
|
* ┌ safe-area top
|
||||||
|
* │ TabHeader (title зависит от connection state)
|
||||||
|
* │ ─ FlatList (chat tiles) ─
|
||||||
|
* └ NavBar (external)
|
||||||
|
*
|
||||||
|
* Фильтры и search убраны — лист один поток; requests доступны через
|
||||||
|
* NavBar → notifications tab. FAB composer'а тоже убран (чат-лист
|
||||||
|
* просто отражает существующие беседы, создание новых — через tab
|
||||||
|
* "New chat" в NavBar'е).
|
||||||
|
*/
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { View, Text, FlatList } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
|
||||||
|
import type { Contact, Message } from '@/lib/types';
|
||||||
|
|
||||||
|
import { TabHeader } from '@/components/TabHeader';
|
||||||
|
import { ChatTile } from '@/components/ChatTile';
|
||||||
|
|
||||||
|
export default function ChatsScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const messages = useStore(s => s.messages);
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
|
// Статус подключения: online / connecting / offline.
|
||||||
|
// Название шапки и цвет pip'а на аватаре зависят от него.
|
||||||
|
const connStatus = useConnectionStatus();
|
||||||
|
|
||||||
|
const headerTitle =
|
||||||
|
connStatus === 'online' ? 'Messages' :
|
||||||
|
connStatus === 'connecting' ? 'Connecting…' :
|
||||||
|
'Waiting for internet';
|
||||||
|
|
||||||
|
const dotColor =
|
||||||
|
connStatus === 'online' ? '#3ba55d' : // green
|
||||||
|
connStatus === 'connecting' ? '#f0b35a' : // amber
|
||||||
|
'#f4212e'; // red
|
||||||
|
|
||||||
|
const lastOf = (c: Contact): Message | null => {
|
||||||
|
const msgs = messages[c.address];
|
||||||
|
return msgs && msgs.length ? msgs[msgs.length - 1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сортировка по последней активности. Saved Messages (self-chat) всегда
|
||||||
|
// закреплён сверху — это "Избранное", бессмысленно конкурировать с ним
|
||||||
|
// по recency'и обычным чатам.
|
||||||
|
const selfAddr = keyFile?.pub_key;
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const saved = selfAddr ? contacts.find(c => c.address === selfAddr) : undefined;
|
||||||
|
const rest = contacts
|
||||||
|
.filter(c => c.address !== selfAddr)
|
||||||
|
.map(c => ({ c, last: lastOf(c) }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
|
||||||
|
const kb = b.last ? b.last.timestamp : b.c.addedAt / 1000;
|
||||||
|
return kb - ka;
|
||||||
|
})
|
||||||
|
.map(x => x.c);
|
||||||
|
return saved ? [saved, ...rest] : rest;
|
||||||
|
}, [contacts, messages, selfAddr]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<TabHeader title={headerTitle} profileDotColor={dotColor} />
|
||||||
|
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<FlatList
|
||||||
|
data={sorted}
|
||||||
|
keyExtractor={c => c.address}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ChatTile
|
||||||
|
contact={item}
|
||||||
|
lastMessage={lastOf(item)}
|
||||||
|
saved={item.address === selfAddr}
|
||||||
|
onPress={() => router.push(`/(app)/chats/${item.address}` as never)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={{ paddingBottom: 40, flexGrow: 1 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<View
|
||||||
|
pointerEvents="box-none"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0, right: 0, top: 0, bottom: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="chatbubbles-outline" size={42} color="#3a3a3a" style={{ marginBottom: 10 }} />
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginBottom: 6 }}>
|
||||||
|
No chats yet
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||||
|
Use the search tab in the navbar to add your first contact.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
402
client-app/app/(app)/compose.tsx
Normal file
402
client-app/app/(app)/compose.tsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
/**
|
||||||
|
* Post composer — full-screen modal for writing a new post.
|
||||||
|
*
|
||||||
|
* Twitter-style layout:
|
||||||
|
* Header: [✕] (draft-ish) [Опубликовать button]
|
||||||
|
* Body: [avatar] [multiline TextInput autogrow]
|
||||||
|
* [hashtags preview chips]
|
||||||
|
* [attachment preview + remove button]
|
||||||
|
* Footer: [📷 attach] ··· [<count / 4000>] [~fee estimate]
|
||||||
|
*
|
||||||
|
* The flow:
|
||||||
|
* 1. User types content; hashtags auto-parse for preview
|
||||||
|
* 2. (Optional) pick image — client-side compression (expo-image-manipulator)
|
||||||
|
* → resize to 1080px max, JPEG quality 50
|
||||||
|
* 3. Tap "Опубликовать" → confirmation modal with fee
|
||||||
|
* 4. Confirm → publishAndCommit() → navigate to post detail
|
||||||
|
*
|
||||||
|
* Failure modes:
|
||||||
|
* - Size overflow (>256 KiB): blocked client-side with hint to compress
|
||||||
|
* further or drop attachment
|
||||||
|
* - Insufficient balance: show humanised error from submitTx
|
||||||
|
* - Network down: toast "нет связи, попробуйте снова"
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, Pressable, Alert, Image, KeyboardAvoidingView,
|
||||||
|
Platform, ActivityIndicator, ScrollView, Linking,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import * as ImageManipulator from 'expo-image-manipulator';
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { publishAndCommit, formatFee } from '@/lib/feed';
|
||||||
|
import { humanizeTxError, getBalance } from '@/lib/api';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
|
const MAX_CONTENT_LENGTH = 4000;
|
||||||
|
const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize
|
||||||
|
const IMAGE_MAX_DIM = 1080;
|
||||||
|
// 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 {
|
||||||
|
uri: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComposeScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const username = useStore(s => s.username);
|
||||||
|
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [attach, setAttach] = useState<Attachment | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [picking, setPicking] = useState(false);
|
||||||
|
const [balance, setBalance] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Fetch balance once so we can warn before publishing.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
// Estimated fee mirrors server's formula exactly. Displayed to the user
|
||||||
|
// so they aren't surprised by a debit.
|
||||||
|
const estimatedFee = useMemo(() => {
|
||||||
|
const size = (new TextEncoder().encode(content)).length + (attach?.size ?? 0) + 128;
|
||||||
|
return 1000 + size; // base 1000 + 1 µT/byte (matches blockchain constants)
|
||||||
|
}, [content, attach]);
|
||||||
|
|
||||||
|
const totalBytes = useMemo(() => {
|
||||||
|
return (new TextEncoder().encode(content)).length + (attach?.size ?? 0) + 128;
|
||||||
|
}, [content, attach]);
|
||||||
|
|
||||||
|
const hashtags = useMemo(() => {
|
||||||
|
const matches = content.match(/#[A-Za-z0-9_\u0400-\u04FF]{1,40}/g) || [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return matches
|
||||||
|
.map(m => m.slice(1).toLowerCase())
|
||||||
|
.filter(t => !seen.has(t) && seen.add(t));
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const canPublish = !busy && (content.trim().length > 0 || attach !== null)
|
||||||
|
&& totalBytes <= MAX_POST_BYTES;
|
||||||
|
|
||||||
|
const onPickImage = async () => {
|
||||||
|
if (picking) return;
|
||||||
|
setPicking(true);
|
||||||
|
try {
|
||||||
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!perm.granted) {
|
||||||
|
Alert.alert(
|
||||||
|
'Photo access required',
|
||||||
|
'Please enable photo library access in Settings.',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel' },
|
||||||
|
{ text: 'Settings', onPress: () => Linking.openSettings() },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||||
|
quality: 1,
|
||||||
|
exif: false, // privacy: ask picker not to return EXIF
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.assets[0]) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
|
||||||
|
// Client-side compression: resize + re-encode. This is the FIRST
|
||||||
|
// scrub pass — server will do another one (mandatory) before storing.
|
||||||
|
const manipulated = await ImageManipulator.manipulateAsync(
|
||||||
|
asset.uri,
|
||||||
|
[{ resize: { width: IMAGE_MAX_DIM } }],
|
||||||
|
{ compress: IMAGE_QUALITY, format: ImageManipulator.SaveFormat.JPEG },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read the compressed bytes.
|
||||||
|
const b64 = await FileSystem.readAsStringAsync(manipulated.uri, {
|
||||||
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
});
|
||||||
|
const bytes = base64ToBytes(b64);
|
||||||
|
|
||||||
|
if (bytes.length > IMAGE_BUDGET_BYTES) {
|
||||||
|
Alert.alert(
|
||||||
|
'Image too large',
|
||||||
|
`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;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttach({
|
||||||
|
uri: manipulated.uri,
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
size: bytes.length,
|
||||||
|
bytes,
|
||||||
|
width: manipulated.width,
|
||||||
|
height: manipulated.height,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Failed', String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setPicking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPublish = async () => {
|
||||||
|
if (!keyFile || !canPublish) return;
|
||||||
|
|
||||||
|
// Balance guard.
|
||||||
|
if (balance !== null && balance < estimatedFee) {
|
||||||
|
Alert.alert(
|
||||||
|
'Insufficient balance',
|
||||||
|
`Need ${formatFee(estimatedFee)}, have ${formatFee(balance)}.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'Publish post?',
|
||||||
|
`Cost: ${formatFee(estimatedFee)}\nSize: ${Math.round(totalBytes / 1024 * 10) / 10} KB`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Publish',
|
||||||
|
onPress: async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const postID = await publishAndCommit({
|
||||||
|
author: keyFile.pub_key,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
content: content.trim(),
|
||||||
|
attachment: attach?.bytes,
|
||||||
|
attachmentMIME: attach?.mime,
|
||||||
|
});
|
||||||
|
// Close composer and open the new post.
|
||||||
|
router.replace(`/(app)/feed/${postID}` as never);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Failed to publish', humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1, backgroundColor: '#000000' }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top + 8,
|
||||||
|
paddingBottom: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#141414',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable onPress={() => safeBack()} hitSlop={8}>
|
||||||
|
<Ionicons name="close" size={26} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
<Pressable
|
||||||
|
onPress={onPublish}
|
||||||
|
disabled={!canPublish}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
paddingHorizontal: 18, paddingVertical: 9,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: canPublish ? (pressed ? '#1a8cd8' : '#1d9bf0') : '#1f1f1f',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: canPublish ? '#ffffff' : '#5a5a5a',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Publish
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 80 }}
|
||||||
|
>
|
||||||
|
{/* Avatar + TextInput row */}
|
||||||
|
<View style={{ flexDirection: 'row' }}>
|
||||||
|
<Avatar name={username ?? '?'} address={keyFile?.pub_key} size={40} />
|
||||||
|
<TextInput
|
||||||
|
value={content}
|
||||||
|
onChangeText={setContent}
|
||||||
|
placeholder="What's happening?"
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
multiline
|
||||||
|
maxLength={MAX_CONTENT_LENGTH}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 10,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 17,
|
||||||
|
lineHeight: 22,
|
||||||
|
minHeight: 120,
|
||||||
|
paddingTop: 4,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Hashtag preview */}
|
||||||
|
{hashtags.length > 0 && (
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 14, marginLeft: 50 }}>
|
||||||
|
{hashtags.map(tag => (
|
||||||
|
<View
|
||||||
|
key={tag}
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 10, paddingVertical: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: '#081a2a',
|
||||||
|
borderWidth: 1, borderColor: '#11385a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
|
||||||
|
#{tag}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachment preview */}
|
||||||
|
{attach && (
|
||||||
|
<View style={{ marginTop: 14, marginLeft: 50 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: attach.uri }}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: attach.width && attach.height
|
||||||
|
? attach.width / attach.height
|
||||||
|
: 4 / 3,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
}}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setAttach(null)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8, right: 8,
|
||||||
|
width: 28, height: 28, borderRadius: 14,
|
||||||
|
backgroundColor: pressed ? 'rgba(0,0,0,0.9)' : 'rgba(0,0,0,0.75)',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={16} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6 }}>
|
||||||
|
{Math.round(attach.size / 1024)} KB · metadata stripped on server
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Footer: attach / counter / fee */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 10),
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#141414',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={onPickImage}
|
||||||
|
disabled={picking || !!attach}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: pressed || picking || attach ? 0.5 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{picking
|
||||||
|
? <ActivityIndicator color="#1d9bf0" size="small" />
|
||||||
|
: <Ionicons name="image-outline" size={22} color="#1d9bf0" />}
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: totalBytes > MAX_POST_BYTES ? '#f4212e'
|
||||||
|
: totalBytes > MAX_POST_BYTES * 0.85 ? '#f0b35a'
|
||||||
|
: '#6a6a6a',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(totalBytes / 1024 * 10) / 10} / {MAX_POST_BYTES / 1024} KB
|
||||||
|
</Text>
|
||||||
|
<View style={{ width: 1, height: 14, backgroundColor: '#1f1f1f' }} />
|
||||||
|
<Text style={{ color: '#6a6a6a', fontSize: 12 }}>
|
||||||
|
≈ {formatFee(estimatedFee)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function base64ToBytes(b64: string): Uint8Array {
|
||||||
|
const binary = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
const out = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
207
client-app/app/(app)/feed/[id].tsx
Normal file
207
client-app/app/(app)/feed/[id].tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Post detail — full view of one post with stats, thread context, and a
|
||||||
|
* lazy-rendered image attachment.
|
||||||
|
*
|
||||||
|
* Why a dedicated screen?
|
||||||
|
* - PostCard in the timeline intentionally doesn't render attachments
|
||||||
|
* (would explode initial render time with N images).
|
||||||
|
* - Per-post stats (views, likes, liked_by_me) want a fresh refresh
|
||||||
|
* on open; timeline batches but not at the per-second cadence a
|
||||||
|
* reader expects when they just tapped in.
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* [← back · Пост]
|
||||||
|
* [PostCard (full — with attachment)]
|
||||||
|
* [stats bar: views · likes · fee]
|
||||||
|
* [— reply affordance below (future)]
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, ActivityIndicator,
|
||||||
|
} 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 { PostCard } from '@/components/feed/PostCard';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import {
|
||||||
|
fetchPost, fetchStats, bumpView, formatCount, formatFee,
|
||||||
|
type FeedPostItem, type PostStats,
|
||||||
|
} from '@/lib/feed';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
|
export default function PostDetailScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { id: postID } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
|
const [post, setPost] = useState<FeedPostItem | null>(null);
|
||||||
|
const [stats, setStats] = useState<PostStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!postID) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [p, s] = await Promise.all([
|
||||||
|
fetchPost(postID),
|
||||||
|
fetchStats(postID, keyFile?.pub_key),
|
||||||
|
]);
|
||||||
|
setPost(p);
|
||||||
|
setStats(s);
|
||||||
|
if (p) bumpView(postID); // fire-and-forget
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [postID, keyFile]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const onStatsChanged = useCallback(async () => {
|
||||||
|
if (!postID) return;
|
||||||
|
const s = await fetchStats(postID, keyFile?.pub_key);
|
||||||
|
if (s) setStats(s);
|
||||||
|
}, [postID, keyFile]);
|
||||||
|
|
||||||
|
const onDeleted = useCallback(() => {
|
||||||
|
// Go back to feed — the post is gone.
|
||||||
|
safeBack();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<Header
|
||||||
|
divider
|
||||||
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
|
title="Post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ActivityIndicator color="#1d9bf0" />
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View style={{ padding: 24 }}>
|
||||||
|
<Text style={{ color: '#f4212e' }}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
) : !post ? (
|
||||||
|
<View style={{ padding: 24, alignItems: 'center' }}>
|
||||||
|
<Ionicons name="trash-outline" size={32} color="#6a6a6a" />
|
||||||
|
<Text style={{ color: '#8b8b8b', marginTop: 10 }}>
|
||||||
|
Post deleted or no longer available
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView>
|
||||||
|
{/* `compact` tells PostCard to drop the 5-line body cap and
|
||||||
|
render the attachment at its natural aspect ratio instead
|
||||||
|
of the portrait-cropped timeline preview. */}
|
||||||
|
<PostCard
|
||||||
|
compact
|
||||||
|
post={{ ...post, likes: stats?.likes ?? post.likes, views: stats?.views ?? post.views }}
|
||||||
|
likedByMe={stats?.liked_by_me ?? false}
|
||||||
|
onStatsChanged={onStatsChanged}
|
||||||
|
onDeleted={onDeleted}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Detailed stats block */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginHorizontal: 14,
|
||||||
|
marginTop: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{
|
||||||
|
color: '#5a5a5a',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
Post details
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<DetailRow label="Views" value={formatCount(stats?.views ?? post.views)} />
|
||||||
|
<DetailRow label="Likes" value={formatCount(stats?.likes ?? post.likes)} />
|
||||||
|
<DetailRow label="Size" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} />
|
||||||
|
<DetailRow
|
||||||
|
label="Paid to publish"
|
||||||
|
value={formatFee(1000 + post.size)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Hosted on"
|
||||||
|
value={shortAddr(post.hosting_relay)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
|
||||||
|
{post.hashtags && post.hashtags.length > 0 && (
|
||||||
|
<>
|
||||||
|
<View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 10 }} />
|
||||||
|
<Text style={{ color: '#5a5a5a', fontSize: 11, marginBottom: 6 }}>
|
||||||
|
Hashtags
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{post.hashtags.map(tag => (
|
||||||
|
<Text
|
||||||
|
key={tag}
|
||||||
|
onPress={() => router.push(`/(app)/feed/tag/${encodeURIComponent(tag)}` as never)}
|
||||||
|
style={{
|
||||||
|
color: '#1d9bf0',
|
||||||
|
fontSize: 13,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
backgroundColor: '#081a2a',
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ height: 80 }} />
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', paddingVertical: 3 }}>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
fontFamily: mono ? 'monospace' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortAddr(a: string, n = 6): string {
|
||||||
|
if (!a) return '—';
|
||||||
|
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||||
|
}
|
||||||
23
client-app/app/(app)/feed/_layout.tsx
Normal file
23
client-app/app/(app)/feed/_layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Feed sub-routes layout — native Stack for /(app)/feed/[id] and
|
||||||
|
* /(app)/feed/tag/[tag]. The tab root itself (app/(app)/feed.tsx) lives
|
||||||
|
* OUTSIDE this folder so it keeps the outer Slot-level navigation.
|
||||||
|
*
|
||||||
|
* Why a Stack here? AnimatedSlot in the parent is stack-less; without
|
||||||
|
* this nested Stack, `router.back()` from a post detail / hashtag feed
|
||||||
|
* couldn't find its caller.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function FeedLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: '#000000' },
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
client-app/app/(app)/feed/author/[pub].tsx
Normal file
249
client-app/app/(app)/feed/author/[pub].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
424
client-app/app/(app)/feed/index.tsx
Normal file
424
client-app/app/(app)/feed/index.tsx
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
/**
|
||||||
|
* Feed tab — Twitter-style timeline with three sources:
|
||||||
|
*
|
||||||
|
* Подписки → /feed/timeline?follower=me (posts from people I follow)
|
||||||
|
* Для вас → /feed/foryou?pub=me (recommendations)
|
||||||
|
* В тренде → /feed/trending?window=24 (most-engaged in last 24h)
|
||||||
|
*
|
||||||
|
* Floating compose button (bottom-right) → /(app)/compose modal.
|
||||||
|
*
|
||||||
|
* Uses a single FlatList per tab with pull-to-refresh + optimistic
|
||||||
|
* local updates. Stats (likes, likedByMe) are fetched once per refresh
|
||||||
|
* and piggy-backed onto each PostCard via props; the card does the
|
||||||
|
* optimistic toggle locally until the next refresh reconciles.
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, FlatList, Pressable, RefreshControl, ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
|
import { TabHeader } from '@/components/TabHeader';
|
||||||
|
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import {
|
||||||
|
fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView,
|
||||||
|
type FeedPostItem,
|
||||||
|
} from '@/lib/feed';
|
||||||
|
|
||||||
|
type TabKey = 'following' | 'foryou' | 'trending';
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<TabKey, string> = {
|
||||||
|
following: 'Following',
|
||||||
|
foryou: 'For you',
|
||||||
|
trending: 'Trending',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FeedScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<TabKey>('foryou'); // default: discovery
|
||||||
|
const [posts, setPosts] = useState<FeedPostItem[]>([]);
|
||||||
|
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [exhausted, setExhausted] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
// Guard against rapid tab switches overwriting each other's results.
|
||||||
|
const requestRef = useRef(0);
|
||||||
|
|
||||||
|
const loadPosts = useCallback(async (isRefresh = false) => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
if (isRefresh) setRefreshing(true);
|
||||||
|
else setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setExhausted(false);
|
||||||
|
|
||||||
|
const seq = ++requestRef.current;
|
||||||
|
try {
|
||||||
|
let items: FeedPostItem[] = [];
|
||||||
|
switch (tab) {
|
||||||
|
case 'following':
|
||||||
|
items = await fetchTimeline(keyFile.pub_key, { limit: PAGE_SIZE });
|
||||||
|
break;
|
||||||
|
case 'foryou':
|
||||||
|
items = await fetchForYou(keyFile.pub_key, PAGE_SIZE);
|
||||||
|
break;
|
||||||
|
case 'trending':
|
||||||
|
items = await fetchTrending(24, PAGE_SIZE);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (seq !== requestRef.current) return; // stale response
|
||||||
|
|
||||||
|
setPosts(items);
|
||||||
|
// If the server returned fewer than PAGE_SIZE, we already have
|
||||||
|
// everything — disable further paginated fetches.
|
||||||
|
if (items.length < PAGE_SIZE) setExhausted(true);
|
||||||
|
|
||||||
|
// Batch-fetch liked_by_me (bounded concurrency — 6 at a time).
|
||||||
|
const liked = new Set<string>();
|
||||||
|
const chunks = chunk(items, 6);
|
||||||
|
for (const group of chunks) {
|
||||||
|
const results = await Promise.all(
|
||||||
|
group.map(p => fetchStats(p.post_id, keyFile.pub_key)),
|
||||||
|
);
|
||||||
|
results.forEach((s, i) => {
|
||||||
|
if (s?.liked_by_me) liked.add(group[i].post_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (seq !== requestRef.current) return;
|
||||||
|
setLikedSet(liked);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (seq !== requestRef.current) return;
|
||||||
|
const msg = String(e?.message ?? e);
|
||||||
|
// Network / 404 is benign — node just unreachable or empty. Show
|
||||||
|
// the empty-state; the catch block above already cleared error
|
||||||
|
// on benign messages. Production treats this identically.
|
||||||
|
if (/Network request failed|→\s*404/.test(msg)) {
|
||||||
|
setPosts([]);
|
||||||
|
setExhausted(true);
|
||||||
|
} else {
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (seq !== requestRef.current) return;
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [keyFile, tab]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* loadMore — paginate older posts when the user scrolls to the end
|
||||||
|
* of the list. Only the "following" and "foryou"/trending-less-useful
|
||||||
|
* paths actually support server-side pagination via the `before`
|
||||||
|
* cursor; foryou/trending return their ranked top-N which is by
|
||||||
|
* design not paginated (users very rarely scroll past 20 hot posts).
|
||||||
|
*
|
||||||
|
* We key the next page off the oldest post currently in state. If
|
||||||
|
* the server returns less than PAGE_SIZE items, we mark the list as
|
||||||
|
* exhausted to stop further fetches.
|
||||||
|
*/
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (!keyFile || loadingMore || exhausted || refreshing || loading) return;
|
||||||
|
if (posts.length === 0) return;
|
||||||
|
// foryou / trending are ranked, not ordered — no stable cursor to
|
||||||
|
// paginate against in v2.0.0. Skip.
|
||||||
|
if (tab === 'foryou' || tab === 'trending') return;
|
||||||
|
|
||||||
|
const oldest = posts[posts.length - 1];
|
||||||
|
const before = oldest?.created_at;
|
||||||
|
if (!before) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
const seq = requestRef.current; // don't bump — this is additive
|
||||||
|
try {
|
||||||
|
const next = await fetchTimeline(keyFile.pub_key, {
|
||||||
|
limit: PAGE_SIZE, before,
|
||||||
|
});
|
||||||
|
if (seq !== requestRef.current) return;
|
||||||
|
if (next.length === 0) {
|
||||||
|
setExhausted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Dedup by post_id (could overlap on the boundary ts).
|
||||||
|
setPosts(prev => {
|
||||||
|
const have = new Set(prev.map(p => p.post_id));
|
||||||
|
const merged = [...prev];
|
||||||
|
for (const p of next) {
|
||||||
|
if (!have.has(p.post_id)) merged.push(p);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
if (next.length < PAGE_SIZE) setExhausted(true);
|
||||||
|
} catch {
|
||||||
|
// Don't escalate to error UI for pagination failures — just stop.
|
||||||
|
setExhausted(true);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [keyFile, loadingMore, exhausted, refreshing, loading, posts, tab]);
|
||||||
|
|
||||||
|
useEffect(() => { loadPosts(false); }, [loadPosts]);
|
||||||
|
|
||||||
|
const onStatsChanged = useCallback(async (postID: string) => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const stats = await fetchStats(postID, keyFile.pub_key);
|
||||||
|
if (!stats) return;
|
||||||
|
setPosts(ps => ps.map(p => p.post_id === postID
|
||||||
|
? { ...p, likes: stats.likes, views: stats.views }
|
||||||
|
: p));
|
||||||
|
setLikedSet(s => {
|
||||||
|
const next = new Set(s);
|
||||||
|
if (stats.liked_by_me) next.add(postID);
|
||||||
|
else next.delete(postID);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
const onDeleted = useCallback((postID: string) => {
|
||||||
|
setPosts(ps => ps.filter(p => p.post_id !== postID));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// View counter: fire bumpView once when a card scrolls into view.
|
||||||
|
const viewedRef = useRef<Set<string>>(new Set());
|
||||||
|
const onViewableItemsChanged = useRef(({ viewableItems }: { viewableItems: Array<{ item: FeedPostItem; isViewable: boolean }> }) => {
|
||||||
|
for (const { item, isViewable } of viewableItems) {
|
||||||
|
if (isViewable && !viewedRef.current.has(item.post_id)) {
|
||||||
|
viewedRef.current.add(item.post_id);
|
||||||
|
bumpView(item.post_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).current;
|
||||||
|
|
||||||
|
const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 60, minimumViewTime: 1000 }).current;
|
||||||
|
|
||||||
|
const emptyHint = useMemo(() => {
|
||||||
|
switch (tab) {
|
||||||
|
case 'following': return 'Follow someone to see their posts here.';
|
||||||
|
case 'foryou': return 'No recommendations yet — come back later.';
|
||||||
|
case 'trending': return 'Nothing trending yet.';
|
||||||
|
}
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<TabHeader title="Feed" />
|
||||||
|
|
||||||
|
{/* Tab strip — три таба, равномерно распределены по ширине
|
||||||
|
(justifyContent: space-between). Каждый Pressable hug'ает
|
||||||
|
свой контент — табы НЕ тянутся на 1/3 ширины, а жмутся к
|
||||||
|
своему лейблу, что даёт воздух между ними. Индикатор активной
|
||||||
|
вкладки — тонкая полоска под лейблом. */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(Object.keys(TAB_LABELS) as TabKey[]).map(key => (
|
||||||
|
<Pressable
|
||||||
|
key={key}
|
||||||
|
onPress={() => setTab(key)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: tab === key ? '#ffffff' : '#6a6a6a',
|
||||||
|
fontWeight: tab === key ? '700' : '500',
|
||||||
|
fontSize: 15,
|
||||||
|
letterSpacing: -0.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TAB_LABELS[key]}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
width: tab === key ? 28 : 0,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
backgroundColor: '#1d9bf0',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Feed list */}
|
||||||
|
<FlatList
|
||||||
|
data={posts}
|
||||||
|
keyExtractor={p => p.post_id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<PostCard
|
||||||
|
post={item}
|
||||||
|
likedByMe={likedSet.has(item.post_id)}
|
||||||
|
onStatsChanged={onStatsChanged}
|
||||||
|
onDeleted={onDeleted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={PostSeparator}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
onEndReachedThreshold={0.6}
|
||||||
|
ListFooterComponent={
|
||||||
|
loadingMore ? (
|
||||||
|
<View style={{ paddingVertical: 20, alignItems: 'center' }}>
|
||||||
|
<ActivityIndicator color="#1d9bf0" size="small" />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
// Lazy-render tuning: start with one viewport's worth of posts,
|
||||||
|
// keep a small window around the visible area. Works together
|
||||||
|
// with onEndReached pagination for smooth long-feed scroll.
|
||||||
|
initialNumToRender={10}
|
||||||
|
maxToRenderPerBatch={8}
|
||||||
|
windowSize={7}
|
||||||
|
removeClippedSubviews
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => loadPosts(true)}
|
||||||
|
tintColor="#1d9bf0"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onViewableItemsChanged={onViewableItemsChanged}
|
||||||
|
viewabilityConfig={viewabilityConfig}
|
||||||
|
ListEmptyComponent={
|
||||||
|
loading ? (
|
||||||
|
<View style={{ paddingTop: 80, alignItems: 'center' }}>
|
||||||
|
<ActivityIndicator color="#1d9bf0" />
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="alert-circle-outline"
|
||||||
|
title="Couldn't load feed"
|
||||||
|
subtitle={error}
|
||||||
|
onRetry={() => loadPosts(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon="newspaper-outline"
|
||||||
|
title="Nothing to show yet"
|
||||||
|
subtitle={emptyHint}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
contentContainerStyle={
|
||||||
|
posts.length === 0
|
||||||
|
? { flexGrow: 1 }
|
||||||
|
: { paddingTop: 8, paddingBottom: 24 }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating compose button.
|
||||||
|
*
|
||||||
|
* Pressable's dynamic-function style sometimes drops absolute
|
||||||
|
* positioning on re-render on some RN versions — we've seen the
|
||||||
|
* button slide to the left edge after the first render. Wrap it
|
||||||
|
* in a plain absolute-positioned View so positioning lives on a
|
||||||
|
* stable element; the Pressable inside only declares its size
|
||||||
|
* and visuals. The parent Feed screen's container ends at the
|
||||||
|
* NavBar top (see (app)/_layout.tsx), so bottom: 12 means 12px
|
||||||
|
* above the NavBar on every device. */}
|
||||||
|
<View
|
||||||
|
pointerEvents="box-none"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 12,
|
||||||
|
bottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push('/(app)/compose' as never)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 56, height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.5,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 8,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="create-outline" size={24} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EmptyState({
|
||||||
|
icon, title, subtitle, onRetry,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingHorizontal: 32, paddingVertical: 80,
|
||||||
|
}}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 64, height: 64, borderRadius: 16,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={28} color="#6a6a6a" />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginBottom: 6 }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{onRetry && (
|
||||||
|
<Pressable
|
||||||
|
onPress={onRetry}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
marginTop: 16,
|
||||||
|
paddingHorizontal: 20, paddingVertical: 10,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
|
||||||
|
Try again
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk<T>(arr: T[], size: number): T[][] {
|
||||||
|
const out: T[][] = [];
|
||||||
|
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
139
client-app/app/(app)/feed/tag/[tag].tsx
Normal file
139
client-app/app/(app)/feed/tag/[tag].tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Hashtag feed — all posts tagged with #tag, newest first.
|
||||||
|
*
|
||||||
|
* Route: /(app)/feed/tag/[tag]
|
||||||
|
* Triggered by tapping a hashtag inside any PostCard's body.
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, FlatList, RefreshControl, ActivityIndicator,
|
||||||
|
} 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 { PostCard, PostSeparator } from '@/components/feed/PostCard';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
|
export default function HashtagScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { tag: rawTag } = useLocalSearchParams<{ tag: string }>();
|
||||||
|
const tag = (rawTag ?? '').replace(/^#/, '').toLowerCase();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
|
const [posts, setPosts] = useState<FeedPostItem[]>([]);
|
||||||
|
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const seq = useRef(0);
|
||||||
|
|
||||||
|
const load = useCallback(async (isRefresh = false) => {
|
||||||
|
if (!tag) return;
|
||||||
|
if (isRefresh) setRefreshing(true);
|
||||||
|
else setLoading(true);
|
||||||
|
|
||||||
|
const id = ++seq.current;
|
||||||
|
try {
|
||||||
|
const items = await fetchHashtag(tag, 60);
|
||||||
|
if (id !== seq.current) return;
|
||||||
|
setPosts(items);
|
||||||
|
|
||||||
|
const liked = new Set<string>();
|
||||||
|
if (keyFile) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id !== seq.current) return;
|
||||||
|
setLikedSet(liked);
|
||||||
|
} catch {
|
||||||
|
if (id !== seq.current) return;
|
||||||
|
setPosts([]);
|
||||||
|
} finally {
|
||||||
|
if (id !== seq.current) return;
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [tag, keyFile]);
|
||||||
|
|
||||||
|
useEffect(() => { load(false); }, [load]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<Header
|
||||||
|
divider
|
||||||
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
|
title={`#${tag}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => load(true)}
|
||||||
|
tintColor="#1d9bf0"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
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="pricetag-outline" size={32} color="#6a6a6a" />
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
|
||||||
|
No posts with this tag yet
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}>
|
||||||
|
Be the first — write a post with #{tag}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
contentContainerStyle={
|
||||||
|
posts.length === 0
|
||||||
|
? { flexGrow: 1 }
|
||||||
|
: { paddingTop: 8, paddingBottom: 24 }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
342
client-app/app/(app)/new-contact.tsx
Normal file
342
client-app/app/(app)/new-contact.tsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* Add new contact — dark minimalist, inspired by the reference.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Пользователь вводит @username или hex pubkey / DC-address.
|
||||||
|
* 2. Жмёт Search → resolveUsername → getIdentity.
|
||||||
|
* 3. Показываем preview (avatar + имя + address + наличие x25519).
|
||||||
|
* 4. Выбирает fee (chip-selector) + вводит intro.
|
||||||
|
* 5. Submit → CONTACT_REQUEST tx.
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, Alert, Pressable, TextInput, ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getIdentity, buildContactRequestTx, submitTx, resolveUsername, humanizeTxError } from '@/lib/api';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { formatAmount, safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
import { SearchBar } from '@/components/SearchBar';
|
||||||
|
|
||||||
|
const MIN_CONTACT_FEE = 5000;
|
||||||
|
const FEE_TIERS = [
|
||||||
|
{ value: 5_000, label: 'Min' },
|
||||||
|
{ value: 10_000, label: 'Standard' },
|
||||||
|
{ value: 50_000, label: 'Priority' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Resolved {
|
||||||
|
address: string;
|
||||||
|
nickname?: string;
|
||||||
|
x25519?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewContactScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const settings = useStore(s => s.settings);
|
||||||
|
const balance = useStore(s => s.balance);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [intro, setIntro] = useState('');
|
||||||
|
const [fee, setFee] = useState(MIN_CONTACT_FEE);
|
||||||
|
const [resolved, setResolved] = useState<Resolved | null>(null);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return;
|
||||||
|
setSearching(true); setResolved(null); setError(null);
|
||||||
|
try {
|
||||||
|
let address = q;
|
||||||
|
if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) {
|
||||||
|
const name = q.replace('@', '');
|
||||||
|
const addr = await resolveUsername(settings.contractId, name);
|
||||||
|
if (!addr) { setError(`@${name} is not registered on this chain`); return; }
|
||||||
|
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 resolvedAddr = identity?.pub_key ?? address;
|
||||||
|
if (keyFile && resolvedAddr.toLowerCase() === keyFile.pub_key.toLowerCase()) {
|
||||||
|
router.replace(`/(app)/chats/${keyFile.pub_key}` as never);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResolved({
|
||||||
|
address: resolvedAddr,
|
||||||
|
nickname: identity?.nickname || undefined,
|
||||||
|
x25519: identity?.x25519_pub || undefined,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? 'Lookup failed');
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendRequest() {
|
||||||
|
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) {
|
||||||
|
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (request fee + network).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true); setError(null);
|
||||||
|
try {
|
||||||
|
const tx = buildContactRequestTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
to: resolved.address,
|
||||||
|
contactFee: fee,
|
||||||
|
intro: intro.trim() || undefined,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
Alert.alert(
|
||||||
|
'Request sent',
|
||||||
|
`A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
|
||||||
|
[{ text: 'OK', onPress: () => safeBack() }],
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = resolved
|
||||||
|
? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address))
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<Header
|
||||||
|
title="Search"
|
||||||
|
divider={false}
|
||||||
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 14, paddingBottom: 120 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<SearchBar
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder="@alice, hex pubkey or DC address"
|
||||||
|
onSubmitEditing={search}
|
||||||
|
autoFocus
|
||||||
|
onClear={() => { setResolved(null); setError(null); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{query.trim().length > 0 && (
|
||||||
|
<Pressable
|
||||||
|
onPress={search}
|
||||||
|
disabled={searching}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingVertical: 11, borderRadius: 999, marginTop: 12,
|
||||||
|
backgroundColor: searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{searching ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text>
|
||||||
|
)}
|
||||||
|
</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 && (
|
||||||
|
<View style={{
|
||||||
|
marginTop: 14,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(244,33,46,0.08)',
|
||||||
|
borderWidth: 1, borderColor: 'rgba(244,33,46,0.25)',
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: '#f4212e', fontSize: 13 }}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolved profile card */}
|
||||||
|
{resolved && (
|
||||||
|
<>
|
||||||
|
<View style={{
|
||||||
|
marginTop: 18,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
}}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
||||||
|
<Avatar
|
||||||
|
name={displayName}
|
||||||
|
address={resolved.address}
|
||||||
|
size={52}
|
||||||
|
dotColor={resolved.x25519 ? '#3ba55d' : '#f0b35a'}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 16 }}>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace', marginTop: 2 }} numberOfLines={1}>
|
||||||
|
{shortAddr(resolved.address, 10)}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 5, gap: 4 }}>
|
||||||
|
<Ionicons
|
||||||
|
name={resolved.x25519 ? 'lock-closed' : 'time-outline'}
|
||||||
|
size={11}
|
||||||
|
color={resolved.x25519 ? '#3ba55d' : '#f0b35a'}
|
||||||
|
/>
|
||||||
|
<Text style={{
|
||||||
|
color: resolved.x25519 ? '#3ba55d' : '#f0b35a',
|
||||||
|
fontSize: 11, fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
{resolved.x25519 ? 'E2E ready' : 'Key not published yet'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Intro */}
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 18, marginBottom: 6 }}>
|
||||||
|
Intro (optional, plaintext on-chain)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={intro}
|
||||||
|
onChangeText={setIntro}
|
||||||
|
placeholder="Hey, it's Jordan from the conference"
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
multiline
|
||||||
|
maxLength={140}
|
||||||
|
style={{
|
||||||
|
color: '#ffffff', fontSize: 14,
|
||||||
|
backgroundColor: '#0a0a0a', borderRadius: 10,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 10,
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
minHeight: 80, textAlignVertical: 'top',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: '#5a5a5a', fontSize: 11, textAlign: 'right', marginTop: 4 }}>
|
||||||
|
{intro.length}/140
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Fee tier */}
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 14, marginBottom: 6 }}>
|
||||||
|
Anti-spam fee (goes to the recipient)
|
||||||
|
</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 }}>
|
||||||
|
{FEE_TIERS.map(t => {
|
||||||
|
const active = fee === t.value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={t.value}
|
||||||
|
onPress={() => setFee(t.value)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: active ? '#ffffff' : '#111111',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: active ? '#ffffff' : '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<Pressable
|
||||||
|
onPress={sendRequest}
|
||||||
|
disabled={sending}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingVertical: 13, borderRadius: 999, marginTop: 20,
|
||||||
|
backgroundColor: sending ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
|
||||||
|
Send request · {formatAmount(fee + 1000)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
434
client-app/app/(app)/profile/[address].tsx
Normal file
434
client-app/app/(app)/profile/[address].tsx
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
/**
|
||||||
|
* Profile screen — info card about any address (yours or someone else's),
|
||||||
|
* plus a Follow/Unfollow button. Posts are intentionally NOT shown here
|
||||||
|
* — this screen is chat-oriented ("who is on the other side of this
|
||||||
|
* conversation"); the feed tab + /feed/author/{pub} is where you go to
|
||||||
|
* browse someone's timeline.
|
||||||
|
*
|
||||||
|
* Route:
|
||||||
|
* /(app)/profile/<ed25519-hex>
|
||||||
|
*
|
||||||
|
* Back behaviour:
|
||||||
|
* Nested Stack layout in app/(app)/profile/_layout.tsx preserves the
|
||||||
|
* push stack, so tapping Back returns the user to whatever screen
|
||||||
|
* pushed them here (feed card tap, chat header tap, etc.).
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, Pressable, ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
import { followUser, unfollowUser } from '@/lib/feed';
|
||||||
|
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 {
|
||||||
|
if (!a) return '—';
|
||||||
|
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { address } = useLocalSearchParams<{ address: string }>();
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const contact = contacts.find(c => c.address === address);
|
||||||
|
|
||||||
|
const [following, setFollowing] = useState(false);
|
||||||
|
const [followingBusy, setFollowingBusy] = 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;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 () => {
|
||||||
|
if (!address) return;
|
||||||
|
await Clipboard.setStringAsync(address);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openChat = () => {
|
||||||
|
if (!address) return;
|
||||||
|
router.replace(`/(app)/chats/${address}` as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleFollow = async () => {
|
||||||
|
if (!keyFile || !address || isMe || followingBusy) return;
|
||||||
|
setFollowingBusy(true);
|
||||||
|
const wasFollowing = following;
|
||||||
|
setFollowing(!wasFollowing);
|
||||||
|
try {
|
||||||
|
if (wasFollowing) {
|
||||||
|
await unfollowUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address });
|
||||||
|
} else {
|
||||||
|
await followUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setFollowing(wasFollowing);
|
||||||
|
// Surface the error via alert — feed lib already formats humanizeTxError.
|
||||||
|
alert(humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setFollowingBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<Header
|
||||||
|
title="Profile"
|
||||||
|
divider
|
||||||
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>
|
||||||
|
{/* ── Hero: avatar + Follow button ──────────────────────────── */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12 }}>
|
||||||
|
<Avatar name={displayName} address={address} size={72} saved={isMe} />
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
{!isMe ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onToggleFollow}
|
||||||
|
disabled={followingBusy}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
paddingHorizontal: 18, paddingVertical: 9,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: following
|
||||||
|
? (pressed ? '#1a1a1a' : '#111111')
|
||||||
|
: (pressed ? '#e7e7e7' : '#ffffff'),
|
||||||
|
borderWidth: following ? 1 : 0,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
minWidth: 120,
|
||||||
|
alignItems: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{followingBusy ? (
|
||||||
|
<ActivityIndicator
|
||||||
|
size="small"
|
||||||
|
color={following ? '#ffffff' : '#000000'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: following ? '#ffffff' : '#000000',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{following ? 'Following' : 'Follow'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => keyFile && router.push(`/(app)/chats/${keyFile.pub_key}` as never)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
paddingHorizontal: 16, paddingVertical: 9,
|
||||||
|
borderRadius: 999,
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||||||
|
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="bookmark" size={13} color="#f0b35a" />
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
|
||||||
|
Saved Messages
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Name + verified tick */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<Text
|
||||||
|
style={{ color: '#ffffff', fontSize: 22, fontWeight: '800', letterSpacing: -0.3 }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
{contact?.username && (
|
||||||
|
<Ionicons name="checkmark-circle" size={18} color="#1d9bf0" style={{ marginLeft: 5 }} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action row — View posts is universal (anyone can have a wall,
|
||||||
|
even non-contacts). Open chat appears alongside only when this
|
||||||
|
address is already a direct-chat contact. */}
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 14 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => address && router.push(`/(app)/feed/author/${address}` as never)}
|
||||||
|
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="document-text-outline" size={15} color="#ffffff" />
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
|
||||||
|
View posts
|
||||||
|
</Text>
|
||||||
|
</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 ───────────────────────────────────────────────── */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 18,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Address — entire row is tappable → copies */}
|
||||||
|
<Pressable
|
||||||
|
onPress={copyAddress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14, paddingVertical: 12,
|
||||||
|
backgroundColor: pressed ? '#0f0f0f' : 'transparent',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>
|
||||||
|
Address
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: copied ? '#3ba55d' : '#ffffff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: '600',
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied' : shortAddr(address ?? '')}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={copied ? 'checkmark' : 'copy-outline'}
|
||||||
|
size={14}
|
||||||
|
color={copied ? '#3ba55d' : '#6a6a6a'}
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
/>
|
||||||
|
</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 */}
|
||||||
|
{contact && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<InfoRow
|
||||||
|
label="Encryption"
|
||||||
|
value={contact.x25519Pub
|
||||||
|
? 'end-to-end (NaCl)'
|
||||||
|
: 'key not published yet'}
|
||||||
|
danger={!contact.x25519Pub}
|
||||||
|
icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<InfoRow
|
||||||
|
label="Added"
|
||||||
|
value={new Date(contact.addedAt).toLocaleDateString()}
|
||||||
|
icon="calendar-outline"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Group-only: participant count. DMs always have exactly two
|
||||||
|
people so the row would be noise. Groups would show real
|
||||||
|
member count here from chain state once v2.1.0 ships groups. */}
|
||||||
|
{contact.kind === 'group' && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<InfoRow
|
||||||
|
label="Members"
|
||||||
|
value="—"
|
||||||
|
icon="people-outline"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!contact && !isMe && (
|
||||||
|
<Text style={{
|
||||||
|
color: '#6a6a6a',
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 14,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
lineHeight: 17,
|
||||||
|
}}>
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
label, value, icon, danger,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon?: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
danger?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={14}
|
||||||
|
color={danger ? '#f0b35a' : '#6a6a6a'}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: danger ? '#f0b35a' : '#ffffff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
client-app/app/(app)/profile/_layout.tsx
Normal file
24
client-app/app/(app)/profile/_layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Profile group layout — provides a dedicated native Stack for the
|
||||||
|
* /(app)/profile/* routes so that `router.back()` returns to the screen
|
||||||
|
* that pushed us here (post detail, chat, feed tab, etc.) instead of
|
||||||
|
* falling through to the root.
|
||||||
|
*
|
||||||
|
* The parent (app)/_layout.tsx uses AnimatedSlot → <Slot>, which is
|
||||||
|
* stack-less. Nesting a <Stack> here gives profile routes proper back
|
||||||
|
* history without affecting the outer tabs.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function ProfileLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: '#000000' },
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
client-app/app/(app)/requests.tsx
Normal file
173
client-app/app/(app)/requests.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Contact requests / notifications — dark minimalist.
|
||||||
|
*
|
||||||
|
* В референсе нижний таб «notifications» ведёт сюда. Пока это только
|
||||||
|
* incoming CONTACT_REQUEST'ы; позже сюда же придут другие системные
|
||||||
|
* уведомления (slash, ADD_VALIDATOR со-sig-ing, и т.д.).
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, FlatList, Alert, Pressable, ActivityIndicator } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import {
|
||||||
|
buildAcceptContactTx, submitTx, getIdentity, humanizeTxError,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { saveContact } from '@/lib/storage';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { relativeTime } from '@/lib/utils';
|
||||||
|
import type { ContactRequest } from '@/lib/types';
|
||||||
|
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { TabHeader } from '@/components/TabHeader';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
|
||||||
|
export default function RequestsScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const requests = useStore(s => s.requests);
|
||||||
|
const setRequests = useStore(s => s.setRequests);
|
||||||
|
const upsertContact = useStore(s => s.upsertContact);
|
||||||
|
|
||||||
|
const [accepting, setAccepting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function accept(req: ContactRequest) {
|
||||||
|
if (!keyFile) return;
|
||||||
|
setAccepting(req.txHash);
|
||||||
|
try {
|
||||||
|
const identity = await getIdentity(req.from);
|
||||||
|
const x25519Pub = identity?.x25519_pub ?? '';
|
||||||
|
|
||||||
|
const tx = buildAcceptContactTx({
|
||||||
|
from: keyFile.pub_key, to: req.from, privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
|
||||||
|
const contact = { address: req.from, x25519Pub, username: req.username, addedAt: Date.now() };
|
||||||
|
upsertContact(contact);
|
||||||
|
await saveContact(contact);
|
||||||
|
|
||||||
|
setRequests(requests.filter(r => r.txHash !== req.txHash));
|
||||||
|
router.replace(`/(app)/chats/${req.from}` as never);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Accept failed', humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setAccepting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decline(req: ContactRequest) {
|
||||||
|
Alert.alert(
|
||||||
|
'Decline request',
|
||||||
|
`Decline request from ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Decline',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem = ({ item: req }: { item: ContactRequest }) => {
|
||||||
|
const name = req.username ? `@${req.username}` : shortAddr(req.from);
|
||||||
|
const isAccepting = accepting === req.txHash;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#0f0f0f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar name={name} address={req.from} size={44} />
|
||||||
|
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
|
||||||
|
wants to add you as a contact · {relativeTime(req.timestamp)}
|
||||||
|
</Text>
|
||||||
|
{req.intro ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#d0d0d0', fontSize: 13, lineHeight: 18,
|
||||||
|
marginTop: 6,
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
numberOfLines={3}
|
||||||
|
>
|
||||||
|
{req.intro}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 10 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => accept(req)}
|
||||||
|
disabled={isAccepting}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingVertical: 9, borderRadius: 999,
|
||||||
|
backgroundColor: isAccepting ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isAccepting ? (
|
||||||
|
<ActivityIndicator size="small" color="#ffffff" />
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>Accept</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => decline(req)}
|
||||||
|
disabled={isAccepting}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingVertical: 9, borderRadius: 999,
|
||||||
|
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>Decline</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<TabHeader title="Notifications" />
|
||||||
|
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
|
||||||
|
<Ionicons name="notifications-outline" size={42} color="#3a3a3a" />
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
|
||||||
|
All caught up
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', marginTop: 6, lineHeight: 19 }}>
|
||||||
|
Contact requests and network events will appear here.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={requests}
|
||||||
|
keyExtractor={r => r.txHash}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={{ paddingBottom: 120 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
595
client-app/app/(app)/settings.tsx
Normal file
595
client-app/app/(app)/settings.tsx
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
/**
|
||||||
|
* Settings screen — sub-route, открывается по tap'у на profile-avatar в
|
||||||
|
* TabHeader. Использует обычный `<Header>` с back-кнопкой.
|
||||||
|
*
|
||||||
|
* Секции:
|
||||||
|
* 1. Профиль — avatar, @username, short-address, Copy row.
|
||||||
|
* 2. Username — регистрация в native:username_registry (если не куплено).
|
||||||
|
* 3. Node — URL + contract ID + Save + Status.
|
||||||
|
* 4. Account — Export key, Delete account.
|
||||||
|
*
|
||||||
|
* Весь Pressable'овый layout живёт на ВНЕШНЕМ View с static style —
|
||||||
|
* Pressable handle-ит только background change (через вложенный View
|
||||||
|
* в ({pressed}) callback'е), никаких layout props в callback-style.
|
||||||
|
* Это лечит web-баг, где Pressable style-функция не применяет
|
||||||
|
* percentage/padding layout надёжно.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, TextInput, Alert, Pressable, ActivityIndicator, Share,
|
||||||
|
} from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { saveSettings, deleteKeyFile } from '@/lib/storage';
|
||||||
|
import {
|
||||||
|
setNodeUrl, getNetStats, resolveUsername, reverseResolve,
|
||||||
|
buildCallContractTx, submitTx,
|
||||||
|
USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
|
||||||
|
humanizeTxError,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { formatAmount, safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
|
||||||
|
type NodeStatus = 'idle' | 'checking' | 'ok' | 'error';
|
||||||
|
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
|
||||||
|
// ─── Shared layout primitives ─────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#5a5a5a',
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginTop: 18,
|
||||||
|
marginBottom: 8,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderRadius: 14,
|
||||||
|
marginHorizontal: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row — clickable / non-clickable list item внутри Card'а.
|
||||||
|
*
|
||||||
|
* Layout живёт на ВНЕШНЕМ контейнере (View если read-only, Pressable
|
||||||
|
* если tappable). Для pressed-стейта используется вложенный `<View>`
|
||||||
|
* с background-color, чтобы не полагаться на style-функцию Pressable'а
|
||||||
|
* (web-баг).
|
||||||
|
*/
|
||||||
|
function Row({
|
||||||
|
icon, label, value, onPress, right, danger, first,
|
||||||
|
}: {
|
||||||
|
icon: IoniconName;
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
danger?: boolean;
|
||||||
|
first?: boolean;
|
||||||
|
}) {
|
||||||
|
const body = (pressed: boolean) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 13,
|
||||||
|
backgroundColor: pressed ? '#151515' : 'transparent',
|
||||||
|
borderTopWidth: first ? 0 : 1,
|
||||||
|
borderTopColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: danger ? 'rgba(244,33,46,0.12)' : '#1a1a1a',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={16} color={danger ? '#f4212e' : '#ffffff'} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: danger ? '#f4212e' : '#ffffff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{value !== undefined && (
|
||||||
|
<Text numberOfLines={1} style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{right}
|
||||||
|
{onPress && !right && (
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#5a5a5a" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onPress) return <View>{body(false)}</View>;
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress}>
|
||||||
|
{({ pressed }) => body(pressed)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screen ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
const settings = useStore(s => s.settings);
|
||||||
|
const setSettings = useStore(s => s.setSettings);
|
||||||
|
const username = useStore(s => s.username);
|
||||||
|
const setUsername = useStore(s => s.setUsername);
|
||||||
|
const balance = useStore(s => s.balance);
|
||||||
|
|
||||||
|
const [nodeUrl, setNodeUrlInput] = useState(settings.nodeUrl);
|
||||||
|
const [contractId, setContractId] = useState(settings.contractId);
|
||||||
|
const [nodeStatus, setNodeStatus] = useState<NodeStatus>('idle');
|
||||||
|
const [peerCount, setPeerCount] = useState<number | null>(null);
|
||||||
|
const [blockCount, setBlockCount] = useState<number | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [savingNode, setSavingNode] = useState(false);
|
||||||
|
|
||||||
|
// Username registration state
|
||||||
|
const [nameInput, setNameInput] = useState('');
|
||||||
|
const [nameError, setNameError] = useState<string | null>(null);
|
||||||
|
const [registering, setRegistering] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { checkNode(); }, []);
|
||||||
|
useEffect(() => { setContractId(settings.contractId); }, [settings.contractId]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings.contractId || !keyFile) { setUsername(null); return; }
|
||||||
|
(async () => {
|
||||||
|
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
|
||||||
|
setUsername(name);
|
||||||
|
})();
|
||||||
|
}, [settings.contractId, keyFile, setUsername]);
|
||||||
|
|
||||||
|
async function checkNode() {
|
||||||
|
setNodeStatus('checking');
|
||||||
|
try {
|
||||||
|
const stats = await getNetStats();
|
||||||
|
setNodeStatus('ok');
|
||||||
|
setPeerCount(stats.peer_count);
|
||||||
|
setBlockCount(stats.total_blocks);
|
||||||
|
} catch {
|
||||||
|
setNodeStatus('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNode() {
|
||||||
|
setSavingNode(true);
|
||||||
|
const url = nodeUrl.trim().replace(/\/$/, '');
|
||||||
|
setNodeUrl(url);
|
||||||
|
const next = { nodeUrl: url, contractId: contractId.trim() };
|
||||||
|
setSettings(next);
|
||||||
|
await saveSettings(next);
|
||||||
|
await checkNode();
|
||||||
|
setSavingNode(false);
|
||||||
|
Alert.alert('Saved', 'Node settings updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyAddress() {
|
||||||
|
if (!keyFile) return;
|
||||||
|
await Clipboard.setStringAsync(keyFile.pub_key);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportKey() {
|
||||||
|
if (!keyFile) return;
|
||||||
|
try {
|
||||||
|
await Share.share({
|
||||||
|
message: JSON.stringify(keyFile, null, 2),
|
||||||
|
title: 'DChain key file',
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Export failed', e?.message ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete account',
|
||||||
|
'Your key will be removed from this device. Make sure you have a backup!',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteKeyFile();
|
||||||
|
setKeyFile(null);
|
||||||
|
router.replace('/');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onNameChange = (v: string) => {
|
||||||
|
const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH);
|
||||||
|
setNameInput(cleaned);
|
||||||
|
setNameError(null);
|
||||||
|
};
|
||||||
|
const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput);
|
||||||
|
|
||||||
|
async function registerUsername() {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const name = nameInput.trim();
|
||||||
|
if (!nameIsValid) {
|
||||||
|
setNameError(`Min ${MIN_USERNAME_LENGTH} chars, starts with a-z`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!settings.contractId) {
|
||||||
|
setNameError('No registry contract in node settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const total = USERNAME_REGISTRATION_FEE + 1000 + 2000;
|
||||||
|
if (balance < total) {
|
||||||
|
setNameError(`Need ${formatAmount(total)}, have ${formatAmount(balance)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const existing = await resolveUsername(settings.contractId, name);
|
||||||
|
if (existing) { setNameError(`@${name} already taken`); return; }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
`Buy @${name}?`,
|
||||||
|
`Cost: ${formatAmount(USERNAME_REGISTRATION_FEE)} + fee ${formatAmount(1000)}.\nBinds to your address until released.`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Buy',
|
||||||
|
onPress: async () => {
|
||||||
|
setRegistering(true);
|
||||||
|
setNameError(null);
|
||||||
|
try {
|
||||||
|
const tx = buildCallContractTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
contractId: settings.contractId,
|
||||||
|
method: 'register',
|
||||||
|
args: [name],
|
||||||
|
amount: USERNAME_REGISTRATION_FEE,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
setNameInput('');
|
||||||
|
Alert.alert('Submitted', 'Registration tx accepted. Name appears in a few seconds.');
|
||||||
|
let attempts = 0;
|
||||||
|
const iv = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
const got = keyFile
|
||||||
|
? await reverseResolve(settings.contractId, keyFile.pub_key)
|
||||||
|
: null;
|
||||||
|
if (got) { setUsername(got); clearInterval(iv); }
|
||||||
|
else if (attempts >= 10) clearInterval(iv);
|
||||||
|
}, 2000);
|
||||||
|
} catch (e: any) {
|
||||||
|
setNameError(humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setRegistering(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor =
|
||||||
|
nodeStatus === 'ok' ? '#3ba55d' :
|
||||||
|
nodeStatus === 'error' ? '#f4212e' :
|
||||||
|
'#f0b35a';
|
||||||
|
const statusLabel =
|
||||||
|
nodeStatus === 'ok' ? 'Connected' :
|
||||||
|
nodeStatus === 'error' ? 'Unreachable' :
|
||||||
|
'Checking…';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<Header
|
||||||
|
title="Settings"
|
||||||
|
divider={false}
|
||||||
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ paddingBottom: 120 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* ── Profile ── */}
|
||||||
|
<SectionLabel>Profile</SectionLabel>
|
||||||
|
<Card>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 14,
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
name={username ?? keyFile?.pub_key ?? '?'}
|
||||||
|
address={keyFile?.pub_key}
|
||||||
|
size={56}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{username ? (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 17 }}>
|
||||||
|
@{username}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="checkmark-circle" size={15} color="#1d9bf0" style={{ marginLeft: 4 }} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 14 }}>No username yet</Text>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#8b8b8b',
|
||||||
|
fontSize: 11,
|
||||||
|
marginTop: 2,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{keyFile ? shortAddr(keyFile.pub_key, 10) : '—'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Row
|
||||||
|
icon={copied ? 'checkmark-outline' : 'copy-outline'}
|
||||||
|
label={copied ? 'Copied!' : 'Copy address'}
|
||||||
|
onPress={copyAddress}
|
||||||
|
right={<View style={{ width: 16 }} />}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Username (только если ещё нет) ── */}
|
||||||
|
{!username && (
|
||||||
|
<>
|
||||||
|
<SectionLabel>Username</SectionLabel>
|
||||||
|
<Card>
|
||||||
|
<View style={{ padding: 14 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14, marginBottom: 4 }}>
|
||||||
|
Buy a username
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 10 }}>
|
||||||
|
Flat {formatAmount(USERNAME_REGISTRATION_FEE)} fee + {formatAmount(1000)} network.
|
||||||
|
Only a-z, 0-9, _, -. Starts with a letter.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: nameError ? '#f4212e' : '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#5a5a5a', fontSize: 15, marginRight: 2 }}>@</Text>
|
||||||
|
<TextInput
|
||||||
|
value={nameInput}
|
||||||
|
onChangeText={onNameChange}
|
||||||
|
placeholder="alice"
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
maxLength={MAX_USERNAME_LENGTH}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 15,
|
||||||
|
paddingVertical: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{nameError && (
|
||||||
|
<Text style={{ color: '#f4212e', fontSize: 12, marginTop: 6 }}>
|
||||||
|
{nameError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
onPress={registerUsername}
|
||||||
|
disabled={registering || !nameIsValid || !settings.contractId}
|
||||||
|
loading={registering}
|
||||||
|
label={`Buy @${nameInput || 'username'}`}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Node ── */}
|
||||||
|
<SectionLabel>Node</SectionLabel>
|
||||||
|
<Card>
|
||||||
|
<View style={{ padding: 14, gap: 10 }}>
|
||||||
|
<LabeledInput
|
||||||
|
label="Node URL"
|
||||||
|
value={nodeUrl}
|
||||||
|
onChangeText={setNodeUrlInput}
|
||||||
|
placeholder="http://localhost:8080"
|
||||||
|
/>
|
||||||
|
<LabeledInput
|
||||||
|
label="Username contract"
|
||||||
|
value={contractId}
|
||||||
|
onChangeText={setContractId}
|
||||||
|
placeholder="auto-discovered via /api/well-known-contracts"
|
||||||
|
monospace
|
||||||
|
/>
|
||||||
|
<PrimaryButton
|
||||||
|
onPress={saveNode}
|
||||||
|
disabled={savingNode}
|
||||||
|
loading={savingNode}
|
||||||
|
label="Save"
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Row
|
||||||
|
icon="pulse-outline"
|
||||||
|
label="Status"
|
||||||
|
value={
|
||||||
|
nodeStatus === 'ok'
|
||||||
|
? `${statusLabel} · ${blockCount ?? 0} blocks · ${peerCount ?? 0} peers`
|
||||||
|
: statusLabel
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
<View
|
||||||
|
style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: statusColor }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Account ── */}
|
||||||
|
<SectionLabel>Account</SectionLabel>
|
||||||
|
<Card>
|
||||||
|
<Row
|
||||||
|
icon="download-outline"
|
||||||
|
label="Export key"
|
||||||
|
value="Save your private key as JSON"
|
||||||
|
onPress={exportKey}
|
||||||
|
first
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
icon="trash-outline"
|
||||||
|
label="Delete account"
|
||||||
|
value="Remove key from this device"
|
||||||
|
onPress={logout}
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Form primitives ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LabeledInput({
|
||||||
|
label, value, onChangeText, placeholder, monospace,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
monospace?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, marginBottom: 6 }}>{label}</Text>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: monospace ? 13 : 14,
|
||||||
|
fontFamily: monospace ? 'monospace' : undefined,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrimaryButton({
|
||||||
|
label, onPress, disabled, loading, style,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
style?: object;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress} disabled={disabled} style={style}>
|
||||||
|
{({ pressed }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 11,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: disabled
|
||||||
|
? '#1a1a1a'
|
||||||
|
: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: disabled ? '#5a5a5a' : '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
427
client-app/app/(app)/tx/[id].tsx
Normal file
427
client-app/app/(app)/tx/[id].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
client-app/app/(app)/tx/_layout.tsx
Normal file
19
client-app/app/(app)/tx/_layout.tsx
Normal 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
653
client-app/app/(app)/wallet.tsx
Normal file
653
client-app/app/(app)/wallet.tsx
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
/**
|
||||||
|
* Wallet screen — dark minimalist.
|
||||||
|
*
|
||||||
|
* Сетка:
|
||||||
|
* [TabHeader: profile-avatar | Wallet | refresh]
|
||||||
|
* [Balance hero card — gradient-ish dark card, big number, address chip, action row]
|
||||||
|
* [SectionLabel: Recent transactions]
|
||||||
|
* [TX list card — tiles per tx, in/out coloring, relative time]
|
||||||
|
* [Send modal: slide-up sheet с полями recipient/amount/fee + total preview]
|
||||||
|
*
|
||||||
|
* Все кнопки и инпуты — те же плоские стили, что на других экранах.
|
||||||
|
* Никаких style-функций у Pressable'ов с layout-пропсами (избегаем web
|
||||||
|
* layout-баги, которые мы уже ловили на ChatTile/MessageBubble).
|
||||||
|
*/
|
||||||
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, Modal, Alert, RefreshControl, Pressable, TextInput, ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { useBalance } from '@/hooks/useBalance';
|
||||||
|
import { buildTransferTx, submitTx, getTxHistory, getBalance, humanizeTxError } from '@/lib/api';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { formatAmount, relativeTime } from '@/lib/utils';
|
||||||
|
import type { TxRecord } from '@/lib/types';
|
||||||
|
|
||||||
|
import { TabHeader } from '@/components/TabHeader';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
|
||||||
|
// ─── TX meta (icon + label + tone) ─────────────────────────────────
|
||||||
|
|
||||||
|
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
|
||||||
|
interface TxMeta {
|
||||||
|
label: string;
|
||||||
|
icon: IoniconName;
|
||||||
|
tone: 'in' | 'out' | 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
const TX_META: Record<string, TxMeta> = {
|
||||||
|
TRANSFER: { label: 'Transfer', icon: 'swap-horizontal-outline', tone: 'neutral' },
|
||||||
|
CONTACT_REQUEST: { label: 'Contact request', icon: 'person-add-outline', tone: 'out' },
|
||||||
|
ACCEPT_CONTACT: { label: 'Contact accepted', icon: 'person-outline', tone: 'in' },
|
||||||
|
BLOCK_CONTACT: { label: 'Block', icon: 'ban-outline', tone: 'out' },
|
||||||
|
DEPLOY_CONTRACT: { label: 'Deploy', icon: 'document-text-outline', tone: 'out' },
|
||||||
|
CALL_CONTRACT: { label: 'Call contract', icon: 'flash-outline', tone: 'out' },
|
||||||
|
STAKE: { label: 'Stake', icon: 'lock-closed-outline', tone: 'out' },
|
||||||
|
UNSTAKE: { label: 'Unstake', icon: 'lock-open-outline', tone: 'in' },
|
||||||
|
REGISTER_KEY: { label: 'Register key', icon: 'key-outline', tone: 'neutral' },
|
||||||
|
BLOCK_REWARD: { label: 'Block reward', icon: 'diamond-outline', tone: 'in' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function txMeta(type: string): TxMeta {
|
||||||
|
return TX_META[type] ?? { label: type.replace(/_/g, ' '), icon: 'ellipse-outline', tone: 'neutral' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const toneColor = (tone: TxMeta['tone']): string =>
|
||||||
|
tone === 'in' ? '#3ba55d' : tone === 'out' ? '#f4212e' : '#e7e7e7';
|
||||||
|
|
||||||
|
// ─── Main ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function WalletScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const balance = useStore(s => s.balance);
|
||||||
|
const setBalance = useStore(s => s.setBalance);
|
||||||
|
|
||||||
|
useBalance();
|
||||||
|
|
||||||
|
const [txHistory, setTxHistory] = useState<TxRecord[]>([]);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [showSend, setShowSend] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const [hist, bal] = await Promise.all([
|
||||||
|
getTxHistory(keyFile.pub_key),
|
||||||
|
getBalance(keyFile.pub_key),
|
||||||
|
]);
|
||||||
|
setTxHistory(hist);
|
||||||
|
setBalance(bal);
|
||||||
|
} catch { /* ignore — WS/HTTP retries sample */ }
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [keyFile, setBalance]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const copyAddress = async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
await Clipboard.setStringAsync(keyFile.pub_key);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mine = keyFile?.pub_key ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<TabHeader
|
||||||
|
title="Wallet"
|
||||||
|
right={<IconButton icon="refresh-outline" size={36} onPress={load} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} tintColor="#1d9bf0" />}
|
||||||
|
contentContainerStyle={{ paddingBottom: 120 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<BalanceHero
|
||||||
|
balance={balance}
|
||||||
|
address={mine}
|
||||||
|
copied={copied}
|
||||||
|
onCopy={copyAddress}
|
||||||
|
onSend={() => setShowSend(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionLabel>Recent transactions</SectionLabel>
|
||||||
|
|
||||||
|
{txHistory.length === 0 ? (
|
||||||
|
<EmptyTx />
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginHorizontal: 14,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{txHistory.map((tx, i) => (
|
||||||
|
<TxTile
|
||||||
|
key={tx.hash + i}
|
||||||
|
tx={tx}
|
||||||
|
first={i === 0}
|
||||||
|
mine={mine}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<SendModal
|
||||||
|
visible={showSend}
|
||||||
|
onClose={() => setShowSend(false)}
|
||||||
|
balance={balance}
|
||||||
|
keyFile={keyFile}
|
||||||
|
onSent={() => {
|
||||||
|
setShowSend(false);
|
||||||
|
setTimeout(load, 1200);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hero card ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function BalanceHero({
|
||||||
|
balance, address, copied, onCopy, onSend,
|
||||||
|
}: {
|
||||||
|
balance: number;
|
||||||
|
address: string;
|
||||||
|
copied: boolean;
|
||||||
|
onCopy: () => void;
|
||||||
|
onSend: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginHorizontal: 14,
|
||||||
|
marginTop: 10,
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, letterSpacing: 0.3 }}>
|
||||||
|
Balance
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: '800',
|
||||||
|
letterSpacing: -0.8,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatAmount(balance)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Address chip */}
|
||||||
|
<Pressable onPress={onCopy} style={{ marginTop: 14 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={copied ? 'checkmark-outline' : 'copy-outline'}
|
||||||
|
size={14}
|
||||||
|
color={copied ? '#3ba55d' : '#8b8b8b'}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: copied ? '#3ba55d' : '#8b8b8b',
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 6,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : shortAddr(address, 10)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View style={{ flexDirection: 'row', gap: 10, marginTop: 14 }}>
|
||||||
|
<HeroButton icon="paper-plane-outline" label="Send" primary onPress={onSend} />
|
||||||
|
<HeroButton icon="download-outline" label="Receive" onPress={onCopy} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroButton({
|
||||||
|
icon, label, primary, onPress,
|
||||||
|
}: {
|
||||||
|
icon: IoniconName;
|
||||||
|
label: string;
|
||||||
|
primary?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
const base = {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 11,
|
||||||
|
borderRadius: 999,
|
||||||
|
gap: 6,
|
||||||
|
} as const;
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress} style={{ flex: 1 }}>
|
||||||
|
{({ pressed }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
...base,
|
||||||
|
backgroundColor: primary
|
||||||
|
? (pressed ? '#1a8cd8' : '#1d9bf0')
|
||||||
|
: (pressed ? '#202020' : '#111111'),
|
||||||
|
borderWidth: primary ? 0 : 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={15} color="#ffffff" />
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Section label ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#5a5a5a',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginTop: 22,
|
||||||
|
marginBottom: 8,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Empty state ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EmptyTx() {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginHorizontal: 14,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
paddingVertical: 36,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="receipt-outline" size={32} color="#5a5a5a" />
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 8 }}>
|
||||||
|
No transactions yet
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#5a5a5a', fontSize: 11, marginTop: 2 }}>
|
||||||
|
Pull to refresh
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TX tile ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Pressable с ВНЕШНИМ плоским style (background через static object),
|
||||||
|
// внутренняя View handles row-layout. Избегаем web-баг со style-функциями
|
||||||
|
// Pressable'а.
|
||||||
|
|
||||||
|
function TxTile({
|
||||||
|
tx, first, mine,
|
||||||
|
}: {
|
||||||
|
tx: TxRecord;
|
||||||
|
first: boolean;
|
||||||
|
mine: string;
|
||||||
|
}) {
|
||||||
|
const m = txMeta(tx.type);
|
||||||
|
const isMineTx = tx.from === mine;
|
||||||
|
const amt = tx.amount ?? 0;
|
||||||
|
const sign = m.tone === 'in' ? '+' : m.tone === 'out' ? '−' : '';
|
||||||
|
const color = toneColor(m.tone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable onPress={() => router.push(`/(app)/tx/${tx.hash}` as never)}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderTopWidth: first ? 0 : 1,
|
||||||
|
borderTopColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={m.icon} size={16} color="#e7e7e7" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}>
|
||||||
|
{m.label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#8b8b8b',
|
||||||
|
fontSize: 11,
|
||||||
|
marginTop: 1,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{tx.type === 'TRANSFER'
|
||||||
|
? (isMineTx ? `→ ${shortAddr(tx.to ?? '', 5)}` : `← ${shortAddr(tx.from, 5)}`)
|
||||||
|
: shortAddr(tx.hash, 8)}
|
||||||
|
{' · '}
|
||||||
|
{relativeTime(tx.timestamp)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{amt > 0 && (
|
||||||
|
<Text style={{ color, fontWeight: '700', fontSize: 14 }}>
|
||||||
|
{sign}{formatAmount(amt)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Send modal ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SendModal({
|
||||||
|
visible, onClose, balance, keyFile, onSent,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
balance: number;
|
||||||
|
keyFile: { pub_key: string; priv_key: string } | null;
|
||||||
|
onSent: () => void;
|
||||||
|
}) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [to, setTo] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [fee, setFee] = useState('1000');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
// reset при закрытии
|
||||||
|
setTo(''); setAmount(''); setFee('1000'); setSending(false);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const amt = parseInt(amount || '0', 10) || 0;
|
||||||
|
const f = parseInt(fee || '0', 10) || 0;
|
||||||
|
const total = amt + f;
|
||||||
|
const ok = !!to.trim() && amt > 0 && total <= balance;
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
if (!ok) {
|
||||||
|
Alert.alert('Check inputs', total > balance
|
||||||
|
? `Need ${formatAmount(total)}, have ${formatAmount(balance)}.`
|
||||||
|
: 'Recipient and amount are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const tx = buildTransferTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
to: to.trim(),
|
||||||
|
amount: amt,
|
||||||
|
fee: f,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
onSent();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Send failed', humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.82)', justifyContent: 'flex-end' }}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { /* block bubble-close */ }}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 14) + 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: 40, height: 4, borderRadius: 2,
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 18, fontWeight: '700', marginBottom: 14 }}>
|
||||||
|
Send tokens
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Field label="Recipient address">
|
||||||
|
<TextInput
|
||||||
|
value={to}
|
||||||
|
onChangeText={setTo}
|
||||||
|
placeholder="DC… or pub_key hex"
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
paddingVertical: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', gap: 10, marginTop: 10 }}>
|
||||||
|
<View style={{ flex: 2 }}>
|
||||||
|
<Field label="Amount (µT)">
|
||||||
|
<TextInput
|
||||||
|
value={amount}
|
||||||
|
onChangeText={setAmount}
|
||||||
|
placeholder="1000"
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
keyboardType="numeric"
|
||||||
|
style={{ color: '#ffffff', fontSize: 14, paddingVertical: 0 }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Field label="Fee (µT)">
|
||||||
|
<TextInput
|
||||||
|
value={fee}
|
||||||
|
onChangeText={setFee}
|
||||||
|
placeholder="1000"
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
keyboardType="numeric"
|
||||||
|
style={{ color: '#ffffff', fontSize: 14, paddingVertical: 0 }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SummaryRow label="Amount" value={formatAmount(amt)} />
|
||||||
|
<SummaryRow label="Fee" value={formatAmount(f)} muted />
|
||||||
|
<View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 6 }} />
|
||||||
|
<SummaryRow
|
||||||
|
label="Total"
|
||||||
|
value={formatAmount(total)}
|
||||||
|
accent={total > balance ? '#f4212e' : '#ffffff'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', gap: 10, marginTop: 16 }}>
|
||||||
|
<Pressable onPress={onClose} style={{ flex: 1 }}>
|
||||||
|
{({ pressed }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={send} disabled={!ok || sending} style={{ flex: 2 }}>
|
||||||
|
{({ pressed }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: !ok || sending
|
||||||
|
? '#1a1a1a'
|
||||||
|
: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
|
||||||
|
Send
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, marginBottom: 6 }}>{label}</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({
|
||||||
|
label, value, muted, accent,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
muted?: boolean;
|
||||||
|
accent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12 }}>{label}</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: accent ?? (muted ? '#8b8b8b' : '#ffffff'),
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: muted ? '500' : '700',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
client-app/app/(auth)/create.tsx
Normal file
140
client-app/app/(auth)/create.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Create Account — dark minimalist.
|
||||||
|
* Генерирует Ed25519 + X25519 keypair локально, сохраняет в SecureStore.
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, ScrollView, Alert, Pressable, ActivityIndicator } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { generateKeyFile } from '@/lib/crypto';
|
||||||
|
import { saveKeyFile } from '@/lib/storage';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
|
||||||
|
export default function CreateAccountScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const kf = generateKeyFile();
|
||||||
|
await saveKeyFile(kf);
|
||||||
|
setKeyFile(kf);
|
||||||
|
router.replace('/(auth)/created' as never);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Error', e?.message ?? 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<Header
|
||||||
|
title="Create account"
|
||||||
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
|
||||||
|
/>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700', marginBottom: 4 }}>
|
||||||
|
A new identity is created locally
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 18 }}>
|
||||||
|
Your private key never leaves this device. The app encrypts it in the
|
||||||
|
platform secure store.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InfoRow icon="key-outline" label="Ed25519 signing key" desc="Your on-chain address and tx signer" first />
|
||||||
|
<InfoRow icon="lock-closed-outline" label="X25519 encryption key" desc="End-to-end encryption for messages" />
|
||||||
|
<InfoRow icon="phone-portrait-outline" label="Stored on device" desc="Encrypted in SecureStore / Keystore" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(240,179,90,0.08)',
|
||||||
|
borderWidth: 1, borderColor: 'rgba(240,179,90,0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
|
||||||
|
<Ionicons name="warning-outline" size={14} color="#f0b35a" style={{ marginRight: 6 }} />
|
||||||
|
<Text style={{ color: '#f0b35a', fontSize: 13, fontWeight: '700' }}>Important</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#d0a26a', fontSize: 12, lineHeight: 17 }}>
|
||||||
|
Export and backup your key file right after creation. If you lose
|
||||||
|
it there is no recovery — blockchain has no password reset.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCreate}
|
||||||
|
disabled={loading}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingVertical: 13, borderRadius: 999, marginTop: 20,
|
||||||
|
backgroundColor: loading ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}>
|
||||||
|
Generate keys & continue
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
icon, label, desc, first,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
label: string;
|
||||||
|
desc: string;
|
||||||
|
first?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 14,
|
||||||
|
gap: 12,
|
||||||
|
borderTopWidth: first ? 0 : 1,
|
||||||
|
borderTopColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, borderRadius: 8,
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={16} color="#ffffff" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}>{label}</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>{desc}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
client-app/app/(auth)/created.tsx
Normal file
196
client-app/app/(auth)/created.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Account Created confirmation screen — dark minimalist.
|
||||||
|
* Показывает адрес + x25519, кнопки copy и export (share) key.json.
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, ScrollView, Alert, Pressable, Share } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
|
||||||
|
export default function AccountCreatedScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!keyFile) {
|
||||||
|
router.replace('/');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(value: string, label: string) {
|
||||||
|
await Clipboard.setStringAsync(value);
|
||||||
|
setCopied(label);
|
||||||
|
setTimeout(() => setCopied(null), 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportKey() {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(keyFile, null, 2);
|
||||||
|
// Используем плоский Share API — без записи во временный файл.
|
||||||
|
// Получатель (mail, notes, etc.) получит текст целиком; юзер сам
|
||||||
|
// сохраняет как .json если нужно.
|
||||||
|
await Share.share({
|
||||||
|
message: json,
|
||||||
|
title: 'DChain key file',
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Export failed', e?.message ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<Header title="Account created" />
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
|
||||||
|
{/* Success badge */}
|
||||||
|
<View style={{ alignItems: 'center', marginTop: 10, marginBottom: 18 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 64, height: 64, borderRadius: 32,
|
||||||
|
backgroundColor: 'rgba(59,165,93,0.15)',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark" size={32} color="#3ba55d" />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 20, fontWeight: '800' }}>
|
||||||
|
Welcome aboard
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 6, textAlign: 'center' }}>
|
||||||
|
Keys have been generated and stored securely.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<KeyCard
|
||||||
|
title="Your address (Ed25519)"
|
||||||
|
value={keyFile.pub_key}
|
||||||
|
copied={copied === 'address'}
|
||||||
|
onCopy={() => copy(keyFile.pub_key, 'address')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* X25519 */}
|
||||||
|
<View style={{ height: 10 }} />
|
||||||
|
<KeyCard
|
||||||
|
title="Encryption key (X25519)"
|
||||||
|
value={keyFile.x25519_pub}
|
||||||
|
copied={copied === 'x25519'}
|
||||||
|
onCopy={() => copy(keyFile.x25519_pub, 'x25519')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Backup */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: 'rgba(240,179,90,0.08)',
|
||||||
|
borderWidth: 1, borderColor: 'rgba(240,179,90,0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<Ionicons name="lock-closed-outline" size={14} color="#f0b35a" />
|
||||||
|
<Text style={{ color: '#f0b35a', fontSize: 13, fontWeight: '700', marginLeft: 6 }}>
|
||||||
|
Backup your key file
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#d0a26a', fontSize: 12, lineHeight: 17, marginBottom: 10 }}>
|
||||||
|
Export it now and store somewhere safe — password managers, cold
|
||||||
|
storage, printed paper. If you lose it, you lose the account.
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={exportKey}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingVertical: 10, borderRadius: 999,
|
||||||
|
backgroundColor: pressed ? '#2a1f0f' : '#1a1409',
|
||||||
|
borderWidth: 1, borderColor: 'rgba(240,179,90,0.35)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#f0b35a', fontWeight: '700', fontSize: 14 }}>
|
||||||
|
Export key.json
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Continue */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.replace('/(app)/chats' as never)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingVertical: 14, borderRadius: 999, marginTop: 20,
|
||||||
|
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}>
|
||||||
|
Open messenger
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyCard({
|
||||||
|
title, value, copied, onCopy,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
copied: boolean;
|
||||||
|
onCopy: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
padding: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
|
||||||
|
textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 12, fontFamily: 'monospace', lineHeight: 18 }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onCopy}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 9, borderRadius: 999,
|
||||||
|
marginTop: 10,
|
||||||
|
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={copied ? 'checkmark' : 'copy-outline'}
|
||||||
|
size={14}
|
||||||
|
color={copied ? '#3ba55d' : '#ffffff'}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: copied ? '#3ba55d' : '#ffffff',
|
||||||
|
fontSize: 13, fontWeight: '600', marginLeft: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
client-app/app/(auth)/import.tsx
Normal file
231
client-app/app/(auth)/import.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* Import existing key — dark minimalist.
|
||||||
|
* Два пути:
|
||||||
|
* 1. Paste JSON напрямую в textarea.
|
||||||
|
* 2. Pick файл .json через DocumentPicker.
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, TextInput, Alert, Pressable, ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { saveKeyFile } from '@/lib/storage';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
import type { KeyFile } from '@/lib/types';
|
||||||
|
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
|
||||||
|
type Tab = 'paste' | 'file';
|
||||||
|
|
||||||
|
const REQUIRED_FIELDS: (keyof KeyFile)[] = ['pub_key', 'priv_key', 'x25519_pub', 'x25519_priv'];
|
||||||
|
|
||||||
|
function validateKeyFile(raw: string): KeyFile {
|
||||||
|
let parsed: any;
|
||||||
|
try { parsed = JSON.parse(raw.trim()); }
|
||||||
|
catch { throw new Error('Invalid JSON — check that you copied the full key file.'); }
|
||||||
|
for (const field of REQUIRED_FIELDS) {
|
||||||
|
if (!parsed[field] || typeof parsed[field] !== 'string') {
|
||||||
|
throw new Error(`Missing or invalid field: "${field}"`);
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-f]+$/i.test(parsed[field])) {
|
||||||
|
throw new Error(`Field "${field}" must be a hex string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed as KeyFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportKeyScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<Tab>('paste');
|
||||||
|
const [jsonText, setJsonText] = useState('');
|
||||||
|
const [fileName, setFileName] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function applyKey(kf: KeyFile) {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
try {
|
||||||
|
await saveKeyFile(kf);
|
||||||
|
setKeyFile(kf);
|
||||||
|
router.replace('/(app)/chats' as never);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? 'Import failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasteImport() {
|
||||||
|
setError(null);
|
||||||
|
const text = jsonText.trim();
|
||||||
|
if (!text) {
|
||||||
|
const clip = await Clipboard.getStringAsync();
|
||||||
|
if (clip) setJsonText(clip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { await applyKey(validateKeyFile(text)); }
|
||||||
|
catch (e: any) { setError(e?.message ?? 'Import failed'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFile() {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: ['application/json', 'text/plain', '*/*'],
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
if (result.canceled) return;
|
||||||
|
const asset = result.assets[0];
|
||||||
|
setFileName(asset.name);
|
||||||
|
const response = await fetch(asset.uri);
|
||||||
|
const raw = await response.text();
|
||||||
|
await applyKey(validateKeyFile(raw));
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? 'Import failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<Header
|
||||||
|
title="Import key"
|
||||||
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 14, paddingBottom: 40 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 14 }}>
|
||||||
|
Restore your account from a previously exported{' '}
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '600' }}>dchain_key.json</Text>.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['paste', 'file'] as Tab[]).map(t => (
|
||||||
|
<Pressable
|
||||||
|
key={t}
|
||||||
|
onPress={() => setTab(t)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: tab === t ? '#1d9bf0' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: tab === t ? '#ffffff' : '#8b8b8b',
|
||||||
|
fontWeight: '700', fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t === 'paste' ? 'Paste JSON' : 'Pick file'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{tab === 'paste' ? (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
value={jsonText}
|
||||||
|
onChangeText={setJsonText}
|
||||||
|
placeholder='{"pub_key":"…","priv_key":"…","x25519_pub":"…","x25519_priv":"…"}'
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
multiline
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
minHeight: 180,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePasteImport}
|
||||||
|
disabled={loading}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingVertical: 12, borderRadius: 999, marginTop: 12,
|
||||||
|
backgroundColor: loading ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
|
||||||
|
{jsonText.trim() ? 'Import key' : 'Paste from clipboard'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
onPress={pickFile}
|
||||||
|
disabled={loading}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingVertical: 40, borderRadius: 14,
|
||||||
|
backgroundColor: pressed ? '#111111' : '#0a0a0a',
|
||||||
|
borderWidth: 1, borderStyle: 'dashed', borderColor: '#1f1f1f',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="document-outline" size={32} color="#8b8b8b" />
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700', marginTop: 10 }}>
|
||||||
|
{fileName ?? 'Tap to pick key.json'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 4 }}>
|
||||||
|
Will auto-import on selection
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{loading && (
|
||||||
|
<View style={{ alignItems: 'center', marginTop: 12 }}>
|
||||||
|
<ActivityIndicator color="#1d9bf0" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 14,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(244,33,46,0.08)',
|
||||||
|
borderWidth: 1, borderColor: 'rgba(244,33,46,0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#f4212e', fontSize: 13 }}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
client-app/app/_layout.tsx
Normal file
59
client-app/app/_layout.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import '../global.css';
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
// GestureHandlerRootView обязателен для работы gesture-handler'а
|
||||||
|
// на всех страницах: Pan/LongPress/Tap жестах внутри чатов.
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { loadKeyFile, loadSettings } from '@/lib/storage';
|
||||||
|
import { setNodeUrl } from '@/lib/api';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
const setSettings = useStore(s => s.setSettings);
|
||||||
|
const booted = useStore(s => s.booted);
|
||||||
|
const setBooted = useStore(s => s.setBooted);
|
||||||
|
|
||||||
|
// Bootstrap: load key + settings from storage синхронно до первого
|
||||||
|
// render'а экранов. Пока `booted=false` мы рендерим чёрный экран —
|
||||||
|
// это убирает "мелькание" welcome'а при старте, когда ключи уже есть
|
||||||
|
// в AsyncStorage, но ещё не успели загрузиться в store.
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [kf, settings] = await Promise.all([loadKeyFile(), loadSettings()]);
|
||||||
|
if (kf) setKeyFile(kf);
|
||||||
|
setSettings(settings);
|
||||||
|
setNodeUrl(settings.nodeUrl);
|
||||||
|
} finally {
|
||||||
|
setBooted(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<View className="flex-1 bg-background">
|
||||||
|
<StatusBar style="light" />
|
||||||
|
{booted ? (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: '#000000' },
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Пустой чёрный экран пока bootstrap идёт — без flicker'а.
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000' }} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
519
client-app/app/index.tsx
Normal file
519
client-app/app/index.tsx
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
/**
|
||||||
|
* Onboarding — 3-слайдовый pager перед auth-экранами.
|
||||||
|
*
|
||||||
|
* Slide 1 — "Why DChain": value-proposition, 3 пункта с иконками.
|
||||||
|
* Slide 2 — "How it works": выбор релей-ноды (public paid vs свой node),
|
||||||
|
* ссылка на Gitea, + node URL input с live ping.
|
||||||
|
* Slide 3 — "Your keys": кнопки Create / Import.
|
||||||
|
*
|
||||||
|
* Если `keyFile` в store уже есть (bootstrap из RootLayout загрузил) —
|
||||||
|
* делаем <Redirect /> в (app), чтобы пользователь не видел вообще никакого
|
||||||
|
* мелькания onboarding'а. До загрузки `booted === false` root показывает
|
||||||
|
* чёрный экран.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, Pressable, ScrollView,
|
||||||
|
Alert, ActivityIndicator, Linking, Dimensions,
|
||||||
|
useWindowDimensions,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router, Redirect } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { saveSettings } from '@/lib/storage';
|
||||||
|
import { setNodeUrl, getNetStats } from '@/lib/api';
|
||||||
|
|
||||||
|
const { width: SCREEN_W } = Dimensions.get('window');
|
||||||
|
const GITEA_URL = 'https://git.vsecoder.vodka/vsecoder/dchain';
|
||||||
|
|
||||||
|
export default function WelcomeScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { height: SCREEN_H } = useWindowDimensions();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const booted = useStore(s => s.booted);
|
||||||
|
const settings = useStore(s => s.settings);
|
||||||
|
const setSettings = useStore(s => s.setSettings);
|
||||||
|
|
||||||
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [nodeInput, setNodeInput] = useState('');
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [nodeOk, setNodeOk] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
|
||||||
|
useEffect(() => { setNodeInput(settings.nodeUrl); }, [settings.nodeUrl]);
|
||||||
|
|
||||||
|
// ВСЕ hooks должны быть объявлены ДО любого early-return, иначе
|
||||||
|
// React на следующем render'е посчитает разное число hooks и выкинет
|
||||||
|
// "Rendered fewer hooks than expected". useCallback ниже — тоже hook.
|
||||||
|
const applyNode = useCallback(async (url: string) => {
|
||||||
|
const clean = url.trim().replace(/\/$/, '');
|
||||||
|
if (!clean) return;
|
||||||
|
setChecking(true);
|
||||||
|
setNodeOk(null);
|
||||||
|
setNodeUrl(clean);
|
||||||
|
try {
|
||||||
|
await getNetStats();
|
||||||
|
setNodeOk(true);
|
||||||
|
const next = { ...settings, nodeUrl: clean };
|
||||||
|
setSettings(next);
|
||||||
|
await saveSettings(next);
|
||||||
|
} catch {
|
||||||
|
setNodeOk(false);
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
}, [settings, setSettings]);
|
||||||
|
|
||||||
|
const onQrScanned = useCallback(({ data }: { data: string }) => {
|
||||||
|
setScanning(false);
|
||||||
|
let url = data.trim();
|
||||||
|
try { const p = JSON.parse(url); if (p.nodeUrl) url = p.nodeUrl; } catch {}
|
||||||
|
setNodeInput(url);
|
||||||
|
applyNode(url);
|
||||||
|
}, [applyNode]);
|
||||||
|
|
||||||
|
// Bootstrap ещё не закончился — ничего не рендерим, RootLayout покажет
|
||||||
|
// чёрный экран (single source of truth для splash-state'а).
|
||||||
|
if (!booted) return null;
|
||||||
|
|
||||||
|
// Ключи уже загружены — сразу в main app, без мелькания onboarding'а.
|
||||||
|
if (keyFile) return <Redirect href={'/(app)/chats' as never} />;
|
||||||
|
|
||||||
|
const openScanner = async () => {
|
||||||
|
if (!permission?.granted) {
|
||||||
|
const { granted } = await requestPermission();
|
||||||
|
if (!granted) {
|
||||||
|
Alert.alert('Camera permission required', 'Allow camera access to scan QR codes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setScanning(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPage = (p: number) => {
|
||||||
|
scrollRef.current?.scrollTo({ x: p * SCREEN_W, animated: true });
|
||||||
|
setPage(p);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (scanning) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000' }}>
|
||||||
|
<CameraView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
facing="back"
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||||
|
onBarcodeScanned={onQrScanned}
|
||||||
|
/>
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<View style={{ width: 240, height: 240, borderWidth: 2, borderColor: '#fff', borderRadius: 16 }} />
|
||||||
|
<Text style={{ color: '#fff', marginTop: 20, opacity: 0.8 }}>
|
||||||
|
Point at a DChain node QR code
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setScanning(false)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 56, left: 16,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20,
|
||||||
|
paddingHorizontal: 16, paddingVertical: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontSize: 16 }}>✕ Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor = nodeOk === true ? '#3ba55d' : nodeOk === false ? '#f4212e' : '#8b8b8b';
|
||||||
|
|
||||||
|
// Высота footer'а (dots + inset) — резервируем под неё снизу каждого
|
||||||
|
// слайда, чтобы CTA-кнопки оказывались прямо над индикатором страниц,
|
||||||
|
// а не залезали под него.
|
||||||
|
const FOOTER_H = Math.max(insets.bottom, 20) + 8 + 12 + 7; // = padBottom + padTop + dot
|
||||||
|
const PAGE_H = SCREEN_H - FOOTER_H;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000' }}>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
horizontal
|
||||||
|
pagingEnabled
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
onMomentumScrollEnd={e => {
|
||||||
|
const p = Math.round(e.nativeEvent.contentOffset.x / SCREEN_W);
|
||||||
|
setPage(p);
|
||||||
|
}}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* ───────── Slide 1: Why DChain ───────── */}
|
||||||
|
<View style={{ width: SCREEN_W, height: PAGE_H }}>
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 28,
|
||||||
|
paddingTop: insets.top + 60,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={{ alignItems: 'center', marginBottom: 36 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 88, height: 88, borderRadius: 24,
|
||||||
|
backgroundColor: '#1d9bf0',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="chatbubbles" size={44} color="#ffffff" />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 30, fontWeight: '800', letterSpacing: -0.8 }}>
|
||||||
|
DChain
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 14, lineHeight: 20, marginTop: 6 }}>
|
||||||
|
A messenger that belongs to you.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FeatureRow
|
||||||
|
icon="lock-closed"
|
||||||
|
title="End-to-end encryption"
|
||||||
|
text="X25519 + NaCl on every message. Not even the relay node can read your conversations."
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
icon="key"
|
||||||
|
title="Your keys, your account"
|
||||||
|
text="No phone, email, or server passwords. Keys never leave your device."
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
icon="git-network"
|
||||||
|
title="Decentralised"
|
||||||
|
text="Anyone can run a node. No single point of failure or censorship."
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* CTA — прижата к правому нижнему краю. */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', justifyContent: 'flex-end',
|
||||||
|
paddingHorizontal: 24, paddingBottom: 8,
|
||||||
|
}}>
|
||||||
|
<CTAPrimary label="Continue" onPress={() => goToPage(1)} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ───────── Slide 2: How it works ───────── */}
|
||||||
|
<View style={{ width: SCREEN_W, height: PAGE_H }}>
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 28,
|
||||||
|
paddingTop: insets.top + 40,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5 }}>
|
||||||
|
How it works
|
||||||
|
</Text>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<OptionCard
|
||||||
|
icon="globe"
|
||||||
|
title="Public node"
|
||||||
|
text="Quick and easy — community-hosted relay, small fee per delivered message."
|
||||||
|
/>
|
||||||
|
<OptionCard
|
||||||
|
icon="hardware-chip"
|
||||||
|
title="Self-hosted"
|
||||||
|
text="Maximum control. Source is open — spin up your own in five minutes."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{
|
||||||
|
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
|
||||||
|
textTransform: 'uppercase', letterSpacing: 1.2, marginTop: 20, marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
Node URL
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1, flexDirection: 'row', alignItems: 'center',
|
||||||
|
backgroundColor: '#0a0a0a', borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
borderRadius: 12, paddingHorizontal: 12, gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ width: 7, height: 7, borderRadius: 3.5, backgroundColor: statusColor }} />
|
||||||
|
<TextInput
|
||||||
|
value={nodeInput}
|
||||||
|
onChangeText={t => { setNodeInput(t); setNodeOk(null); }}
|
||||||
|
onEndEditing={() => applyNode(nodeInput)}
|
||||||
|
onSubmitEditing={() => applyNode(nodeInput)}
|
||||||
|
placeholder="http://192.168.1.10:8080"
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
style={{ flex: 1, color: '#ffffff', fontSize: 14, paddingVertical: 12 }}
|
||||||
|
/>
|
||||||
|
{checking
|
||||||
|
? <ActivityIndicator size="small" color="#8b8b8b" />
|
||||||
|
: nodeOk === true
|
||||||
|
? <Ionicons name="checkmark" size={16} color="#3ba55d" />
|
||||||
|
: nodeOk === false
|
||||||
|
? <Ionicons name="close" size={16} color="#f4212e" />
|
||||||
|
: null}
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={openScanner}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 48, alignItems: 'center', justifyContent: 'center',
|
||||||
|
backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
borderRadius: 12,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="qr-code-outline" size={22} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{nodeOk === false && (
|
||||||
|
<Text style={{ color: '#f4212e', fontSize: 12, marginTop: 6 }}>
|
||||||
|
Cannot reach node — check URL and that the node is running
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* CTA — прижата к правому нижнему краю. */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
|
||||||
|
paddingHorizontal: 24, paddingBottom: 8,
|
||||||
|
}}>
|
||||||
|
<CTASecondary
|
||||||
|
label="Source"
|
||||||
|
icon="logo-github"
|
||||||
|
onPress={() => Linking.openURL(GITEA_URL).catch(() => {})}
|
||||||
|
/>
|
||||||
|
<CTAPrimary label="Continue" onPress={() => goToPage(2)} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ───────── Slide 3: Your keys ───────── */}
|
||||||
|
<View style={{ width: SCREEN_W, height: PAGE_H }}>
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 28,
|
||||||
|
paddingTop: insets.top + 60,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={{ alignItems: 'center', marginBottom: 36 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 88, height: 88, borderRadius: 24,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="key" size={44} color="#1d9bf0" />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5, textAlign: 'center' }}>
|
||||||
|
Your account
|
||||||
|
</Text>
|
||||||
|
<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>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* CTA — прижата к правому нижнему краю. */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
|
||||||
|
paddingHorizontal: 24, paddingBottom: 8,
|
||||||
|
}}>
|
||||||
|
<CTASecondary
|
||||||
|
label="Import"
|
||||||
|
onPress={() => router.push('/(auth)/import' as never)}
|
||||||
|
/>
|
||||||
|
<CTAPrimary
|
||||||
|
label="Create account"
|
||||||
|
onPress={() => router.push('/(auth)/create' as never)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Footer: dots-only pager indicator. CTA-кнопки теперь inline
|
||||||
|
на каждом слайде, чтобы выглядели как полноценные кнопки, а не
|
||||||
|
мелкий "Далее" в углу. */}
|
||||||
|
<View style={{
|
||||||
|
paddingHorizontal: 28,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 20) + 8,
|
||||||
|
paddingTop: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
}}>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<Pressable
|
||||||
|
key={i}
|
||||||
|
onPress={() => goToPage(i)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={{
|
||||||
|
width: page === i ? 22 : 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 3.5,
|
||||||
|
backgroundColor: page === i ? '#1d9bf0' : '#2a2a2a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────── helper components ─────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary CTA button — синий pill. Натуральная ширина (hugs content),
|
||||||
|
* `numberOfLines={1}` на лейбле чтобы текст не переносился. Фон
|
||||||
|
* применяется через inner View, а не напрямую на Pressable — это
|
||||||
|
* обходит редкие RN-баги, когда backgroundColor на Pressable не
|
||||||
|
* рендерится пока кнопка не нажата.
|
||||||
|
*/
|
||||||
|
function CTAPrimary({ label, onPress }: { label: string; onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.85 : 1 })}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 46,
|
||||||
|
paddingHorizontal: 22,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: '#1d9bf0',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Secondary CTA — тёмный pill с border'ом, optional icon слева. */
|
||||||
|
function CTASecondary({
|
||||||
|
label, icon, onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon?: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 46,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon && <Ionicons name={icon} size={15} color="#ffffff" />}
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureRow({
|
||||||
|
icon, title, text,
|
||||||
|
}: { icon: React.ComponentProps<typeof Ionicons>['name']; title: string; text: string }) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', marginBottom: 20, gap: 14 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40, height: 40, borderRadius: 12,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={20} color="#1d9bf0" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700', marginBottom: 3 }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19 }}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionCard({
|
||||||
|
icon, title, text, actionLabel, onAction,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
borderRadius: 14, padding: 14, marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
||||||
|
<Ionicons name={icon} size={18} color="#1d9bf0" />
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19 }}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<Pressable onPress={onAction} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, marginTop: 8 })}>
|
||||||
|
<Text style={{ color: '#1d9bf0', fontSize: 13, fontWeight: '600' }}>
|
||||||
|
{actionLabel}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
client-app/babel.config.js
Normal file
12
client-app/babel.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||||
|
'nativewind/babel',
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
'react-native-reanimated/plugin', // must be last
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
35
client-app/components/AnimatedSlot.tsx
Normal file
35
client-app/components/AnimatedSlot.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* AnimatedSlot — renders the (app) group as a native <Stack>.
|
||||||
|
*
|
||||||
|
* Why Stack instead of Slot: Slot is stack-less. When a child route
|
||||||
|
* (e.g. profile/[address]) pushes another child, Slot swaps content
|
||||||
|
* with no history, so router.back() falls all the way through to the
|
||||||
|
* URL root instead of returning to the caller (e.g. /chats/xyz →
|
||||||
|
* /profile/abc → back should go to /chats/xyz, not to /chats).
|
||||||
|
*
|
||||||
|
* Tab switching stays flat because NavBar uses router.replace, which
|
||||||
|
* maps to navigation.replace on the Stack → no history accumulation.
|
||||||
|
*
|
||||||
|
* animation: 'none' on tab roots keeps tab-swap instant (matches the
|
||||||
|
* prior Slot look). Sub-routes (profile/*, compose, feed/*, chats/[id])
|
||||||
|
* inherit slide_from_right from their own nested _layout.tsx Stacks,
|
||||||
|
* which is where the push animation happens.
|
||||||
|
*
|
||||||
|
* This file is named AnimatedSlot for git-history continuity — the
|
||||||
|
* Animated.Value + translateX slide was removed earlier (got stuck at
|
||||||
|
* ±width when interrupted by re-render cascades).
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export function AnimatedSlot() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: '#000000' },
|
||||||
|
animation: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
client-app/components/Avatar.tsx
Normal file
86
client-app/components/Avatar.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Avatar — круглая заглушка с инициалом, опционально online-пип.
|
||||||
|
* Нет зависимостей от асинхронных источников (картинок) — для messenger-тайла
|
||||||
|
* важнее мгновенный рендер, чем фотография. Если в будущем будут фото,
|
||||||
|
* расширяем здесь.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
export interface AvatarProps {
|
||||||
|
/** Имя / @username — берём первый символ для placeholder. */
|
||||||
|
name?: string;
|
||||||
|
/** Адрес (hex pubkey) — fallback для тех у кого нет имени. */
|
||||||
|
address?: string;
|
||||||
|
/** Общий размер в px. По умолчанию 48 (tile size). */
|
||||||
|
size?: number;
|
||||||
|
/** Цвет пипа справа-снизу. undefined = без пипа. */
|
||||||
|
dotColor?: string;
|
||||||
|
/** Класс для обёртки (position: relative кадр). */
|
||||||
|
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 оттенков серого для разнообразия. */
|
||||||
|
function pickBg(seed: string): string {
|
||||||
|
const shades = ['#1a1a1a', '#222222', '#2a2a2a', '#151515', '#1c1c1c', '#1f1f1f'];
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) & 0xffff;
|
||||||
|
return shades[h % shades.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ name, address, size = 48, dotColor, className, saved }: AvatarProps) {
|
||||||
|
const seed = (name ?? address ?? '?').replace(/^@/, '');
|
||||||
|
const initial = seed.charAt(0).toUpperCase() || '?';
|
||||||
|
const bg = saved ? '#1d9bf0' : pickBg(seed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={className} style={{ width: size, height: size, position: 'relative' }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size / 2,
|
||||||
|
backgroundColor: bg,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saved ? (
|
||||||
|
<Ionicons name="bookmark" size={size * 0.5} color="#ffffff" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#d0d0d0',
|
||||||
|
fontSize: size * 0.4,
|
||||||
|
fontWeight: '600',
|
||||||
|
includeFontPadding: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{dotColor && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: size * 0.28,
|
||||||
|
height: size * 0.28,
|
||||||
|
borderRadius: size * 0.14,
|
||||||
|
backgroundColor: dotColor,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#000',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
client-app/components/ChatTile.tsx
Normal file
179
client-app/components/ChatTile.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* ChatTile — одна строка в списке чатов на главной (Messages screen).
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* [avatar 44] [name (+verified) (+kind-icon)] [time]
|
||||||
|
* [last-msg preview] [unread pill]
|
||||||
|
*
|
||||||
|
* Kind-icon — мегафон для channel, 👥 для group, ничего для direct.
|
||||||
|
* Verified checkmark — если у контакта есть @username.
|
||||||
|
* Online-dot на аватарке — только для direct-чатов с x25519 ключом.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, Pressable } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
import type { Contact, Message } from '@/lib/types';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { formatWhen } from '@/lib/dates';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
function previewText(s: string, max = 50): string {
|
||||||
|
return s.length <= max ? s : s.slice(0, max).trimEnd() + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текстовое превью последнего сообщения. Если у сообщения нет текста
|
||||||
|
* (только вложение) — возвращаем маркер с иконкой названием типа:
|
||||||
|
* "🖼 Photo" / "🎬 Video" / "🎙 Voice" / "📎 File"
|
||||||
|
* Если текст есть — он используется; если есть и то и другое, префикс
|
||||||
|
* добавляется перед текстом.
|
||||||
|
*/
|
||||||
|
function lastPreview(m: Message): string {
|
||||||
|
const emojiByKind = {
|
||||||
|
image: '🖼', video: '🎬', voice: '🎙', file: '📎',
|
||||||
|
} as const;
|
||||||
|
const labelByKind = {
|
||||||
|
image: 'Photo', video: 'Video', voice: 'Voice message', file: 'File',
|
||||||
|
} as const;
|
||||||
|
const text = m.text.trim();
|
||||||
|
if (m.attachment) {
|
||||||
|
const prefix = `${emojiByKind[m.attachment.kind]} ${labelByKind[m.attachment.kind]}`;
|
||||||
|
return text ? `${prefix} ${previewText(text, 40)}` : prefix;
|
||||||
|
}
|
||||||
|
return previewText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortAddr(a: string, n = 5): string {
|
||||||
|
if (!a) return '—';
|
||||||
|
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(c: Contact): string {
|
||||||
|
return c.username ? `@${c.username}` : c.alias ?? shortAddr(c.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatTileProps {
|
||||||
|
contact: Contact;
|
||||||
|
lastMessage: Message | null;
|
||||||
|
onPress: () => void;
|
||||||
|
/** Render as the Saved Messages tile (blue bookmark avatar, fixed name). */
|
||||||
|
saved?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatTile({ contact: c, lastMessage, onPress, saved }: ChatTileProps) {
|
||||||
|
const name = saved ? 'Saved Messages' : displayName(c);
|
||||||
|
const last = lastMessage;
|
||||||
|
|
||||||
|
// Визуальный маркер типа чата.
|
||||||
|
const kindIcon: React.ComponentProps<typeof Ionicons>['name'] | null =
|
||||||
|
c.kind === 'group' ? 'people' : null;
|
||||||
|
|
||||||
|
// Unread берётся из runtime-store'а (инкрементится в useGlobalInbox,
|
||||||
|
// обнуляется при открытии чата). Fallback на c.unread для legacy seed.
|
||||||
|
const storeUnread = useStore(s => s.unreadByContact[c.address] ?? 0);
|
||||||
|
const unreadCount = storeUnread || (c.unread ?? 0);
|
||||||
|
const unread = unreadCount > 0 ? unreadCount : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
backgroundColor: pressed ? '#0a0a0a' : 'transparent',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
name={name}
|
||||||
|
address={c.address}
|
||||||
|
size={44}
|
||||||
|
saved={saved}
|
||||||
|
dotColor={!saved && c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
|
||||||
|
{/* Первая строка: [kind-icon] name [verified] ··· time */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
{kindIcon && (
|
||||||
|
<Ionicons
|
||||||
|
name={kindIcon}
|
||||||
|
size={12}
|
||||||
|
color="#8b8b8b"
|
||||||
|
style={{ marginRight: 5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ color: '#ffffff', fontWeight: '700', fontSize: 15, flex: 1 }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{c.username && (
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark-circle"
|
||||||
|
size={14}
|
||||||
|
color="#1d9bf0"
|
||||||
|
style={{ marginLeft: 4, marginRight: 2 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{last && (
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, marginLeft: 6 }}>
|
||||||
|
{formatWhen(last.timestamp)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Вторая строка: [✓✓ mine-seen] preview ··· [unread] */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
|
||||||
|
{last?.mine && (
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark-done-outline"
|
||||||
|
size={13}
|
||||||
|
color="#8b8b8b"
|
||||||
|
style={{ marginRight: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}
|
||||||
|
>
|
||||||
|
{last
|
||||||
|
? lastPreview(last)
|
||||||
|
: saved
|
||||||
|
? 'Your personal notes & files'
|
||||||
|
: c.x25519Pub
|
||||||
|
? 'Tap to start encrypted chat'
|
||||||
|
: 'Waiting for identity…'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{unread !== null && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
minWidth: 18,
|
||||||
|
height: 18,
|
||||||
|
paddingHorizontal: 5,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: '#1d9bf0',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '700' }}>
|
||||||
|
{unread > 99 ? '99+' : unread}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
client-app/components/Composer.tsx
Normal file
329
client-app/components/Composer.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* Composer — плавающий блок ввода сообщения, прибит к низу.
|
||||||
|
*
|
||||||
|
* Композиция:
|
||||||
|
* 1. Опциональный баннер (edit / reply) сверху.
|
||||||
|
* 2. Опциональная pending-attachment preview.
|
||||||
|
* 3. Либо:
|
||||||
|
* - обычный input-bubble с `[+] [textarea] [↑/🎤/⭕]`
|
||||||
|
* - inline VoiceRecorder когда идёт запись голосового
|
||||||
|
*
|
||||||
|
* Send-action зависит от состояния:
|
||||||
|
* - есть текст/attachment → ↑ (send)
|
||||||
|
* - пусто → показываем две иконки: 🎤 (start voice) + ⭕ (open video circle)
|
||||||
|
*
|
||||||
|
* API:
|
||||||
|
* mode, onCancelMode
|
||||||
|
* text, onChangeText
|
||||||
|
* onSend, sending
|
||||||
|
* onAttach — tap на + (AttachmentMenu)
|
||||||
|
* attachment, onClearAttach
|
||||||
|
* onFinishVoice — готовая voice-attachment (из VoiceRecorder)
|
||||||
|
* onStartVideoCircle — tap на ⭕, родитель открывает VideoCircleRecorder
|
||||||
|
* placeholder
|
||||||
|
*/
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { View, Text, TextInput, Pressable, ActivityIndicator, Image } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
import type { Attachment } from '@/lib/types';
|
||||||
|
import { VoiceRecorder } from '@/components/chat/VoiceRecorder';
|
||||||
|
|
||||||
|
export type ComposerMode =
|
||||||
|
| { kind: 'new' }
|
||||||
|
| { kind: 'edit'; text: string }
|
||||||
|
| { kind: 'reply'; msgId: string; author: string; preview: string };
|
||||||
|
|
||||||
|
export interface ComposerProps {
|
||||||
|
mode: ComposerMode;
|
||||||
|
onCancelMode?: () => void;
|
||||||
|
|
||||||
|
text: string;
|
||||||
|
onChangeText: (t: string) => void;
|
||||||
|
|
||||||
|
onSend: () => void;
|
||||||
|
sending?: boolean;
|
||||||
|
|
||||||
|
onAttach?: () => void;
|
||||||
|
|
||||||
|
attachment?: Attachment | null;
|
||||||
|
onClearAttach?: () => void;
|
||||||
|
|
||||||
|
/** Voice recording завершена и отправляем сразу (мгновенный flow). */
|
||||||
|
onFinishVoice?: (att: Attachment) => void;
|
||||||
|
/** Tap на "⭕" — родитель открывает VideoCircleRecorder. */
|
||||||
|
onStartVideoCircle?: () => void;
|
||||||
|
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INPUT_MIN_HEIGHT = 24;
|
||||||
|
const INPUT_MAX_HEIGHT = 72;
|
||||||
|
|
||||||
|
export function Composer(props: ComposerProps) {
|
||||||
|
const {
|
||||||
|
mode, onCancelMode, text, onChangeText, onSend, sending, onAttach,
|
||||||
|
attachment, onClearAttach,
|
||||||
|
onFinishVoice, onStartVideoCircle,
|
||||||
|
placeholder,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const [recordingVoice, setRecordingVoice] = useState(false);
|
||||||
|
|
||||||
|
const hasContent = !!text.trim() || !!attachment;
|
||||||
|
const canSend = hasContent && !sending;
|
||||||
|
const inEdit = mode.kind === 'edit';
|
||||||
|
const inReply = mode.kind === 'reply';
|
||||||
|
|
||||||
|
const focusInput = () => inputRef.current?.focus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ paddingHorizontal: 8, paddingTop: 6, paddingBottom: 4, gap: 6 }}>
|
||||||
|
{/* ── Banner: edit / reply ── */}
|
||||||
|
{(inEdit || inReply) && !recordingVoice && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
borderRadius: 18,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={inEdit ? 'create-outline' : 'arrow-undo-outline'}
|
||||||
|
size={16}
|
||||||
|
color="#ffffff"
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{inEdit && (
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}>
|
||||||
|
Edit message
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{inReply && (
|
||||||
|
<>
|
||||||
|
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '700' }} numberOfLines={1}>
|
||||||
|
Reply to {(mode as { author: string }).author}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12 }} numberOfLines={1}>
|
||||||
|
{(mode as { preview: string }).preview}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={onCancelMode}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={20} color="#8b8b8b" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Pending attachment preview ── */}
|
||||||
|
{attachment && !recordingVoice && (
|
||||||
|
<AttachmentChip attachment={attachment} onClear={onClearAttach} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Voice recording (inline) ИЛИ обычный input ── */}
|
||||||
|
{recordingVoice ? (
|
||||||
|
<VoiceRecorder
|
||||||
|
onFinish={(att) => {
|
||||||
|
setRecordingVoice(false);
|
||||||
|
onFinishVoice?.(att);
|
||||||
|
}}
|
||||||
|
onCancel={() => setRecordingVoice(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Pressable onPress={focusInput}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
borderRadius: 22,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
paddingLeft: 4,
|
||||||
|
paddingRight: 8,
|
||||||
|
paddingVertical: 6,
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* + attach — всегда, кроме edit */}
|
||||||
|
{onAttach && !inEdit && (
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => { e.stopPropagation?.(); onAttach(); }}
|
||||||
|
hitSlop={6}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 32, height: 32, borderRadius: 16,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={22} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={text}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
placeholder={placeholder ?? 'Message'}
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
multiline
|
||||||
|
maxLength={2000}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
minHeight: INPUT_MIN_HEIGHT,
|
||||||
|
maxHeight: INPUT_MAX_HEIGHT,
|
||||||
|
paddingTop: 6,
|
||||||
|
paddingBottom: 6,
|
||||||
|
paddingLeft: onAttach && !inEdit ? 6 : 10,
|
||||||
|
paddingRight: 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Правая часть: send ИЛИ [mic + video-circle] */}
|
||||||
|
{canSend ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => { e.stopPropagation?.(); onSend(); }}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 32, height: 32, borderRadius: 16,
|
||||||
|
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="arrow-up" size={18} color="#ffffff" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : !inEdit && (onFinishVoice || onStartVideoCircle) ? (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
{onStartVideoCircle && (
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => { e.stopPropagation?.(); onStartVideoCircle(); }}
|
||||||
|
hitSlop={6}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 32, height: 32, borderRadius: 16,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="videocam-outline" size={20} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
{onFinishVoice && (
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => { e.stopPropagation?.(); setRecordingVoice(true); }}
|
||||||
|
hitSlop={6}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 32, height: 32, borderRadius: 16,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="mic-outline" size={20} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Attachment chip — preview текущего pending attachment'а ────────
|
||||||
|
|
||||||
|
function AttachmentChip({
|
||||||
|
attachment, onClear,
|
||||||
|
}: {
|
||||||
|
attachment: Attachment;
|
||||||
|
onClear?: () => void;
|
||||||
|
}) {
|
||||||
|
const icon: React.ComponentProps<typeof Ionicons>['name'] =
|
||||||
|
attachment.kind === 'image' ? 'image-outline' :
|
||||||
|
attachment.kind === 'video' ? 'videocam-outline' :
|
||||||
|
attachment.kind === 'voice' ? 'mic-outline' :
|
||||||
|
'document-outline';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{attachment.kind === 'image' || attachment.kind === 'video' ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: attachment.uri }}
|
||||||
|
style={{
|
||||||
|
width: 40, height: 40,
|
||||||
|
borderRadius: attachment.circle ? 20 : 8,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40, height: 40, borderRadius: 8,
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={20} color="#ffffff" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '600' }} numberOfLines={1}>
|
||||||
|
{attachment.name ?? attachmentLabel(attachment)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 11 }} numberOfLines={1}>
|
||||||
|
{attachment.kind.toUpperCase()}
|
||||||
|
{attachment.circle ? ' · circle' : ''}
|
||||||
|
{attachment.size ? ` · ${(attachment.size / 1024).toFixed(0)} KB` : ''}
|
||||||
|
{attachment.duration ? ` · ${attachment.duration}s` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={onClear}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, padding: 4 })}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={18} color="#8b8b8b" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentLabel(a: Attachment): string {
|
||||||
|
switch (a.kind) {
|
||||||
|
case 'image': return 'Photo';
|
||||||
|
case 'video': return a.circle ? 'Video message' : 'Video';
|
||||||
|
case 'voice': return 'Voice message';
|
||||||
|
case 'file': return 'File';
|
||||||
|
}
|
||||||
|
}
|
||||||
76
client-app/components/Header.tsx
Normal file
76
client-app/components/Header.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Header — единая шапка экрана: [left slot] [title centered] [right slot].
|
||||||
|
*
|
||||||
|
* Правила выравнивания:
|
||||||
|
* - left/right принимают натуральную ширину контента (обычно 1-2
|
||||||
|
* IconButton'а 36px, или pressable-avatar 32px).
|
||||||
|
* - title (ReactNode, принимает как string, так и compound — аватар +
|
||||||
|
* имя вместе) всегда центрирован через flex:1 + alignItems:center.
|
||||||
|
* Абсолютно не позиционируется, т.к. при слишком широком title'е
|
||||||
|
* лучше ужать его, чем наложить на кнопки.
|
||||||
|
*
|
||||||
|
* `title` может быть строкой (тогда рендерится как Text 17px semibold)
|
||||||
|
* либо произвольным node'ом — используется в chat detail для
|
||||||
|
* [avatar][name + typing-subtitle] compound-блока.
|
||||||
|
*
|
||||||
|
* `divider` (default true) — тонкая 1px линия снизу; в tab-страницах
|
||||||
|
* обычно выключена (TabHeader всегда ставит divider=false).
|
||||||
|
*/
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { View, Text } from 'react-native';
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
title?: ReactNode;
|
||||||
|
left?: ReactNode;
|
||||||
|
right?: ReactNode;
|
||||||
|
/** Показывать нижнюю тонкую линию-разделитель. По умолчанию true. */
|
||||||
|
divider?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ title, left, right, divider = true }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: divider ? 1 : 0,
|
||||||
|
borderBottomColor: '#0f0f0f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: 44,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left slot — натуральная ширина, минимум 44 чтобы title
|
||||||
|
визуально центрировался для одно-icon-left + одно-icon-right. */}
|
||||||
|
<View style={{ minWidth: 44, alignItems: 'flex-start' }}>{left}</View>
|
||||||
|
|
||||||
|
{/* Title centered */}
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{typeof title === 'string' ? (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
) : title ?? null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right slot — row, натуральная ширина, минимум 44. gap=4
|
||||||
|
чтобы несколько IconButton'ов не слипались в selection-mode. */}
|
||||||
|
<View style={{ minWidth: 44, flexDirection: 'row', justifyContent: 'flex-end', gap: 4 }}>
|
||||||
|
{right}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
client-app/components/IconButton.tsx
Normal file
61
client-app/components/IconButton.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* IconButton — круглая touch-target кнопка под Ionicon.
|
||||||
|
*
|
||||||
|
* Три варианта:
|
||||||
|
* - 'ghost' — прозрачная, используется в хедере (шестерёнка, back).
|
||||||
|
* - 'solid' — акцентный заливной круг, например composer FAB.
|
||||||
|
* - 'tile' — квадратная заливка 36×36 для небольших action-chip'ов.
|
||||||
|
*
|
||||||
|
* Размер управляется props.size (диаметр). Touch-target никогда меньше 40px
|
||||||
|
* (accessibility), поэтому для size<40 внутренний иконопад растёт.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Pressable, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
|
||||||
|
export interface IconButtonProps {
|
||||||
|
icon: IoniconName;
|
||||||
|
onPress?: () => void;
|
||||||
|
variant?: 'ghost' | 'solid' | 'tile';
|
||||||
|
size?: number; // visual diameter; hit slop ensures accessibility
|
||||||
|
color?: string; // override icon color
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconButton({
|
||||||
|
icon, onPress, variant = 'ghost', size = 40, color, disabled, className,
|
||||||
|
}: IconButtonProps) {
|
||||||
|
const iconSize = Math.round(size * 0.5);
|
||||||
|
const bg =
|
||||||
|
variant === 'solid' ? '#1d9bf0' :
|
||||||
|
variant === 'tile' ? '#1a1a1a' :
|
||||||
|
'transparent';
|
||||||
|
const tint =
|
||||||
|
color ??
|
||||||
|
(variant === 'solid' ? '#ffffff' :
|
||||||
|
disabled ? '#3a3a3a' :
|
||||||
|
'#e7e7e7');
|
||||||
|
|
||||||
|
const radius = variant === 'tile' ? 10 : size / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={disabled ? undefined : onPress}
|
||||||
|
hitSlop={8}
|
||||||
|
className={className}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: radius,
|
||||||
|
backgroundColor: pressed && !disabled ? (variant === 'solid' ? '#1a8cd8' : '#1a1a1a') : bg,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={iconSize} color={tint} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
client-app/components/NavBar.tsx
Normal file
150
client-app/components/NavBar.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* NavBar — нижний бар на 5 иконок без подписей.
|
||||||
|
*
|
||||||
|
* Активный таб:
|
||||||
|
* - иконка заполненная (Ionicons variant без `-outline`)
|
||||||
|
* - вокруг иконки subtle highlight-блок (чуть светлее bg), радиус 14
|
||||||
|
* - текст/бейдж остаются как у inactive
|
||||||
|
*
|
||||||
|
* Inactive:
|
||||||
|
* - outline-иконка, цвет #6b6b6b
|
||||||
|
* - soon-таб дополнительно dimmed и показывает чип SOON
|
||||||
|
*
|
||||||
|
* Роутинг через expo-router `router.replace` — без стекa, каждый tab это
|
||||||
|
* полная страница без "back" концепции.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Pressable, Text } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useRouter, usePathname } from 'expo-router';
|
||||||
|
|
||||||
|
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
key: string;
|
||||||
|
href: string;
|
||||||
|
icon: IoniconName;
|
||||||
|
iconActive: IoniconName;
|
||||||
|
badge?: number;
|
||||||
|
soon?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavBarProps {
|
||||||
|
bottomInset?: number;
|
||||||
|
requestCount?: number;
|
||||||
|
notifCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavBar({ bottomInset = 0, requestCount = 0, notifCount = 0 }: NavBarProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const items: Item[] = [
|
||||||
|
{ key: 'home', href: '/(app)/chats', icon: 'home-outline', iconActive: 'home', badge: requestCount },
|
||||||
|
{ key: 'add', href: '/(app)/new-contact', icon: 'search-outline', iconActive: 'search' },
|
||||||
|
{ key: 'feed', href: '/(app)/feed', icon: 'newspaper-outline', iconActive: 'newspaper' },
|
||||||
|
{ key: 'notif', href: '/(app)/requests', icon: 'notifications-outline', iconActive: 'notifications', badge: notifCount },
|
||||||
|
{ key: 'wallet', href: '/(app)/wallet', icon: 'wallet-outline', iconActive: 'wallet' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// NavBar active-matching: путь может начинаться с "/chats" ИЛИ с href
|
||||||
|
// напрямую. Вариант `/chats/xyz` тоже считается active для home.
|
||||||
|
const isActive = (href: string) => {
|
||||||
|
// Нормализуем /(app)/chats → /chats
|
||||||
|
const norm = href.replace(/^\/\(app\)/, '');
|
||||||
|
return pathname === norm || pathname.startsWith(norm + '/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#0f0f0f',
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: Math.max(bottomInset, 8),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((it) => {
|
||||||
|
const active = isActive(it.href);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={it.key}
|
||||||
|
onPress={() => {
|
||||||
|
if (it.soon) return;
|
||||||
|
router.replace(it.href as never);
|
||||||
|
}}
|
||||||
|
hitSlop={6}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 4,
|
||||||
|
opacity: pressed ? 0.65 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
// highlight-блок вокруг active-иконки
|
||||||
|
width: 52,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={active ? it.iconActive : it.icon}
|
||||||
|
size={26}
|
||||||
|
color={active ? '#ffffff' : it.soon ? '#3a3a3a' : '#6b6b6b'}
|
||||||
|
/>
|
||||||
|
{it.badge && it.badge > 0 ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
right: 8,
|
||||||
|
minWidth: 16,
|
||||||
|
height: 16,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#1d9bf0',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: '#000',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontSize: 9, fontWeight: '700' }}>
|
||||||
|
{it.badge > 99 ? '99+' : it.badge}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{it.soon && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -2,
|
||||||
|
right: 2,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingVertical: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 7, fontWeight: '700', letterSpacing: 0.3 }}>
|
||||||
|
SOON
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
client-app/components/SearchBar.tsx
Normal file
69
client-app/components/SearchBar.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* SearchBar — single-TextInput pill. Icon + input в одном ряду, без
|
||||||
|
* idle/focused двойного состояния (раньше был хак с невидимым
|
||||||
|
* TextInput поверх отцентрированного Text — ломал focus и выравнивание
|
||||||
|
* на Android).
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, TextInput, Pressable } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
export interface SearchBarProps {
|
||||||
|
value: string;
|
||||||
|
onChangeText: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
onSubmitEditing?: () => void;
|
||||||
|
onClear?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({
|
||||||
|
value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing, onClear,
|
||||||
|
}: SearchBarProps) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="search" size={16} color="#6a6a6a" />
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
onSubmitEditing={onSubmitEditing}
|
||||||
|
returnKeyType="search"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
padding: 0,
|
||||||
|
includeFontPadding: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
onChangeText('');
|
||||||
|
onClear?.();
|
||||||
|
}}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
<Ionicons name="close-circle" size={16} color="#6a6a6a" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
client-app/components/TabHeader.tsx
Normal file
59
client-app/components/TabHeader.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* TabHeader — общая шапка для всех tab-страниц (home/feed/notifications/wallet).
|
||||||
|
*
|
||||||
|
* Структура строго как в референсе Messages-экрана:
|
||||||
|
* [avatar 32 → /settings] [title] [right slot]
|
||||||
|
*
|
||||||
|
* Без нижнего разделителя (divider=false) — тот же уровень, что и фон экрана.
|
||||||
|
*
|
||||||
|
* Right-slot по умолчанию — шестерёнка → /settings. Но экраны могут передать
|
||||||
|
* свой (например, refresh в wallet). Левый avatar — всегда клик-навигация в
|
||||||
|
* settings, как в референсе.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Pressable } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
|
||||||
|
export interface TabHeaderProps {
|
||||||
|
title: string;
|
||||||
|
/** Right-slot. Если не передан — выставляется IconButton с settings-outline. */
|
||||||
|
right?: React.ReactNode;
|
||||||
|
/** Dot-color на profile-avatar'е (например, WS live/polling indicator). */
|
||||||
|
profileDotColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabHeader({ title, right, profileDotColor }: TabHeaderProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const username = useStore(s => s.username);
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Header
|
||||||
|
title={title}
|
||||||
|
divider={false}
|
||||||
|
left={
|
||||||
|
<Pressable onPress={() => router.push('/(app)/settings' as never)} hitSlop={8}>
|
||||||
|
<Avatar
|
||||||
|
name={username ?? '?'}
|
||||||
|
address={keyFile?.pub_key}
|
||||||
|
size={32}
|
||||||
|
dotColor={profileDotColor}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
right ?? (
|
||||||
|
<IconButton
|
||||||
|
icon="settings-outline"
|
||||||
|
size={36}
|
||||||
|
onPress={() => router.push('/(app)/settings' as never)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
client-app/components/chat/AttachmentMenu.tsx
Normal file
188
client-app/components/chat/AttachmentMenu.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* AttachmentMenu — bottom-sheet с вариантами прикрепления.
|
||||||
|
*
|
||||||
|
* Выводится при нажатии на `+` в composer'е. Опции:
|
||||||
|
* - 📷 Photo / video из галереи (expo-image-picker)
|
||||||
|
* - 📸 Take photo (камера)
|
||||||
|
* - 📎 File (expo-document-picker)
|
||||||
|
* - 🎙️ Voice message — stub (запись через expo-av потребует
|
||||||
|
* permissions runtime + recording UI; сейчас добавляет мок-
|
||||||
|
* голосовое с duration 4s)
|
||||||
|
*
|
||||||
|
* Всё визуально — тёмный overlay + sheet снизу. Закрытие по tap'у на
|
||||||
|
* overlay или на Cancel.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, Pressable, Alert, Modal } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
|
||||||
|
import type { Attachment } from '@/lib/types';
|
||||||
|
|
||||||
|
export interface AttachmentMenuProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Вызывается когда attachment готов для отправки. */
|
||||||
|
onPick: (att: Attachment) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentMenu({ visible, onClose, onPick }: AttachmentMenuProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const pickImageOrVideo = async () => {
|
||||||
|
try {
|
||||||
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!perm.granted) {
|
||||||
|
Alert.alert('Permission needed', 'Grant photos access to attach media.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||||
|
quality: 0.85,
|
||||||
|
allowsEditing: false,
|
||||||
|
});
|
||||||
|
if (result.canceled) return;
|
||||||
|
const asset = result.assets[0];
|
||||||
|
onPick({
|
||||||
|
kind: asset.type === 'video' ? 'video' : 'image',
|
||||||
|
uri: asset.uri,
|
||||||
|
mime: asset.mimeType,
|
||||||
|
width: asset.width,
|
||||||
|
height: asset.height,
|
||||||
|
duration: asset.duration ? Math.round(asset.duration / 1000) : undefined,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Pick failed', e?.message ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const takePhoto = async () => {
|
||||||
|
try {
|
||||||
|
const perm = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
if (!perm.granted) {
|
||||||
|
Alert.alert('Permission needed', 'Grant camera access to take a photo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImagePicker.launchCameraAsync({ quality: 0.85 });
|
||||||
|
if (result.canceled) return;
|
||||||
|
const asset = result.assets[0];
|
||||||
|
onPick({
|
||||||
|
kind: asset.type === 'video' ? 'video' : 'image',
|
||||||
|
uri: asset.uri,
|
||||||
|
mime: asset.mimeType,
|
||||||
|
width: asset.width,
|
||||||
|
height: asset.height,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Camera failed', e?.message ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickFile = async () => {
|
||||||
|
try {
|
||||||
|
const res = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: '*/*',
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
if (res.canceled) return;
|
||||||
|
const asset = res.assets[0];
|
||||||
|
onPick({
|
||||||
|
kind: 'file',
|
||||||
|
uri: asset.uri,
|
||||||
|
name: asset.name,
|
||||||
|
mime: asset.mimeType ?? undefined,
|
||||||
|
size: asset.size,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('File pick failed', e?.message ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Voice recorder больше не stub — см. inline-кнопку 🎤 в composer'е,
|
||||||
|
// которая разворачивает VoiceRecorder (expo-av Audio.Recording). Опция
|
||||||
|
// Voice в этом меню убрана, т.к. дублировала бы UX.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.55)' }}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {}}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 12) + 10,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
borderTopWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: 40, height: 4, borderRadius: 2,
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#ffffff', fontSize: 16, fontWeight: '700',
|
||||||
|
marginLeft: 8, marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Attach
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Row icon="images-outline" label="Photo / video" onPress={pickImageOrVideo} />
|
||||||
|
<Row icon="camera-outline" label="Take photo" onPress={takePhoto} />
|
||||||
|
<Row icon="document-outline" label="File" onPress={pickFile} />
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
icon, label, onPress,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: pressed ? '#1a1a1a' : 'transparent',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40, height: 40, borderRadius: 10,
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={20} color="#ffffff" />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '600' }}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
client-app/components/chat/AttachmentPreview.tsx
Normal file
178
client-app/components/chat/AttachmentPreview.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* AttachmentPreview — рендер `Message.attachment` внутри bubble'а.
|
||||||
|
*
|
||||||
|
* Четыре формы:
|
||||||
|
* - image → Image с object-fit cover, aspect-ratio из width/height
|
||||||
|
* - video → то же + play-overlay в центре, duration внизу-справа
|
||||||
|
* - voice → row [play-icon] [waveform stub] [duration]
|
||||||
|
* - file → row [file-icon] [name + size]
|
||||||
|
*
|
||||||
|
* Вложения размещаются ВНУТРИ того же bubble'а что и текст, чуть ниже
|
||||||
|
* footer'а нет и ширина bubble'а снимает maxWidth-ограничение ради
|
||||||
|
* изображений (отдельный media-first-bubble case).
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, Image } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
import type { Attachment } from '@/lib/types';
|
||||||
|
import { VoicePlayer } from '@/components/chat/VoicePlayer';
|
||||||
|
import { VideoCirclePlayer } from '@/components/chat/VideoCirclePlayer';
|
||||||
|
|
||||||
|
export interface AttachmentPreviewProps {
|
||||||
|
attachment: Attachment;
|
||||||
|
/** Используется для тонирования footer-элементов. */
|
||||||
|
own?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentPreview({ attachment, own }: AttachmentPreviewProps) {
|
||||||
|
switch (attachment.kind) {
|
||||||
|
case 'image':
|
||||||
|
return <ImageAttachment att={attachment} />;
|
||||||
|
case 'video':
|
||||||
|
// circle=true — круглое видео-сообщение (Telegram-стиль).
|
||||||
|
return attachment.circle
|
||||||
|
? <VideoCirclePlayer uri={attachment.uri} duration={attachment.duration} />
|
||||||
|
: <VideoAttachment att={attachment} />;
|
||||||
|
case 'voice':
|
||||||
|
return <VoicePlayer uri={attachment.uri} duration={attachment.duration} own={own} />;
|
||||||
|
case 'file':
|
||||||
|
return <FileAttachment att={attachment} own={own} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Image ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ImageAttachment({ att }: { att: Attachment }) {
|
||||||
|
// Aspect-ratio из реальных width/height; fallback 4:3.
|
||||||
|
const aspect = att.width && att.height ? att.width / att.height : 4 / 3;
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri: att.uri }}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: aspect,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
}}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Video ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function VideoAttachment({ att }: { att: Attachment }) {
|
||||||
|
const aspect = att.width && att.height ? att.width / att.height : 16 / 9;
|
||||||
|
return (
|
||||||
|
<View style={{ position: 'relative', marginBottom: 4 }}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: att.uri }}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: aspect,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
}}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
{/* Play overlay по центру */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%', left: '50%',
|
||||||
|
transform: [{ translateX: -22 }, { translateY: -22 }],
|
||||||
|
width: 44, height: 44, borderRadius: 22,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="play" size={22} color="#ffffff" style={{ marginLeft: 2 }} />
|
||||||
|
</View>
|
||||||
|
{att.duration !== undefined && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8, bottom: 8,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
paddingHorizontal: 6, paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '600' }}>
|
||||||
|
{formatDuration(att.duration)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Voice ──────────────────────────────────────────────────────────
|
||||||
|
// Реальный плеер — см. components/chat/VoicePlayer.tsx (expo-av Sound).
|
||||||
|
|
||||||
|
// ─── File ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FileAttachment({ att, own }: { att: Attachment; own?: boolean }) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 4,
|
||||||
|
gap: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36, height: 36, borderRadius: 10,
|
||||||
|
backgroundColor: own ? 'rgba(255,255,255,0.18)' : '#1a1a1a',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="document-text"
|
||||||
|
size={18}
|
||||||
|
color={own ? '#ffffff' : '#ffffff'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{att.name ?? 'file'}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: own ? 'rgba(255,255,255,0.75)' : '#8b8b8b',
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{att.size !== undefined ? formatSize(att.size) : ''}
|
||||||
|
{att.size !== undefined && att.mime ? ' · ' : ''}
|
||||||
|
{att.mime ?? ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
client-app/components/chat/DaySeparator.tsx
Normal file
36
client-app/components/chat/DaySeparator.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* DaySeparator — центральный лейбл "Сегодня" / "Вчера" / "17 июня 2025"
|
||||||
|
* между группами сообщений.
|
||||||
|
*
|
||||||
|
* Стиль: тонкий шрифт серого цвета, маленький размер. В референсе этот
|
||||||
|
* лейбл не должен перетягивать на себя внимание — он визуальный якорь,
|
||||||
|
* не заголовок.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, Platform } from 'react-native';
|
||||||
|
|
||||||
|
export interface DaySeparatorProps {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DaySeparator({ label }: DaySeparatorProps) {
|
||||||
|
return (
|
||||||
|
<View style={{ alignItems: 'center', marginTop: 14, marginBottom: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#6b6b6b',
|
||||||
|
fontSize: 12,
|
||||||
|
// Тонкий шрифт — на iOS "200" рисует ultra-light, на Android —
|
||||||
|
// sans-serif-thin. В Expo font-weight 300 почти идентичен на
|
||||||
|
// обеих платформах и доступен без дополнительных шрифтов.
|
||||||
|
fontWeight: '300',
|
||||||
|
// Android font-weight 100-300 требует явной семьи, иначе
|
||||||
|
// округляется до 400. Для thin визуала передаём serif-thin.
|
||||||
|
...(Platform.OS === 'android' ? { fontFamily: 'sans-serif-thin' } : null),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
385
client-app/components/chat/MessageBubble.tsx
Normal file
385
client-app/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* MessageBubble — рендер одного сообщения с gesture interactions.
|
||||||
|
*
|
||||||
|
* Гестуры — разведены по двум примитивам во избежание конфликта со
|
||||||
|
* скроллом FlatList'а:
|
||||||
|
*
|
||||||
|
* 1. Swipe-left (reply): PanResponder на Animated.View обёртке
|
||||||
|
* bubble'а. `onMoveShouldSetPanResponder` клеймит responder ТОЛЬКО
|
||||||
|
* когда пользователь сдвинул палец > 6px влево и горизонталь
|
||||||
|
* преобладает над вертикалью. Для вертикального скролла
|
||||||
|
* `onMoveShouldSet` возвращает false — FlatList получает gesture.
|
||||||
|
* Touchdown ничего не клеймит (onStartShouldSetPanResponder
|
||||||
|
* отсутствует).
|
||||||
|
*
|
||||||
|
* 2. Long-press / tap: через View.onTouchStart/End. Primitive touch
|
||||||
|
* events bubble'ятся независимо от responder'а. Long-press запускаем
|
||||||
|
* timer'ом на 550ms, cancel при `onTouchMove` с достаточной
|
||||||
|
* амплитудой. Tap — короткое касание без move в selection mode.
|
||||||
|
*
|
||||||
|
* 3. `selectionMode=true` — PanResponder disabled (в selection режиме
|
||||||
|
* свайпы не работают).
|
||||||
|
*
|
||||||
|
* 4. ReplyQuote — отдельный Pressable над bubble-текстом; tap прыгает
|
||||||
|
* к оригиналу через onJumpToReply.
|
||||||
|
*
|
||||||
|
* 5. highlight prop — bubble-row мерцает accent-blue фоном, использует
|
||||||
|
* Animated.Value; управляется из ChatScreen после scrollToIndex.
|
||||||
|
*/
|
||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, Pressable, ViewStyle, Animated, PanResponder,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
import type { Message } from '@/lib/types';
|
||||||
|
import { relTime } from '@/lib/dates';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { AttachmentPreview } from '@/components/chat/AttachmentPreview';
|
||||||
|
import { ReplyQuote } from '@/components/chat/ReplyQuote';
|
||||||
|
import { PostRefCard } from '@/components/chat/PostRefCard';
|
||||||
|
|
||||||
|
export const PEER_AVATAR_SLOT = 34;
|
||||||
|
const SWIPE_THRESHOLD = 60;
|
||||||
|
const LONG_PRESS_MS = 550;
|
||||||
|
const TAP_MAX_MOVEMENT = 8;
|
||||||
|
const TAP_MAX_ELAPSED = 300;
|
||||||
|
|
||||||
|
export interface MessageBubbleProps {
|
||||||
|
msg: Message;
|
||||||
|
peerName: string;
|
||||||
|
peerAddress?: string;
|
||||||
|
withSenderMeta?: boolean;
|
||||||
|
showName: boolean;
|
||||||
|
showAvatar: boolean;
|
||||||
|
|
||||||
|
onReply?: (m: Message) => void;
|
||||||
|
onLongPress?: (m: Message) => void;
|
||||||
|
onTap?: (m: Message) => void;
|
||||||
|
onOpenProfile?: () => void;
|
||||||
|
onJumpToReply?: (originalId: string) => void;
|
||||||
|
|
||||||
|
selectionMode?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
/** Mgnt-управляемый highlight: row мерцает accent-фоном ~1-2 секунды. */
|
||||||
|
highlighted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bubble styles ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const bubbleBase: ViewStyle = {
|
||||||
|
borderRadius: 18,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const peerBubble: ViewStyle = {
|
||||||
|
...bubbleBase,
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
borderBottomLeftRadius: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownBubble: ViewStyle = {
|
||||||
|
...bubbleBase,
|
||||||
|
backgroundColor: '#1d9bf0',
|
||||||
|
borderBottomRightRadius: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bubbleText = { color: '#ffffff', fontSize: 15, lineHeight: 20 } as const;
|
||||||
|
|
||||||
|
// ─── Main ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function MessageBubble(props: MessageBubbleProps) {
|
||||||
|
if (props.msg.mine) return <RowShell {...props} variant="own" />;
|
||||||
|
if (!props.withSenderMeta) return <RowShell {...props} variant="peer-compact" />;
|
||||||
|
return <RowShell {...props} variant="group-peer" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Variant = 'own' | 'peer-compact' | 'group-peer';
|
||||||
|
|
||||||
|
function RowShell({
|
||||||
|
msg, peerName, peerAddress, showName, showAvatar,
|
||||||
|
onReply, onLongPress, onTap, onOpenProfile, onJumpToReply,
|
||||||
|
selectionMode, selected, highlighted, variant,
|
||||||
|
}: MessageBubbleProps & { variant: Variant }) {
|
||||||
|
const translateX = useRef(new Animated.Value(0)).current;
|
||||||
|
const startTs = useRef(0);
|
||||||
|
const moved = useRef(false);
|
||||||
|
const lpTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearLp = () => {
|
||||||
|
if (lpTimer.current) { clearTimeout(lpTimer.current); lpTimer.current = null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch start — запускаем long-press timer (НЕ клеймим responder).
|
||||||
|
const onTouchStart = () => {
|
||||||
|
startTs.current = Date.now();
|
||||||
|
moved.current = false;
|
||||||
|
clearLp();
|
||||||
|
if (onLongPress) {
|
||||||
|
lpTimer.current = setTimeout(() => {
|
||||||
|
if (!moved.current) onLongPress(msg);
|
||||||
|
lpTimer.current = null;
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchMove = (e: { nativeEvent: { pageX: number; pageY: number } }) => {
|
||||||
|
// Если пользователь двигает палец — отменяем long-press timer.
|
||||||
|
// Малые движения (< TAP_MAX_MOVEMENT) игнорируем — устраняют
|
||||||
|
// fale-cancel от дрожания пальца.
|
||||||
|
// Здесь нет точного dx/dy от gesture-системы, используем primitive
|
||||||
|
// touch coords отсчитываемые по абсолютным координатам. Проще —
|
||||||
|
// всегда отменяем на first move (PanResponder ниже отнимет
|
||||||
|
// responder если leftward).
|
||||||
|
moved.current = true;
|
||||||
|
clearLp();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
const elapsed = Date.now() - startTs.current;
|
||||||
|
clearLp();
|
||||||
|
// Короткий tap без движения → в selection mode toggle.
|
||||||
|
if (!moved.current && elapsed < TAP_MAX_ELAPSED && selectionMode) {
|
||||||
|
onTap?.(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Swipe-to-reply: PanResponder клеймит ТОЛЬКО leftward-dominant move.
|
||||||
|
// Для vertical scroll / rightward swipe / start-touch возвращает false,
|
||||||
|
// FlatList / AnimatedSlot получают gesture.
|
||||||
|
const panResponder = useRef(
|
||||||
|
PanResponder.create({
|
||||||
|
onMoveShouldSetPanResponder: (_e, g) => {
|
||||||
|
if (selectionMode) return false;
|
||||||
|
// Leftward > 6px и горизонталь преобладает.
|
||||||
|
return g.dx < -6 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5;
|
||||||
|
},
|
||||||
|
onPanResponderGrant: () => {
|
||||||
|
// Как только мы заклеймили gesture, отменяем long-press
|
||||||
|
// (пользователь явно свайпает, не удерживает).
|
||||||
|
clearLp();
|
||||||
|
moved.current = true;
|
||||||
|
},
|
||||||
|
onPanResponderMove: (_e, g) => {
|
||||||
|
translateX.setValue(Math.min(0, g.dx));
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_e, g) => {
|
||||||
|
if (g.dx <= -SWIPE_THRESHOLD) onReply?.(msg);
|
||||||
|
Animated.spring(translateX, {
|
||||||
|
toValue: 0, friction: 6, tension: 80, useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
},
|
||||||
|
onPanResponderTerminate: () => {
|
||||||
|
Animated.spring(translateX, {
|
||||||
|
toValue: 0, friction: 6, tension: 80, useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).current;
|
||||||
|
|
||||||
|
// Highlight fade: при переключении highlighted=true крутим короткую
|
||||||
|
// анимацию "flash + fade out" через Animated.Value (0→1→0 за ~1.8s).
|
||||||
|
const highlightAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlighted) return;
|
||||||
|
highlightAnim.setValue(0);
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(highlightAnim, { toValue: 1, duration: 150, useNativeDriver: false }),
|
||||||
|
Animated.delay(1400),
|
||||||
|
Animated.timing(highlightAnim, { toValue: 0, duration: 450, useNativeDriver: false }),
|
||||||
|
]).start();
|
||||||
|
}, [highlighted, highlightAnim]);
|
||||||
|
|
||||||
|
const highlightBg = highlightAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['rgba(29,155,240,0)', 'rgba(29,155,240,0.22)'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMine = variant === 'own';
|
||||||
|
const hasAttachment = !!msg.attachment;
|
||||||
|
const hasPostRef = !!msg.postRef;
|
||||||
|
const hasReply = !!msg.replyTo;
|
||||||
|
const attachmentOnly = hasAttachment && !msg.text.trim();
|
||||||
|
const bubbleStyle = attachmentOnly
|
||||||
|
? { ...(isMine ? ownBubble : peerBubble), padding: 4 }
|
||||||
|
: (isMine ? ownBubble : peerBubble);
|
||||||
|
|
||||||
|
const bubbleNode = (
|
||||||
|
<Animated.View
|
||||||
|
{...panResponder.panHandlers}
|
||||||
|
style={{
|
||||||
|
transform: [{ translateX }],
|
||||||
|
maxWidth: hasAttachment ? '80%' : '85%',
|
||||||
|
minWidth: hasAttachment || hasReply ? 220 : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={bubbleStyle}>
|
||||||
|
{msg.replyTo && (
|
||||||
|
<ReplyQuote
|
||||||
|
author={msg.replyTo.author}
|
||||||
|
preview={msg.replyTo.text}
|
||||||
|
own={isMine}
|
||||||
|
onJump={() => onJumpToReply?.(msg.replyTo!.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{msg.attachment && (
|
||||||
|
<AttachmentPreview attachment={msg.attachment} own={isMine} />
|
||||||
|
)}
|
||||||
|
{msg.postRef && (
|
||||||
|
<PostRefCard
|
||||||
|
postID={msg.postRef.postID}
|
||||||
|
author={msg.postRef.author}
|
||||||
|
excerpt={msg.postRef.excerpt}
|
||||||
|
hasImage={!!msg.postRef.hasImage}
|
||||||
|
own={isMine}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{msg.text.trim() ? (
|
||||||
|
<Text style={bubbleText}>{msg.text}</Text>
|
||||||
|
) : null}
|
||||||
|
<BubbleFooter
|
||||||
|
edited={!!msg.edited}
|
||||||
|
time={relTime(msg.timestamp)}
|
||||||
|
own={isMine}
|
||||||
|
read={!!msg.read}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentRow =
|
||||||
|
variant === 'own' ? (
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'flex-end' }}>
|
||||||
|
{bubbleNode}
|
||||||
|
</View>
|
||||||
|
) : variant === 'peer-compact' ? (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
|
||||||
|
{bubbleNode}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View>
|
||||||
|
{showName && (
|
||||||
|
<Pressable
|
||||||
|
onPress={onOpenProfile}
|
||||||
|
hitSlop={4}
|
||||||
|
style={{ marginLeft: PEER_AVATAR_SLOT, marginBottom: 3 }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12 }} numberOfLines={1}>
|
||||||
|
{peerName}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
|
||||||
|
<View style={{ width: PEER_AVATAR_SLOT, alignItems: 'flex-start' }}>
|
||||||
|
{showAvatar ? (
|
||||||
|
<Pressable onPress={onOpenProfile} hitSlop={4}>
|
||||||
|
<Avatar name={peerName} address={peerAddress} size={26} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{bubbleNode}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
onTouchCancel={() => { clearLp(); moved.current = true; }}
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
marginBottom: 6,
|
||||||
|
// Selection & highlight накладываются: highlight flash побеждает
|
||||||
|
// когда анимация > 0, иначе статичный selection-tint.
|
||||||
|
backgroundColor: selected ? 'rgba(29,155,240,0.12)' : highlightBg,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contentRow}
|
||||||
|
{selectionMode && (
|
||||||
|
<CheckDot
|
||||||
|
selected={!!selected}
|
||||||
|
onPress={() => onTap?.(msg)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Clickable check-dot ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CheckDot({ selected, onPress }: { selected: boolean; onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
hitSlop={12}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 4,
|
||||||
|
top: 0, bottom: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: selected ? '#1d9bf0' : 'rgba(0,0,0,0.55)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: selected ? '#1d9bf0' : '#6b6b6b',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected && <Ionicons name="checkmark" size={12} color="#ffffff" />}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Footer ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
edited: boolean;
|
||||||
|
time: string;
|
||||||
|
own?: boolean;
|
||||||
|
read?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BubbleFooter({ edited, time, own, read }: FooterProps) {
|
||||||
|
const textColor = own ? 'rgba(255,255,255,0.78)' : '#8b8b8b';
|
||||||
|
const dotColor = own ? 'rgba(255,255,255,0.55)' : '#5a5a5a';
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: 2,
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{edited && (
|
||||||
|
<>
|
||||||
|
<Text style={{ color: textColor, fontSize: 11 }}>Edited</Text>
|
||||||
|
<Text style={{ color: dotColor, fontSize: 11 }}>·</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Text style={{ color: textColor, fontSize: 11 }}>{time}</Text>
|
||||||
|
{own && (
|
||||||
|
<Ionicons
|
||||||
|
name={read ? 'checkmark-circle' : 'checkmark-circle-outline'}
|
||||||
|
size={13}
|
||||||
|
color={read ? '#ffffff' : 'rgba(255,255,255,0.78)'}
|
||||||
|
style={{ marginLeft: 2 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
client-app/components/chat/PostRefCard.tsx
Normal file
143
client-app/components/chat/PostRefCard.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* PostRefCard — renders a shared feed post inside a chat bubble.
|
||||||
|
*
|
||||||
|
* Visually distinct from plain messages so the user sees at-a-glance
|
||||||
|
* that this came from the feed, not a direct-typed text. Matches
|
||||||
|
* VK's "shared wall post" embed pattern:
|
||||||
|
*
|
||||||
|
* [newspaper icon] ПОСТ
|
||||||
|
* @author · 2 строки excerpt'а
|
||||||
|
* [📷 Фото in this post]
|
||||||
|
*
|
||||||
|
* Tap → /(app)/feed/{postID}. The full post (with image + stats +
|
||||||
|
* like button) is displayed in the standard post-detail screen.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, Pressable } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
|
||||||
|
export interface PostRefCardProps {
|
||||||
|
postID: string;
|
||||||
|
author: string;
|
||||||
|
excerpt: string;
|
||||||
|
hasImage?: boolean;
|
||||||
|
/** True when the card appears inside the sender's own bubble (our own
|
||||||
|
* share). Adjusts colour contrast so it reads on the blue bubble
|
||||||
|
* background. */
|
||||||
|
own: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefCardProps) {
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
|
||||||
|
// Resolve author name the same way the feed does.
|
||||||
|
const contact = contacts.find(c => c.address === author);
|
||||||
|
const displayName = contact?.username
|
||||||
|
? `@${contact.username}`
|
||||||
|
: contact?.alias ?? shortAddr(author);
|
||||||
|
|
||||||
|
const onOpen = () => {
|
||||||
|
router.push(`/(app)/feed/${postID}` as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tinted palette based on bubble side — inside an "own" (blue) bubble
|
||||||
|
// the card uses a deeper blue so it reads as a distinct nested block,
|
||||||
|
// otherwise we use the standard card colours.
|
||||||
|
const bg = own ? 'rgba(0, 0, 0, 0.22)' : '#0a0a0a';
|
||||||
|
const border = own ? 'rgba(255, 255, 255, 0.15)' : '#1f1f1f';
|
||||||
|
const labelColor = own ? 'rgba(255, 255, 255, 0.75)' : '#1d9bf0';
|
||||||
|
const bodyColor = own ? '#ffffff' : '#ffffff';
|
||||||
|
const subColor = own ? 'rgba(255, 255, 255, 0.65)' : '#8b8b8b';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onOpen}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
marginBottom: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: pressed ? 'rgba(0,0,0,0.35)' : bg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: border,
|
||||||
|
overflow: 'hidden',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Top ribbon: "ПОСТ" label — makes the shared nature unmistakable. */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="newspaper-outline" size={11} color={labelColor} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: labelColor,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
POST
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Author + excerpt */}
|
||||||
|
<View style={{ flexDirection: 'row', paddingHorizontal: 10, paddingBottom: 10 }}>
|
||||||
|
<Avatar name={displayName} address={author} size={28} />
|
||||||
|
<View style={{ flex: 1, marginLeft: 8, minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
color: bodyColor,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
{excerpt.length > 0 && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={3}
|
||||||
|
style={{
|
||||||
|
color: subColor,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{excerpt}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{hasImage && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="image-outline" size={11} color={subColor} />
|
||||||
|
<Text style={{ color: subColor, fontSize: 11 }}>
|
||||||
|
photo
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
client-app/components/chat/ReplyQuote.tsx
Normal file
70
client-app/components/chat/ReplyQuote.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* ReplyQuote — блок "цитаты" внутри bubble'а сообщения-ответа.
|
||||||
|
*
|
||||||
|
* Визуал: slim-row с синим бордером слева (accent-bar), author в синем,
|
||||||
|
* preview text — серым, в одну строку.
|
||||||
|
*
|
||||||
|
* Tap на quoted-блок → onJump → ChatScreen скроллит к оригиналу и
|
||||||
|
* подсвечивает его на пару секунд. Если оригинал не найден в текущем
|
||||||
|
* списке (удалён / ушёл за пределы пагинации) — onJump может просто
|
||||||
|
* no-op'нуть.
|
||||||
|
*
|
||||||
|
* Цвета зависят от того в чьём bubble'е мы находимся:
|
||||||
|
* - own (синий bubble) → quote border = белый, текст белый/85%
|
||||||
|
* - peer (серый bubble) → quote border = accent blue, текст white
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, Pressable } from 'react-native';
|
||||||
|
|
||||||
|
export interface ReplyQuoteProps {
|
||||||
|
author: string;
|
||||||
|
preview: string;
|
||||||
|
own?: boolean;
|
||||||
|
onJump?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReplyQuote({ author, preview, own, onJump }: ReplyQuoteProps) {
|
||||||
|
const barColor = own ? 'rgba(255,255,255,0.85)' : '#1d9bf0';
|
||||||
|
const authorColor = own ? '#ffffff' : '#1d9bf0';
|
||||||
|
const previewColor = own ? 'rgba(255,255,255,0.85)' : '#c0c0c0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onJump}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: own ? 'rgba(255,255,255,0.10)' : 'rgba(29,155,240,0.10)',
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: 5,
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Accent bar слева */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 3,
|
||||||
|
backgroundColor: barColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1, paddingHorizontal: 8, paddingVertical: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: authorColor,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{author}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{ color: previewColor, fontSize: 13 }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{preview || 'attachment'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
client-app/components/chat/VideoCirclePlayer.tsx
Normal file
158
client-app/components/chat/VideoCirclePlayer.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* VideoCirclePlayer — telegram-style круглое видео-сообщение.
|
||||||
|
*
|
||||||
|
* Мигрировано с expo-av `<Video>` на expo-video `<VideoView>` +
|
||||||
|
* useVideoPlayer hook (expo-av deprecated в SDK 54).
|
||||||
|
*
|
||||||
|
* UI:
|
||||||
|
* - Круглая thumbnail-рамка (Image превью первого кадра) с play-overlay
|
||||||
|
* - Tap → полноэкранный Modal с VideoView в круглой рамке, auto-play + loop
|
||||||
|
* - Duration badge снизу
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, Pressable, Modal, Image } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useVideoPlayer, VideoView } from 'expo-video';
|
||||||
|
|
||||||
|
export interface VideoCirclePlayerProps {
|
||||||
|
uri: string;
|
||||||
|
duration?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoCirclePlayer({ uri, duration, size = 220 }: VideoCirclePlayerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
style={{
|
||||||
|
width: size, height: size, borderRadius: size / 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
marginBottom: 4,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Статический thumbnail через Image (первый кадр если платформа
|
||||||
|
поддерживает, иначе чёрный фон). Реальное видео играет только
|
||||||
|
в Modal ради производительности FlatList'а. */}
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: 52, height: 52, borderRadius: 26,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="play" size={22} color="#ffffff" style={{ marginLeft: 2 }} />
|
||||||
|
</View>
|
||||||
|
{duration !== undefined && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: size / 2 - 26, bottom: 16,
|
||||||
|
paddingHorizontal: 6, paddingVertical: 2,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '600' }}>
|
||||||
|
{formatClock(duration)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<VideoModal uri={uri} onClose={() => setOpen(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal рендерится только когда open=true — это значит useVideoPlayer
|
||||||
|
// не создаёт лишних плееров пока пользователь не открыл overlay.
|
||||||
|
|
||||||
|
function VideoModal({ uri, onClose }: { uri: string; onClose: () => void }) {
|
||||||
|
// useVideoPlayer может throw'нуть на некоторых платформах при
|
||||||
|
// невалидных source'ах. try/catch вокруг render'а защищает парента
|
||||||
|
// от полного crash'а.
|
||||||
|
let player: ReturnType<typeof useVideoPlayer> | null = null;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
player = useVideoPlayer({ uri }, (p) => {
|
||||||
|
p.loop = true;
|
||||||
|
p.muted = false;
|
||||||
|
p.play();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
player = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible transparent animationType="fade" onRequestClose={onClose}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.92)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: '90%',
|
||||||
|
aspectRatio: 1,
|
||||||
|
maxWidth: 420, maxHeight: 420,
|
||||||
|
borderRadius: 9999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{player ? (
|
||||||
|
<VideoView
|
||||||
|
player={player}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
contentFit="cover"
|
||||||
|
nativeControls={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Ionicons name="alert-circle-outline" size={36} color="#8b8b8b" />
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 8 }}>
|
||||||
|
Playback not available
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 48, right: 16,
|
||||||
|
width: 40, height: 40, borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.14)',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={22} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
client-app/components/chat/VideoCircleRecorder.tsx
Normal file
217
client-app/components/chat/VideoCircleRecorder.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* VideoCircleRecorder — full-screen Modal для записи круглого видео-
|
||||||
|
* сообщения (Telegram-style).
|
||||||
|
*
|
||||||
|
* UX:
|
||||||
|
* 1. Открывается Modal с CameraView (по умолчанию front-camera).
|
||||||
|
* 2. Превью — круглое (аналогично VideoCirclePlayer).
|
||||||
|
* 3. Большая красная кнопка внизу: tap-to-start, tap-to-stop.
|
||||||
|
* 4. Максимум 15 секунд — авто-стоп.
|
||||||
|
* 5. По stop'у возвращаем attachment { kind:'video', circle:true, uri, duration }.
|
||||||
|
* 6. Свайп вниз / close-icon → cancel (без отправки).
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { View, Text, Pressable, Modal, Alert } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { CameraView, useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
||||||
|
|
||||||
|
import type { Attachment } from '@/lib/types';
|
||||||
|
|
||||||
|
export interface VideoCircleRecorderProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onFinish: (att: Attachment) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DURATION_SEC = 15;
|
||||||
|
|
||||||
|
function formatClock(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoCircleRecorder({ visible, onClose, onFinish }: VideoCircleRecorderProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const camRef = useRef<CameraView>(null);
|
||||||
|
|
||||||
|
const [camPerm, requestCam] = useCameraPermissions();
|
||||||
|
const [micPerm, requestMic] = useMicrophonePermissions();
|
||||||
|
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const startedAt = useRef(0);
|
||||||
|
const facing: 'front' | 'back' = 'front';
|
||||||
|
|
||||||
|
// Timer + auto-stop at MAX_DURATION_SEC
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recording) return;
|
||||||
|
const t = setInterval(() => {
|
||||||
|
const s = Math.floor((Date.now() - startedAt.current) / 1000);
|
||||||
|
setElapsed(s);
|
||||||
|
if (s >= MAX_DURATION_SEC) stopAndSend();
|
||||||
|
}, 250);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [recording]);
|
||||||
|
|
||||||
|
// Permissions on mount of visible
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setRecording(false);
|
||||||
|
setElapsed(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
if (!camPerm?.granted) await requestCam();
|
||||||
|
if (!micPerm?.granted) await requestMic();
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
if (!camRef.current || recording) return;
|
||||||
|
try {
|
||||||
|
startedAt.current = Date.now();
|
||||||
|
setElapsed(0);
|
||||||
|
setRecording(true);
|
||||||
|
// recordAsync блокируется до stopRecording или maxDuration
|
||||||
|
const result = await camRef.current.recordAsync({ maxDuration: MAX_DURATION_SEC });
|
||||||
|
setRecording(false);
|
||||||
|
if (!result?.uri) return;
|
||||||
|
const seconds = Math.max(1, Math.floor((Date.now() - startedAt.current) / 1000));
|
||||||
|
onFinish({
|
||||||
|
kind: 'video',
|
||||||
|
circle: true,
|
||||||
|
uri: result.uri,
|
||||||
|
duration: seconds,
|
||||||
|
mime: 'video/mp4',
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
setRecording(false);
|
||||||
|
Alert.alert('Recording failed', e?.message ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAndSend = () => {
|
||||||
|
if (!recording) return;
|
||||||
|
camRef.current?.stopRecording();
|
||||||
|
// recordAsync promise выше resolve'нется с uri → onFinish
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (recording) {
|
||||||
|
camRef.current?.stopRecording();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const permOK = camPerm?.granted && micPerm?.granted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={cancel}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
paddingTop: insets.top,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 12),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', padding: 12 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={cancel}
|
||||||
|
hitSlop={10}
|
||||||
|
style={{
|
||||||
|
width: 36, height: 36, borderRadius: 18,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={20} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', flex: 1, textAlign: 'center' }}>
|
||||||
|
Video message
|
||||||
|
</Text>
|
||||||
|
<View style={{ width: 36 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Camera */}
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||||
|
{permOK ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: '85%',
|
||||||
|
aspectRatio: 1,
|
||||||
|
maxWidth: 360, maxHeight: 360,
|
||||||
|
borderRadius: 9999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: recording ? 3 : 0,
|
||||||
|
borderColor: '#f4212e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CameraView
|
||||||
|
ref={camRef}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
facing={facing}
|
||||||
|
mode="video"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{ alignItems: 'center', paddingHorizontal: 24 }}>
|
||||||
|
<Ionicons name="videocam-off-outline" size={42} color="#8b8b8b" />
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 12 }}>
|
||||||
|
Permissions needed
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 4, textAlign: 'center' }}>
|
||||||
|
Camera + microphone access are required to record a video message.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timer */}
|
||||||
|
{recording && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#f4212e',
|
||||||
|
fontSize: 14, fontWeight: '700',
|
||||||
|
marginTop: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
● {formatClock(elapsed)} / {formatClock(MAX_DURATION_SEC)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Record / Stop button */}
|
||||||
|
<View style={{ alignItems: 'center', paddingBottom: 16 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={recording ? stopAndSend : start}
|
||||||
|
disabled={!permOK}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 72, height: 72, borderRadius: 36,
|
||||||
|
backgroundColor: !permOK ? '#1a1a1a' : recording ? '#f4212e' : '#1d9bf0',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
opacity: pressed ? 0.85 : 1,
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: 'rgba(255,255,255,0.2)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={recording ? 'stop' : 'videocam'}
|
||||||
|
size={30}
|
||||||
|
color="#ffffff"
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 10 }}>
|
||||||
|
{recording ? 'Tap to stop & send' : permOK ? 'Tap to record' : 'Grant permissions'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
client-app/components/chat/VoicePlayer.tsx
Normal file
166
client-app/components/chat/VoicePlayer.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* VoicePlayer — play/pause voice message через expo-audio.
|
||||||
|
*
|
||||||
|
* Раздел на две подкомпоненты:
|
||||||
|
* - RealVoicePlayer: useAudioPlayer с настоящим URI
|
||||||
|
* - StubVoicePlayer: отрисовка waveform без player'а (seed-URI)
|
||||||
|
*
|
||||||
|
* Разделение важно: useAudioPlayer не должен получать null/stub-строки —
|
||||||
|
* при падении внутри expo-audio это крашит render всего bubble'а и
|
||||||
|
* (в FlatList) визуально "пропадает" интерфейс чата.
|
||||||
|
*
|
||||||
|
* UI:
|
||||||
|
* [▶/⏸] ▮▮▮▮▮▮▮▮▮▯▯▯▯▯▯▯▯ 0:03 / 0:17
|
||||||
|
*/
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { View, Text, Pressable } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
||||||
|
|
||||||
|
export interface VoicePlayerProps {
|
||||||
|
uri: string;
|
||||||
|
duration?: number;
|
||||||
|
own?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAR_COUNT = 22;
|
||||||
|
|
||||||
|
function formatClock(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStubUri(u: string): boolean {
|
||||||
|
return u.startsWith('voice-stub://') || u.startsWith('voice-demo://');
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBars(uri: string) {
|
||||||
|
return useMemo(() => {
|
||||||
|
const seed = uri.length;
|
||||||
|
return Array.from({ length: BAR_COUNT }, (_, i) => {
|
||||||
|
const h = ((seed * (i + 1) * 7919) % 11) + 4;
|
||||||
|
return h;
|
||||||
|
});
|
||||||
|
}, [uri]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Top-level router ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function VoicePlayer(props: VoicePlayerProps) {
|
||||||
|
// Stub-URI (seed) не передаётся в useAudioPlayer — hook может крашить
|
||||||
|
// на невалидных source'ах. Рендерим статический waveform.
|
||||||
|
if (isStubUri(props.uri)) return <StubVoicePlayer {...props} />;
|
||||||
|
return <RealVoicePlayer {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stub (seed / preview) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function StubVoicePlayer({ uri, duration, own }: VoicePlayerProps) {
|
||||||
|
const bars = useBars(uri);
|
||||||
|
const accent = own ? 'rgba(255,255,255,0.92)' : '#1d9bf0';
|
||||||
|
const subtle = own ? 'rgba(255,255,255,0.35)' : '#3a3a3a';
|
||||||
|
const textColor = own ? 'rgba(255,255,255,0.85)' : '#8b8b8b';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, borderRadius: 16,
|
||||||
|
backgroundColor: own ? 'rgba(255,255,255,0.18)' : '#1a1a1a',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="play" size={14} color="#ffffff" style={{ marginLeft: 1 }} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2, flex: 1 }}>
|
||||||
|
{bars.map((h, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 2, height: h, borderRadius: 1,
|
||||||
|
backgroundColor: i < 6 ? accent : subtle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: textColor, fontSize: 12 }}>
|
||||||
|
{formatClock(duration ?? 0)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Real expo-audio player ────────────────────────────────────────
|
||||||
|
|
||||||
|
function RealVoicePlayer({ uri, duration, own }: VoicePlayerProps) {
|
||||||
|
const player = useAudioPlayer({ uri });
|
||||||
|
const status = useAudioPlayerStatus(player);
|
||||||
|
const bars = useBars(uri);
|
||||||
|
|
||||||
|
const accent = own ? 'rgba(255,255,255,0.92)' : '#1d9bf0';
|
||||||
|
const subtle = own ? 'rgba(255,255,255,0.35)' : '#3a3a3a';
|
||||||
|
const textColor = own ? 'rgba(255,255,255,0.85)' : '#8b8b8b';
|
||||||
|
|
||||||
|
const playing = !!status.playing;
|
||||||
|
const loading = !!status.isBuffering && !status.isLoaded;
|
||||||
|
const curSec = status.currentTime ?? 0;
|
||||||
|
const totalSec = (status.duration && status.duration > 0) ? status.duration : (duration ?? 0);
|
||||||
|
|
||||||
|
const playedRatio = totalSec > 0 ? Math.min(1, curSec / totalSec) : 0;
|
||||||
|
const playedBars = Math.round(playedRatio * BAR_COUNT);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
try {
|
||||||
|
if (status.playing) {
|
||||||
|
player.pause();
|
||||||
|
} else {
|
||||||
|
if (status.duration && curSec >= status.duration - 0.05) {
|
||||||
|
player.seekTo(0);
|
||||||
|
}
|
||||||
|
player.play();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* dbl-tap during load */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={toggle}
|
||||||
|
hitSlop={8}
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, borderRadius: 16,
|
||||||
|
backgroundColor: own ? 'rgba(255,255,255,0.18)' : '#1a1a1a',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={playing ? 'pause' : (loading ? 'hourglass-outline' : 'play')}
|
||||||
|
size={14}
|
||||||
|
color="#ffffff"
|
||||||
|
style={{ marginLeft: playing || loading ? 0 : 1 }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2, flex: 1 }}>
|
||||||
|
{bars.map((h, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 2, height: h, borderRadius: 1,
|
||||||
|
backgroundColor: i < playedBars ? accent : subtle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={{ color: textColor, fontSize: 12 }}>
|
||||||
|
{playing || curSec > 0
|
||||||
|
? `${formatClock(Math.floor(curSec))} / ${formatClock(Math.floor(totalSec))}`
|
||||||
|
: formatClock(Math.floor(totalSec))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
client-app/components/chat/VoiceRecorder.tsx
Normal file
183
client-app/components/chat/VoiceRecorder.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* VoiceRecorder — inline UI для записи голосового сообщения через
|
||||||
|
* expo-audio (заменил deprecated expo-av).
|
||||||
|
*
|
||||||
|
* UX:
|
||||||
|
* - При монтировании проверяет permission + запускает запись
|
||||||
|
* - [🗑] ● timer Recording… [↑]
|
||||||
|
* - 🗑 = cancel (discard), ↑ = stop + send
|
||||||
|
*
|
||||||
|
* Состояние recorder'а живёт в useAudioRecorder hook'е. Prepare + start
|
||||||
|
* вызывается из useEffect. Stop — при release, finalized URI через
|
||||||
|
* `recorder.uri`.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { View, Text, Pressable, Alert, Animated } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import {
|
||||||
|
useAudioRecorder, AudioModule, RecordingPresets, setAudioModeAsync,
|
||||||
|
} from 'expo-audio';
|
||||||
|
|
||||||
|
import type { Attachment } from '@/lib/types';
|
||||||
|
|
||||||
|
export interface VoiceRecorderProps {
|
||||||
|
onFinish: (att: Attachment) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceRecorder({ onFinish, onCancel }: VoiceRecorderProps) {
|
||||||
|
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||||
|
const startedAt = useRef(0);
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
// Pulsing red dot
|
||||||
|
const pulse = useRef(new Animated.Value(1)).current;
|
||||||
|
useEffect(() => {
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(pulse, { toValue: 0.4, duration: 500, useNativeDriver: true }),
|
||||||
|
Animated.timing(pulse, { toValue: 1, duration: 500, useNativeDriver: true }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [pulse]);
|
||||||
|
|
||||||
|
// Start recording at mount
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const perm = await AudioModule.requestRecordingPermissionsAsync();
|
||||||
|
if (!perm.granted) {
|
||||||
|
setError('Microphone permission denied');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setAudioModeAsync({
|
||||||
|
allowsRecording: true,
|
||||||
|
playsInSilentMode: true,
|
||||||
|
});
|
||||||
|
await recorder.prepareToRecordAsync();
|
||||||
|
if (cancelled) return;
|
||||||
|
recorder.record();
|
||||||
|
startedAt.current = Date.now();
|
||||||
|
setReady(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? 'Failed to start recording');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Timer tick
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
const t = setInterval(() => {
|
||||||
|
setElapsed(Math.floor((Date.now() - startedAt.current) / 1000));
|
||||||
|
}, 250);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [ready]);
|
||||||
|
|
||||||
|
const stop = async (send: boolean) => {
|
||||||
|
try {
|
||||||
|
if (recorder.isRecording) {
|
||||||
|
await recorder.stop();
|
||||||
|
}
|
||||||
|
const uri = recorder.uri;
|
||||||
|
const seconds = Math.max(1, Math.floor((Date.now() - startedAt.current) / 1000));
|
||||||
|
if (!send || !uri || seconds < 1) {
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFinish({
|
||||||
|
kind: 'voice',
|
||||||
|
uri,
|
||||||
|
duration: seconds,
|
||||||
|
mime: 'audio/m4a',
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Recording failed', e?.message ?? 'Unknown error');
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mm = Math.floor(elapsed / 60);
|
||||||
|
const ss = elapsed % 60;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
borderRadius: 22,
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
paddingHorizontal: 14, paddingVertical: 8,
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="alert-circle" size={18} color="#f4212e" />
|
||||||
|
<Text style={{ color: '#f4212e', fontSize: 13, flex: 1 }}>{error}</Text>
|
||||||
|
<Pressable onPress={onCancel} hitSlop={8}>
|
||||||
|
<Ionicons name="close" size={20} color="#8b8b8b" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
borderRadius: 22,
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
paddingHorizontal: 10, paddingVertical: 6,
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => stop(false)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, borderRadius: 16,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={20} color="#f4212e" />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, flex: 1 }}>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: 10, height: 10, borderRadius: 5,
|
||||||
|
backgroundColor: '#f4212e',
|
||||||
|
opacity: pulse,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '600' }}>
|
||||||
|
{mm}:{String(ss).padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12 }}>
|
||||||
|
Recording…
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => stop(true)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 32, height: 32, borderRadius: 16,
|
||||||
|
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="arrow-up" size={18} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
client-app/components/chat/rows.ts
Normal file
79
client-app/components/chat/rows.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Группировка сообщений в rows для FlatList чат-экрана.
|
||||||
|
*
|
||||||
|
* Чистая функция — никаких React-зависимостей, легко тестируется unit'ом.
|
||||||
|
*
|
||||||
|
* Правила:
|
||||||
|
* 1. Между разными календарными днями вставляется {kind:'sep', label}.
|
||||||
|
* 2. Внутри одного дня peer-сообщения группируются в "лесенку" с учётом:
|
||||||
|
* - смены отправителя
|
||||||
|
* - перерыва > 1 часа между соседними сообщениями
|
||||||
|
* В пределах одной группы:
|
||||||
|
* showName = true только у первого
|
||||||
|
* showAvatar = true только у последнего
|
||||||
|
* 3. mine-сообщения всегда idle: showName=false, showAvatar=false
|
||||||
|
* (в референсе X-style никогда не рисуется имя/аватар над своим bubble).
|
||||||
|
*
|
||||||
|
* showName/showAvatar всё равно вычисляются — даже если потом render-слой
|
||||||
|
* их проигнорирует (DM / channel — без sender-meta). Логика кнопки renders
|
||||||
|
* сама решает показывать ли их, см. MessageBubble → withSenderMeta.
|
||||||
|
*/
|
||||||
|
import type { Message } from '@/lib/types';
|
||||||
|
import { dateBucket } from '@/lib/dates';
|
||||||
|
|
||||||
|
export type Row =
|
||||||
|
| { kind: 'sep'; id: string; label: string }
|
||||||
|
| {
|
||||||
|
kind: 'msg';
|
||||||
|
msg: Message;
|
||||||
|
showName: boolean;
|
||||||
|
showAvatar: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Максимальная пауза внутри "лесенки" — после неё новый run.
|
||||||
|
const RUN_GAP_SECONDS = 60 * 60; // 1 час
|
||||||
|
|
||||||
|
export function buildRows(msgs: Message[]): Row[] {
|
||||||
|
const out: Row[] = [];
|
||||||
|
let lastBucket = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < msgs.length; i++) {
|
||||||
|
const m = msgs[i];
|
||||||
|
const b = dateBucket(m.timestamp);
|
||||||
|
|
||||||
|
if (b !== lastBucket) {
|
||||||
|
out.push({ kind: 'sep', id: `sep_${b}_${m.id}`, label: b });
|
||||||
|
lastBucket = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = msgs[i - 1];
|
||||||
|
const next = msgs[i + 1];
|
||||||
|
|
||||||
|
// "Прервать run" флаги:
|
||||||
|
// - разный день
|
||||||
|
// - разный отправитель
|
||||||
|
// - своё vs чужое
|
||||||
|
// - пауза > 1 часа
|
||||||
|
const breakBefore =
|
||||||
|
!prev ||
|
||||||
|
dateBucket(prev.timestamp) !== b ||
|
||||||
|
prev.from !== m.from ||
|
||||||
|
prev.mine !== m.mine ||
|
||||||
|
(m.timestamp - prev.timestamp) > RUN_GAP_SECONDS;
|
||||||
|
|
||||||
|
const breakAfter =
|
||||||
|
!next ||
|
||||||
|
dateBucket(next.timestamp) !== b ||
|
||||||
|
next.from !== m.from ||
|
||||||
|
next.mine !== m.mine ||
|
||||||
|
(next.timestamp - m.timestamp) > RUN_GAP_SECONDS;
|
||||||
|
|
||||||
|
// Для mine — никогда не показываем имя/аватар.
|
||||||
|
const showName = m.mine ? false : breakBefore;
|
||||||
|
const showAvatar = m.mine ? false : breakAfter;
|
||||||
|
|
||||||
|
out.push({ kind: 'msg', msg: m, showName, showAvatar });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
480
client-app/components/feed/PostCard.tsx
Normal file
480
client-app/components/feed/PostCard.tsx
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* PostCard — Twitter-style feed row.
|
||||||
|
*
|
||||||
|
* Layout (top-to-bottom, left-to-right):
|
||||||
|
*
|
||||||
|
* [avatar 44] [@author · time · ⋯ menu]
|
||||||
|
* [post text body with #tags + @mentions highlighted]
|
||||||
|
* [optional attachment preview]
|
||||||
|
* [💬 0 🔁 link ❤️ likes 👁 views]
|
||||||
|
*
|
||||||
|
* Interaction model:
|
||||||
|
* - Tap anywhere except controls → navigate to post detail
|
||||||
|
* - Tap author/avatar → profile
|
||||||
|
* - Double-tap the post body → like (with a short heart-bounce animation)
|
||||||
|
* - Long-press → context menu (copy, share link, delete-if-mine)
|
||||||
|
*
|
||||||
|
* Performance notes:
|
||||||
|
* - Memoised. Feed lists re-render often (after every like, view bump,
|
||||||
|
* new post), but each card only needs to update when ITS own stats
|
||||||
|
* change. We use shallow prop comparison + stable key on post_id.
|
||||||
|
* - Stats are passed in by parent (fetched once per refresh), not
|
||||||
|
* fetched here — avoids N /stats requests per timeline render.
|
||||||
|
*/
|
||||||
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, Pressable, Alert, Animated, Image,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getNodeUrl } from '@/lib/api';
|
||||||
|
import type { FeedPostItem } from '@/lib/feed';
|
||||||
|
import {
|
||||||
|
formatRelativeTime, formatCount, likePost, unlikePost, deletePost,
|
||||||
|
} from '@/lib/feed';
|
||||||
|
import { ShareSheet } from '@/components/feed/ShareSheet';
|
||||||
|
|
||||||
|
export interface PostCardProps {
|
||||||
|
post: FeedPostItem;
|
||||||
|
/** true = current user has liked this post (used for filled heart). */
|
||||||
|
likedByMe?: boolean;
|
||||||
|
/** Called after a successful like/unlike so parent can refresh stats. */
|
||||||
|
onStatsChanged?: (postID: string) => void;
|
||||||
|
/** Called after delete so parent can drop the card from the list. */
|
||||||
|
onDeleted?: (postID: string) => void;
|
||||||
|
/** Compact (no attachment, less padding) — used in nested thread context. */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }: PostCardProps) {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
|
||||||
|
// Optimistic local state — immediate response to tap, reconciled after tx.
|
||||||
|
const [localLiked, setLocalLiked] = useState<boolean>(!!likedByMe);
|
||||||
|
const [localLikeCount, setLocalLikeCount] = useState<number>(post.likes);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setLocalLiked(!!likedByMe);
|
||||||
|
setLocalLikeCount(post.likes);
|
||||||
|
}, [likedByMe, post.likes]);
|
||||||
|
|
||||||
|
// Heart bounce animation when liked (Twitter-style).
|
||||||
|
const heartScale = useMemo(() => new Animated.Value(1), []);
|
||||||
|
const animateHeart = useCallback(() => {
|
||||||
|
heartScale.setValue(0.6);
|
||||||
|
Animated.spring(heartScale, {
|
||||||
|
toValue: 1,
|
||||||
|
friction: 3,
|
||||||
|
tension: 120,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}, [heartScale]);
|
||||||
|
|
||||||
|
const mine = !!keyFile && keyFile.pub_key === post.author;
|
||||||
|
|
||||||
|
// Find a display-friendly name for the author. If it's a known contact
|
||||||
|
// 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(() => {
|
||||||
|
if (mine) return 'You';
|
||||||
|
const c = contacts.find(x => x.address === post.author);
|
||||||
|
if (c?.username) return `@${c.username}`;
|
||||||
|
if (c?.alias) return c.alias;
|
||||||
|
return shortAddr(post.author);
|
||||||
|
}, [contacts, post.author, mine]);
|
||||||
|
|
||||||
|
const onToggleLike = useCallback(async () => {
|
||||||
|
if (!keyFile || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
const wasLiked = localLiked;
|
||||||
|
// Optimistic update.
|
||||||
|
setLocalLiked(!wasLiked);
|
||||||
|
setLocalLikeCount(c => c + (wasLiked ? -1 : 1));
|
||||||
|
animateHeart();
|
||||||
|
try {
|
||||||
|
if (wasLiked) {
|
||||||
|
await unlikePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||||||
|
} else {
|
||||||
|
await likePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||||||
|
}
|
||||||
|
// Refresh stats from server so counts reconcile (on-chain is delayed
|
||||||
|
// by block time; server returns current cached count).
|
||||||
|
setTimeout(() => onStatsChanged?.(post.post_id), 1500);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Roll back optimistic update.
|
||||||
|
setLocalLiked(wasLiked);
|
||||||
|
setLocalLikeCount(c => c + (wasLiked ? 1 : -1));
|
||||||
|
Alert.alert('Failed', String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [keyFile, busy, localLiked, post.post_id, animateHeart, onStatsChanged]);
|
||||||
|
|
||||||
|
const onOpenDetail = useCallback(() => {
|
||||||
|
router.push(`/(app)/feed/${post.post_id}` as never);
|
||||||
|
}, [post.post_id]);
|
||||||
|
|
||||||
|
const onOpenAuthor = useCallback(() => {
|
||||||
|
router.push(`/(app)/profile/${post.author}` as never);
|
||||||
|
}, [post.author]);
|
||||||
|
|
||||||
|
const onLongPress = useCallback(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const options: Array<{ label: string; destructive?: boolean; onPress: () => void }> = [];
|
||||||
|
if (mine) {
|
||||||
|
options.push({
|
||||||
|
label: 'Delete post',
|
||||||
|
destructive: true,
|
||||||
|
onPress: () => {
|
||||||
|
Alert.alert('Delete post?', 'This action cannot be undone.', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await deletePost({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
postID: post.post_id,
|
||||||
|
});
|
||||||
|
onDeleted?.(post.post_id);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Error', String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.length === 0) return;
|
||||||
|
const buttons: Array<{ text: string; style?: 'default' | 'cancel' | 'destructive'; onPress?: () => void }> = [
|
||||||
|
...options.map(o => ({
|
||||||
|
text: o.label,
|
||||||
|
style: (o.destructive ? 'destructive' : 'default') as 'default' | 'destructive',
|
||||||
|
onPress: o.onPress,
|
||||||
|
})),
|
||||||
|
{ text: 'Cancel', style: 'cancel' as const },
|
||||||
|
];
|
||||||
|
Alert.alert('Actions', '', buttons);
|
||||||
|
}, [keyFile, mine, post.post_id, onDeleted]);
|
||||||
|
|
||||||
|
// Attachment preview URL — native Image can stream straight from the
|
||||||
|
// hosting relay's /feed/post/{id}/attachment endpoint. `getNodeUrl()`
|
||||||
|
// returns the node the client is connected to; for cross-relay posts
|
||||||
|
// that's actually the hosting relay once /api/relays resolution lands
|
||||||
|
// (Phase D). For now we assume same-node.
|
||||||
|
const attachmentURL = post.has_attachment
|
||||||
|
? `${getNodeUrl()}/feed/post/${post.post_id}/attachment`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Body content truncation:
|
||||||
|
// - In the timeline (compact=undefined/false) cap at 5 lines. If the
|
||||||
|
// text is longer the rest is hidden behind "…" — tapping the card
|
||||||
|
// opens the detail view where the full body is shown.
|
||||||
|
// - In post detail (compact=true) show everything.
|
||||||
|
const bodyLines = compact ? undefined : 5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Outer container is a plain View so layout styles (padding, row
|
||||||
|
direction) are static and always applied. Pressable's dynamic
|
||||||
|
style-function has been observed to drop properties between
|
||||||
|
renders on some RN versions — we hit that with the FAB, so
|
||||||
|
we're not relying on it here either. Tap handling lives on the
|
||||||
|
content-column Pressable (covers ~90% of the card area) plus a
|
||||||
|
separate Pressable around the avatar. */}
|
||||||
|
{/* Card = vertical stack: [HEADER row with avatar+name+time] /
|
||||||
|
[FULL-WIDTH content column with body/image/actions]. Putting
|
||||||
|
content under the header (rather than in a column next to the
|
||||||
|
avatar) means body text and attachments occupy the full card
|
||||||
|
width — no risk of the text running next to the avatar and
|
||||||
|
clipping off the right edge. */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingLeft: 16,
|
||||||
|
paddingRight: 16,
|
||||||
|
paddingVertical: compact ? 10 : 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── HEADER ROW: [avatar] [name · time] [menu] ──────────────── */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<Pressable onPress={onOpenAuthor} hitSlop={4} style={{ width: 44 }}>
|
||||||
|
<Avatar name={displayName} address={post.author} size={44} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Name + time take all remaining horizontal space in the
|
||||||
|
header, with the name truncating (numberOfLines:1 +
|
||||||
|
flexShrink:1) and the "· <time>" tail pinned to stay on
|
||||||
|
the same line (flexShrink:0). */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onOpenAuthor}
|
||||||
|
hitSlop={{ top: 4, bottom: 2 }}
|
||||||
|
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', marginLeft: 10, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
color: '#6a6a6a',
|
||||||
|
fontSize: 13,
|
||||||
|
marginLeft: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
· {formatRelativeTime(post.created_at)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{mine && (
|
||||||
|
<Pressable onPress={onLongPress} hitSlop={8} style={{ marginLeft: 8 }}>
|
||||||
|
<Ionicons name="ellipsis-horizontal" size={16} color="#6a6a6a" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── CONTENT (body, attachment, actions) — full card width ──── */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onOpenDetail}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
marginTop: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
opacity: pressed ? 0.85 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* Body text with hashtag highlighting. Full card width now
|
||||||
|
(we moved it out of the avatar-sibling column) — no special
|
||||||
|
width tricks needed, normal wrapping just works. */}
|
||||||
|
{post.content.length > 0 && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={bodyLines}
|
||||||
|
ellipsizeMode="tail"
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderInline(post.content)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachment preview.
|
||||||
|
Timeline (compact=false): aspect-ratio capped at 4:5 so a
|
||||||
|
portrait photo doesn't occupy the whole screen — extra height
|
||||||
|
is cropped via resizeMode="cover", full image shows in detail.
|
||||||
|
Detail (compact=true): contain with no aspect cap → original
|
||||||
|
proportions preserved. */}
|
||||||
|
{attachmentURL && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
borderRadius: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: attachmentURL }}
|
||||||
|
style={compact ? {
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: 1, // will be overridden by onLoad if known
|
||||||
|
maxHeight: undefined,
|
||||||
|
} : {
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: 4 / 5, // portrait-friendly but bounded
|
||||||
|
}}
|
||||||
|
resizeMode={compact ? 'contain' : 'cover'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action row — 3 buttons (Twitter-style). Comments button
|
||||||
|
intentionally omitted: v2.0.0 doesn't implement replies and
|
||||||
|
a dead button with "0" adds noise. Heart + views + share
|
||||||
|
distribute across the row; share pins to the right edge. */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1, alignItems: 'flex-start' }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onToggleLike}
|
||||||
|
disabled={busy}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||||||
|
opacity: pressed ? 0.5 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
|
||||||
|
<Ionicons
|
||||||
|
name={localLiked ? 'heart' : 'heart-outline'}
|
||||||
|
size={16}
|
||||||
|
color={localLiked ? '#e0245e' : '#6a6a6a'}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: localLiked ? '#e0245e' : '#6a6a6a',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: localLiked ? '600' : '400',
|
||||||
|
lineHeight: 16,
|
||||||
|
includeFontPadding: false,
|
||||||
|
textAlignVertical: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCount(localLikeCount)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, alignItems: 'flex-start' }}>
|
||||||
|
<ActionButton
|
||||||
|
icon="eye-outline"
|
||||||
|
label={formatCount(post.views)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ alignItems: 'flex-end' }}>
|
||||||
|
<ActionButton
|
||||||
|
icon="share-outline"
|
||||||
|
onPress={() => setShareOpen(true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<ShareSheet
|
||||||
|
visible={shareOpen}
|
||||||
|
post={post}
|
||||||
|
onClose={() => setShareOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PostCard = React.memo(PostCardInner);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostSeparator — visible divider line between post cards. Exported so
|
||||||
|
* every feed surface (timeline, author, hashtag, post detail) can pass
|
||||||
|
* it as ItemSeparatorComponent and get identical spacing / colour.
|
||||||
|
*
|
||||||
|
* Layout: 12px blank space → 1px grey line → 12px blank space. The
|
||||||
|
* blank space on each side makes the line "float" between posts rather
|
||||||
|
* than hugging the edge of the card — gives clear visual separation
|
||||||
|
* without needing big card padding everywhere.
|
||||||
|
*
|
||||||
|
* Colour #2a2a2a is the minimum grey that reads on OLED black under
|
||||||
|
* mobile-bright-mode gamma; darker and the seam vanishes.
|
||||||
|
*/
|
||||||
|
export function PostSeparator() {
|
||||||
|
return (
|
||||||
|
<View style={{ paddingVertical: 12 }}>
|
||||||
|
<View style={{ height: 1, backgroundColor: '#2a2a2a' }} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** ActionButton — small icon + optional label. */
|
||||||
|
function ActionButton({ icon, label, onPress }: {
|
||||||
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
label?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
hitSlop={8}
|
||||||
|
disabled={!onPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||||||
|
opacity: pressed ? 0.5 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={16} color="#6a6a6a" />
|
||||||
|
{label && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#6a6a6a',
|
||||||
|
fontSize: 12,
|
||||||
|
// Match icon height (16) as lineHeight so baseline-anchored
|
||||||
|
// text aligns with the icon's vertical centre. Without this,
|
||||||
|
// RN renders Text one pixel lower than the icon mid-point on
|
||||||
|
// most Android fonts, which looks sloppy next to the heart /
|
||||||
|
// eye glyphs.
|
||||||
|
lineHeight: 16,
|
||||||
|
includeFontPadding: false,
|
||||||
|
textAlignVertical: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render post body with hashtag highlighting. Splits by the hashtag regex,
|
||||||
|
* wraps matches in blue-coloured Text spans that are tappable → hashtag
|
||||||
|
* feed. For future: @mentions highlighting + URL auto-linking.
|
||||||
|
*/
|
||||||
|
function renderInline(text: string): React.ReactNode {
|
||||||
|
const parts = text.split(/(#[A-Za-z0-9_\u0400-\u04FF]{1,40})/g);
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
if (part.startsWith('#')) {
|
||||||
|
const tag = part.slice(1);
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={i}
|
||||||
|
style={{ color: '#1d9bf0' }}
|
||||||
|
onPress={() => router.push(`/(app)/feed/tag/${encodeURIComponent(tag)}` as never)}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text key={i}>{part}</Text>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortAddr(a: string, n = 6): string {
|
||||||
|
if (!a) return '—';
|
||||||
|
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||||
|
}
|
||||||
|
|
||||||
302
client-app/components/feed/ShareSheet.tsx
Normal file
302
client-app/components/feed/ShareSheet.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* ShareSheet — bottom-sheet picker that forwards a feed post into one
|
||||||
|
* (or several) chats. Opens when the user taps the share icon on a
|
||||||
|
* PostCard.
|
||||||
|
*
|
||||||
|
* Design notes
|
||||||
|
* ------------
|
||||||
|
* - Single modal component, managed by the parent via `visible` +
|
||||||
|
* `onClose`. Parent passes the `post` it wants to share.
|
||||||
|
* - Multi-select: the user can tick several contacts at once and hit
|
||||||
|
* "Отправить". Fits the common "share with a couple of friends"
|
||||||
|
* flow better than one-at-a-time.
|
||||||
|
* - Only contacts with an x25519 key show up — those are the ones we
|
||||||
|
* can actually encrypt for. An info note explains absent contacts.
|
||||||
|
* - Search: typing filters the list by username / alias / address
|
||||||
|
* prefix. Useful once the user has more than a screenful of
|
||||||
|
* contacts.
|
||||||
|
*/
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, Pressable, Modal, FlatList, TextInput, ActivityIndicator, Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import type { Contact } from '@/lib/types';
|
||||||
|
import type { FeedPostItem } from '@/lib/feed';
|
||||||
|
import { forwardPostToContacts } from '@/lib/forwardPost';
|
||||||
|
|
||||||
|
export interface ShareSheetProps {
|
||||||
|
visible: boolean;
|
||||||
|
post: FeedPostItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
const available = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const withKeys = contacts.filter(c => !!c.x25519Pub);
|
||||||
|
if (!q) return withKeys;
|
||||||
|
return withKeys.filter(c =>
|
||||||
|
(c.username ?? '').toLowerCase().includes(q) ||
|
||||||
|
(c.alias ?? '').toLowerCase().includes(q) ||
|
||||||
|
c.address.toLowerCase().startsWith(q),
|
||||||
|
);
|
||||||
|
}, [contacts, query]);
|
||||||
|
|
||||||
|
const toggle = (address: string) => {
|
||||||
|
setPicked(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(address)) next.delete(address);
|
||||||
|
else next.add(address);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const doSend = async () => {
|
||||||
|
if (!post || !keyFile) return;
|
||||||
|
const targets = contacts.filter(c => picked.has(c.address));
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const { ok, failed } = await forwardPostToContacts({
|
||||||
|
post, contacts: targets, keyFile,
|
||||||
|
});
|
||||||
|
if (failed > 0) {
|
||||||
|
Alert.alert('Done', `Sent to ${ok} of ${ok + failed} ${plural(ok + failed)}.`);
|
||||||
|
}
|
||||||
|
// Close + reset regardless — done is done.
|
||||||
|
setPicked(new Set());
|
||||||
|
setQuery('');
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Failed', String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAndReset = () => {
|
||||||
|
setPicked(new Set());
|
||||||
|
setQuery('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
transparent
|
||||||
|
onRequestClose={closeAndReset}
|
||||||
|
>
|
||||||
|
{/* Dim backdrop — tap to dismiss */}
|
||||||
|
<Pressable
|
||||||
|
onPress={closeAndReset}
|
||||||
|
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.72)', justifyContent: 'flex-end' }}
|
||||||
|
>
|
||||||
|
{/* Sheet body — stopPropagation so inner taps don't dismiss */}
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => e.stopPropagation?.()}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderTopLeftRadius: 22,
|
||||||
|
borderTopRightRadius: 22,
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 10) + 10,
|
||||||
|
maxHeight: '78%',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: 44, height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title row */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
paddingHorizontal: 16, marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}>
|
||||||
|
Share post
|
||||||
|
</Text>
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
<Pressable onPress={closeAndReset} hitSlop={8}>
|
||||||
|
<Ionicons name="close" size={22} color="#8b8b8b" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<View style={{ paddingHorizontal: 16, marginBottom: 10 }}>
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
gap: 6,
|
||||||
|
}}>
|
||||||
|
<Ionicons name="search" size={14} color="#6a6a6a" />
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder="Search contacts"
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
}}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
{query.length > 0 && (
|
||||||
|
<Pressable onPress={() => setQuery('')} hitSlop={6}>
|
||||||
|
<Ionicons name="close-circle" size={16} color="#6a6a6a" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Contact list */}
|
||||||
|
<FlatList
|
||||||
|
data={available}
|
||||||
|
keyExtractor={c => c.address}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ContactRow
|
||||||
|
contact={item}
|
||||||
|
checked={picked.has(item.address)}
|
||||||
|
onToggle={() => toggle(item.address)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={{
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<Ionicons name="people-outline" size={28} color="#5a5a5a" />
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 10 }}>
|
||||||
|
{query.length > 0
|
||||||
|
? 'No contacts match this search'
|
||||||
|
: 'No contacts with encryption keys yet'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Send button */}
|
||||||
|
<View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={doSend}
|
||||||
|
disabled={picked.size === 0 || sending}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 13,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor:
|
||||||
|
picked.size === 0 ? '#1f1f1f'
|
||||||
|
: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={{
|
||||||
|
color: picked.size === 0 ? '#5a5a5a' : '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
|
}}>
|
||||||
|
{picked.size === 0
|
||||||
|
? 'Select contacts'
|
||||||
|
: `Send (${picked.size})`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ContactRow({ contact, checked, onToggle }: {
|
||||||
|
contact: Contact;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const name = contact.username
|
||||||
|
? `@${contact.username}`
|
||||||
|
: contact.alias ?? shortAddr(contact.address);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onToggle}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
backgroundColor: pressed ? '#111111' : 'transparent',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Avatar name={name} address={contact.address} size={38} />
|
||||||
|
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}
|
||||||
|
>
|
||||||
|
{shortAddr(contact.address, 8)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{/* Checkbox indicator */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 22, height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: checked ? '#1d9bf0' : '#2a2a2a',
|
||||||
|
backgroundColor: checked ? '#1d9bf0' : 'transparent',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{checked && <Ionicons name="checkmark" size={14} color="#ffffff" />}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortAddr(a: string, n = 6): string {
|
||||||
|
if (!a) return '—';
|
||||||
|
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(n: number): string {
|
||||||
|
return n === 1 ? 'chat' : 'chats';
|
||||||
|
}
|
||||||
22
client-app/eas.json
Normal file
22
client-app/eas.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 18.7.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": { "buildType": "apk" }
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
client-app/global.css
Normal file
3
client-app/global.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
94
client-app/hooks/useBalance.ts
Normal file
94
client-app/hooks/useBalance.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Balance hook — uses the WebSocket gateway to receive instant updates when
|
||||||
|
* a tx involving the current address is committed, with HTTP polling as a
|
||||||
|
* graceful fallback for old nodes that don't expose /api/ws.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. On mount: immediate HTTP fetch so the UI has a non-zero balance ASAP
|
||||||
|
* 2. Subscribe to `addr:<my_pubkey>` on the WS hub
|
||||||
|
* 3. On every `tx` event, re-fetch balance (cheap — one Badger read server-side)
|
||||||
|
* 4. If WS disconnects for >15s, fall back to 10-second polling until it reconnects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { getBalance } from '@/lib/api';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
const FALLBACK_POLL_INTERVAL = 10_000; // HTTP poll when WS is down
|
||||||
|
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
|
||||||
|
|
||||||
|
export function useBalance() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const setBalance = useStore(s => s.setBalance);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
try {
|
||||||
|
const bal = await getBalance(keyFile.pub_key);
|
||||||
|
setBalance(bal);
|
||||||
|
} catch {
|
||||||
|
// transient — next call will retry
|
||||||
|
}
|
||||||
|
}, [keyFile, setBalance]);
|
||||||
|
|
||||||
|
// --- fallback polling management ---
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const disconnectSinceRef = useRef<number | null>(null);
|
||||||
|
const disconnectTORef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const startPolling = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) return;
|
||||||
|
console.log('[useBalance] WS down for grace period — starting HTTP poll');
|
||||||
|
refresh();
|
||||||
|
pollTimerRef.current = setInterval(refresh, FALLBACK_POLL_INTERVAL);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) {
|
||||||
|
clearInterval(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (disconnectTORef.current) {
|
||||||
|
clearTimeout(disconnectTORef.current);
|
||||||
|
disconnectTORef.current = null;
|
||||||
|
}
|
||||||
|
disconnectSinceRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const ws = getWSClient();
|
||||||
|
|
||||||
|
// Immediate HTTP fetch so the UI is not empty while the WS hello arrives.
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
// Refresh balance whenever a tx for our address is committed.
|
||||||
|
const offTx = ws.subscribe('addr:' + keyFile.pub_key, (frame) => {
|
||||||
|
if (frame.event === 'tx') {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manage fallback polling based on WS connection state.
|
||||||
|
const offConn = ws.onConnectionChange((ok) => {
|
||||||
|
if (ok) {
|
||||||
|
stopPolling();
|
||||||
|
refresh(); // catch up anything we missed while disconnected
|
||||||
|
} else if (disconnectTORef.current === null) {
|
||||||
|
disconnectSinceRef.current = Date.now();
|
||||||
|
disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
offTx();
|
||||||
|
offConn();
|
||||||
|
stopPolling();
|
||||||
|
};
|
||||||
|
}, [keyFile, refresh, startPolling, stopPolling]);
|
||||||
|
|
||||||
|
return { refresh };
|
||||||
|
}
|
||||||
52
client-app/hooks/useConnectionStatus.ts
Normal file
52
client-app/hooks/useConnectionStatus.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* useConnectionStatus — объединённое состояние подключения клиента к сети.
|
||||||
|
*
|
||||||
|
* Определяет один из трёх стейтов:
|
||||||
|
* - 'offline' — нет интернета по данным NetInfo
|
||||||
|
* - 'connecting' — интернет есть, но WebSocket к ноде не подключён
|
||||||
|
* - 'online' — WebSocket к ноде активен
|
||||||
|
*
|
||||||
|
* Используется в headers (например Messages → "Connecting...",
|
||||||
|
* "Waiting for internet") и на profile-avatar'ах как индикатор
|
||||||
|
* живости.
|
||||||
|
*
|
||||||
|
* NetInfo использует connected + internetReachable для детекта
|
||||||
|
* настоящего Internet (не просто Wi-Fi SSID без доступа); fallback
|
||||||
|
* на `connected`-only когда internetReachable неопределён (некоторые
|
||||||
|
* корпоративные сети или Android в первые секунды).
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
|
||||||
|
export type ConnectionStatus = 'online' | 'connecting' | 'offline';
|
||||||
|
|
||||||
|
export function useConnectionStatus(): ConnectionStatus {
|
||||||
|
const [wsLive, setWsLive] = useState(false);
|
||||||
|
const [hasNet, setHasNet] = useState(true);
|
||||||
|
|
||||||
|
// WS live-state: subscribe к его onConnectionChange.
|
||||||
|
useEffect(() => {
|
||||||
|
const ws = getWSClient();
|
||||||
|
setWsLive(ws.isConnected());
|
||||||
|
return ws.onConnectionChange(setWsLive);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Internet reachability: через NetInfo.
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = NetInfo.addEventListener((state) => {
|
||||||
|
// internetReachable = null значит "ещё не проверили" — считаем
|
||||||
|
// что есть, чтобы не ложно отображать "offline" на старте.
|
||||||
|
const reachable =
|
||||||
|
state.isInternetReachable === false ? false :
|
||||||
|
state.isConnected === false ? false :
|
||||||
|
true;
|
||||||
|
setHasNet(reachable);
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!hasNet) return 'offline';
|
||||||
|
if (wsLive) return 'online';
|
||||||
|
return 'connecting';
|
||||||
|
}
|
||||||
80
client-app/hooks/useContacts.ts
Normal file
80
client-app/hooks/useContacts.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Contacts + inbound request tracking.
|
||||||
|
*
|
||||||
|
* - Loads cached contacts from local storage on boot.
|
||||||
|
* - Subscribes to the address WS topic so a new CONTACT_REQUEST pulls the
|
||||||
|
* relay contact list immediately (sub-second UX).
|
||||||
|
* - Keeps a 30 s polling fallback for nodes without WS or while disconnected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { fetchContactRequests } from '@/lib/api';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
import { loadContacts } from '@/lib/storage';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
const FALLBACK_POLL_INTERVAL = 30_000;
|
||||||
|
|
||||||
|
export function useContacts() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const setContacts = useStore(s => s.setContacts);
|
||||||
|
const setRequests = useStore(s => s.setRequests);
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
|
||||||
|
// Load cached contacts from local storage once
|
||||||
|
useEffect(() => {
|
||||||
|
loadContacts().then(setContacts);
|
||||||
|
}, [setContacts]);
|
||||||
|
|
||||||
|
const pollRequests = useCallback(async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
try {
|
||||||
|
const raw = await fetchContactRequests(keyFile.pub_key);
|
||||||
|
|
||||||
|
// Filter out already-accepted contacts
|
||||||
|
const contactAddresses = new Set(contacts.map(c => c.address));
|
||||||
|
|
||||||
|
const requests = raw
|
||||||
|
.filter(r => r.status === 'pending' && !contactAddresses.has(r.requester_pub))
|
||||||
|
.map(r => ({
|
||||||
|
from: r.requester_pub,
|
||||||
|
// x25519Pub will be fetched from identity when user taps Accept
|
||||||
|
x25519Pub: '',
|
||||||
|
intro: r.intro ?? '',
|
||||||
|
timestamp: r.created_at,
|
||||||
|
txHash: r.tx_id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setRequests(requests);
|
||||||
|
} catch {
|
||||||
|
// Ignore transient network errors
|
||||||
|
}
|
||||||
|
}, [keyFile, contacts, setRequests]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const ws = getWSClient();
|
||||||
|
|
||||||
|
// Initial load + low-frequency fallback poll (covers missed WS events,
|
||||||
|
// works even when the node has no WS endpoint).
|
||||||
|
pollRequests();
|
||||||
|
const interval = setInterval(pollRequests, FALLBACK_POLL_INTERVAL);
|
||||||
|
|
||||||
|
// Immediate refresh when a CONTACT_REQUEST / ACCEPT_CONTACT tx addressed
|
||||||
|
// to us lands on-chain. WS fan-out already filters to our address topic.
|
||||||
|
const off = ws.subscribe('addr:' + keyFile.pub_key, (frame) => {
|
||||||
|
if (frame.event === 'tx') {
|
||||||
|
const d = frame.data as { tx_type?: string } | undefined;
|
||||||
|
if (d?.tx_type === 'CONTACT_REQUEST' || d?.tx_type === 'ACCEPT_CONTACT') {
|
||||||
|
pollRequests();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
off();
|
||||||
|
};
|
||||||
|
}, [keyFile, pollRequests]);
|
||||||
|
}
|
||||||
124
client-app/hooks/useGlobalInbox.ts
Normal file
124
client-app/hooks/useGlobalInbox.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* useGlobalInbox — app-wide inbox listener.
|
||||||
|
*
|
||||||
|
* Подписан на WS-топик `inbox:<my_x25519>` при любом экране внутри
|
||||||
|
* (app)-группы. Когда приходит push с envelope, мы:
|
||||||
|
* 1. Декриптуем — если это наш контакт, добавляем в store.
|
||||||
|
* 2. Инкрементим unreadByContact[address].
|
||||||
|
* 3. Показываем local notification (от кого + счётчик).
|
||||||
|
*
|
||||||
|
* НЕ дублирует chat-detail'овский `useMessages` — тот делает initial
|
||||||
|
* HTTP-pull при открытии чата и слушает тот же топик (двойная подписка
|
||||||
|
* с фильтром по sender_pub). Оба держат в store консистентное состояние
|
||||||
|
* через `appendMessage` (который идемпотентен по id).
|
||||||
|
*
|
||||||
|
* Фильтрация "app backgrounded" не нужна: Expo notifications'handler
|
||||||
|
* показывает banner и в foreground, но при активном чате с этим
|
||||||
|
* контактом нотификация dismiss'ится автоматически через
|
||||||
|
* clearContactNotifications (вызывается при mount'е chats/[id]).
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { AppState } from 'react-native';
|
||||||
|
import { usePathname } from 'expo-router';
|
||||||
|
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
import { decryptMessage } from '@/lib/crypto';
|
||||||
|
import { tryParsePostRef } from '@/lib/forwardPost';
|
||||||
|
import { fetchInbox } from '@/lib/api';
|
||||||
|
import { appendMessage } from '@/lib/storage';
|
||||||
|
import { randomId } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { notifyIncoming } from './useNotifications';
|
||||||
|
|
||||||
|
export function useGlobalInbox() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const appendMsg = useStore(s => s.appendMessage);
|
||||||
|
const incrementUnread = useStore(s => s.incrementUnread);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const contactsRef = useRef(contacts);
|
||||||
|
const pathnameRef = useRef(pathname);
|
||||||
|
|
||||||
|
useEffect(() => { contactsRef.current = contacts; }, [contacts]);
|
||||||
|
useEffect(() => { pathnameRef.current = pathname; }, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile?.x25519_pub) return;
|
||||||
|
|
||||||
|
const ws = getWSClient();
|
||||||
|
|
||||||
|
const handleEnvelopePull = async () => {
|
||||||
|
try {
|
||||||
|
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||||||
|
for (const env of envelopes) {
|
||||||
|
// Найти контакт по sender_pub — если не знакомый, игнорим
|
||||||
|
// (для MVP; в future можно показывать "unknown sender").
|
||||||
|
const c = contactsRef.current.find(
|
||||||
|
x => x.x25519Pub === env.sender_pub,
|
||||||
|
);
|
||||||
|
if (!c) continue;
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
|
text = decryptMessage(
|
||||||
|
env.ciphertext,
|
||||||
|
env.nonce,
|
||||||
|
env.sender_pub,
|
||||||
|
keyFile.x25519_priv,
|
||||||
|
) ?? '';
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
// Стабильный id от сервера (sha256(nonce||ct)[:16]); fallback
|
||||||
|
// на nonce-префикс если вдруг env.id пустой.
|
||||||
|
const msgId = env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`;
|
||||||
|
const postRef = tryParsePostRef(text);
|
||||||
|
const msg = {
|
||||||
|
id: msgId,
|
||||||
|
from: env.sender_pub,
|
||||||
|
text: postRef ? '' : text,
|
||||||
|
timestamp: env.timestamp,
|
||||||
|
mine: false,
|
||||||
|
...(postRef && {
|
||||||
|
postRef: {
|
||||||
|
postID: postRef.post_id,
|
||||||
|
author: postRef.author,
|
||||||
|
excerpt: postRef.excerpt,
|
||||||
|
hasImage: postRef.has_image,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
appendMsg(c.address, msg);
|
||||||
|
await appendMessage(c.address, msg);
|
||||||
|
|
||||||
|
// Если пользователь прямо сейчас в этом чате — unread не инкрементим,
|
||||||
|
// notification не показываем.
|
||||||
|
const inThisChat =
|
||||||
|
pathnameRef.current === `/chats/${c.address}` ||
|
||||||
|
pathnameRef.current.startsWith(`/chats/${c.address}/`);
|
||||||
|
if (inThisChat && AppState.currentState === 'active') continue;
|
||||||
|
|
||||||
|
incrementUnread(c.address);
|
||||||
|
const unread = useStore.getState().unreadByContact[c.address] ?? 1;
|
||||||
|
notifyIncoming({
|
||||||
|
contactAddress: c.address,
|
||||||
|
senderName: c.username ? `@${c.username}` : (c.alias ?? 'New message'),
|
||||||
|
unreadCount: unread,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* silent — ошибки pull'а обрабатывает useMessages */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const off = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
|
||||||
|
if (frame.event !== 'inbox') return;
|
||||||
|
handleEnvelopePull();
|
||||||
|
});
|
||||||
|
|
||||||
|
return off;
|
||||||
|
}, [keyFile, appendMsg, incrementUnread]);
|
||||||
|
}
|
||||||
159
client-app/hooks/useMessages.ts
Normal file
159
client-app/hooks/useMessages.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Subscribe to the relay inbox via WebSocket and decrypt incoming envelopes
|
||||||
|
* for the active chat. Falls back to 30-second polling whenever the WS is
|
||||||
|
* not connected — preserves correctness on older nodes or flaky networks.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. On mount: one HTTP fetch so we have whatever is already in the inbox
|
||||||
|
* 2. Subscribe to topic `inbox:<my_x25519>` — the node pushes a summary
|
||||||
|
* for each fresh envelope as soon as mailbox.Store() succeeds
|
||||||
|
* 3. On each push, pull the full envelope list (cheap — bounded by
|
||||||
|
* MailboxPerRecipientCap) and decrypt anything we haven't seen yet
|
||||||
|
* 4. If WS disconnects for > 15 seconds, start a 30 s HTTP poll until it
|
||||||
|
* reconnects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { fetchInbox } from '@/lib/api';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
import { decryptMessage } from '@/lib/crypto';
|
||||||
|
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { tryParsePostRef } from '@/lib/forwardPost';
|
||||||
|
|
||||||
|
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
|
||||||
|
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
|
||||||
|
|
||||||
|
export function useMessages(contactX25519: string) {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const appendMsg = useStore(s => s.appendMessage);
|
||||||
|
|
||||||
|
// Подгружаем кэш сообщений из AsyncStorage при открытии чата.
|
||||||
|
// Релей держит envelope'ы всего 7 дней, поэтому без чтения кэша
|
||||||
|
// история старше недели пропадает при каждом рестарте приложения.
|
||||||
|
// appendMsg в store идемпотентен по id, поэтому безопасно гонять его
|
||||||
|
// для каждого кэшированного сообщения.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contactX25519) return;
|
||||||
|
let cancelled = false;
|
||||||
|
loadMessages(contactX25519).then(cached => {
|
||||||
|
if (cancelled) return;
|
||||||
|
for (const m of cached) appendMsg(contactX25519, m);
|
||||||
|
}).catch(() => { /* cache miss / JSON error — not fatal */ });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [contactX25519, appendMsg]);
|
||||||
|
|
||||||
|
const pullAndDecrypt = useCallback(async () => {
|
||||||
|
if (!keyFile || !contactX25519) return;
|
||||||
|
try {
|
||||||
|
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||||||
|
for (const env of envelopes) {
|
||||||
|
// Only process messages from this contact
|
||||||
|
if (env.sender_pub !== contactX25519) continue;
|
||||||
|
|
||||||
|
const text = decryptMessage(
|
||||||
|
env.ciphertext,
|
||||||
|
env.nonce,
|
||||||
|
env.sender_pub,
|
||||||
|
keyFile.x25519_priv,
|
||||||
|
);
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
// Detect forwarded feed posts — plaintext is a tiny JSON
|
||||||
|
// envelope (see lib/forwardPost.ts). Regular text messages
|
||||||
|
// stay as-is.
|
||||||
|
const postRef = tryParsePostRef(text);
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
id: env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`,
|
||||||
|
from: env.sender_pub,
|
||||||
|
text: postRef ? '' : text,
|
||||||
|
timestamp: env.timestamp,
|
||||||
|
mine: false,
|
||||||
|
...(postRef && {
|
||||||
|
postRef: {
|
||||||
|
postID: postRef.post_id,
|
||||||
|
author: postRef.author,
|
||||||
|
excerpt: postRef.excerpt,
|
||||||
|
hasImage: postRef.has_image,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
appendMsg(contactX25519, msg);
|
||||||
|
await appendMessage(contactX25519, msg);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// Шумные ошибки (404 = нет mailbox'а, Network request failed =
|
||||||
|
// нода недоступна) — ожидаемы в dev-среде и при offline-режиме,
|
||||||
|
// не спамим console. Остальное — логируем.
|
||||||
|
const msg = String(e?.message ?? e ?? '');
|
||||||
|
if (/→\s*404\b/.test(msg)) return;
|
||||||
|
if (/ 404\b/.test(msg)) return;
|
||||||
|
if (/Network request failed/i.test(msg)) return;
|
||||||
|
if (/Failed to fetch/i.test(msg)) return;
|
||||||
|
console.warn('[useMessages] pull error:', e);
|
||||||
|
}
|
||||||
|
}, [keyFile, contactX25519, appendMsg]);
|
||||||
|
|
||||||
|
// ── Fallback polling state ────────────────────────────────────────────
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const disconnectTORef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const startPolling = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) return;
|
||||||
|
console.log('[useMessages] WS down — starting HTTP poll fallback');
|
||||||
|
pullAndDecrypt();
|
||||||
|
pollTimerRef.current = setInterval(pullAndDecrypt, FALLBACK_POLL_INTERVAL);
|
||||||
|
}, [pullAndDecrypt]);
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) {
|
||||||
|
clearInterval(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (disconnectTORef.current) {
|
||||||
|
clearTimeout(disconnectTORef.current);
|
||||||
|
disconnectTORef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile || !contactX25519) return;
|
||||||
|
|
||||||
|
const ws = getWSClient();
|
||||||
|
|
||||||
|
// Initial fetch — populate whatever landed before we mounted.
|
||||||
|
pullAndDecrypt();
|
||||||
|
|
||||||
|
// Subscribe to our x25519 inbox — the node emits on mailbox.Store.
|
||||||
|
// Topic filter: only envelopes for ME; we then filter by sender inside
|
||||||
|
// the handler so we only render messages in THIS chat.
|
||||||
|
const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
|
||||||
|
if (frame.event !== 'inbox') return;
|
||||||
|
const d = frame.data as { sender_pub?: string } | undefined;
|
||||||
|
// Optimisation: if the envelope is from a different peer, skip the
|
||||||
|
// whole refetch — we'd just drop it in the sender filter below anyway.
|
||||||
|
if (d?.sender_pub && d.sender_pub !== contactX25519) return;
|
||||||
|
pullAndDecrypt();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manage fallback polling based on WS connection state.
|
||||||
|
const offConn = ws.onConnectionChange((ok) => {
|
||||||
|
if (ok) {
|
||||||
|
stopPolling();
|
||||||
|
// Catch up anything we missed while disconnected.
|
||||||
|
pullAndDecrypt();
|
||||||
|
} else if (disconnectTORef.current === null) {
|
||||||
|
disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
offInbox();
|
||||||
|
offConn();
|
||||||
|
stopPolling();
|
||||||
|
};
|
||||||
|
}, [keyFile, contactX25519, pullAndDecrypt, startPolling, stopPolling]);
|
||||||
|
}
|
||||||
144
client-app/hooks/useNotifications.ts
Normal file
144
client-app/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* useNotifications — bootstrap expo-notifications (permission + handler)
|
||||||
|
* и routing при tap'е на notification → открыть конкретный чат.
|
||||||
|
*
|
||||||
|
* ВАЖНО: expo-notifications в Expo Go (SDK 53+) валит error при САМОМ
|
||||||
|
* import'е модуля ("Android Push notifications ... removed from Expo Go").
|
||||||
|
* Поэтому мы НЕ делаем static `import * as Notifications from ...` —
|
||||||
|
* вместо этого lazy `require()` внутри функций, только если мы вне Expo Go.
|
||||||
|
* На dev-build / production APK всё работает штатно.
|
||||||
|
*
|
||||||
|
* Privacy: notification содержит ТОЛЬКО имя отправителя и счётчик
|
||||||
|
* непрочитанных. Тело сообщения НЕ показывается — для E2E-мессенджера
|
||||||
|
* это критично (push нотификации проходят через OS / APNs и могут
|
||||||
|
* логироваться).
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import Constants, { ExecutionEnvironment } from 'expo-constants';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
|
// В Expo Go push-нотификации отключены с SDK 53. Любое обращение к
|
||||||
|
// expo-notifications (включая import) пишет error в консоль. Детектим
|
||||||
|
// среду один раз на module-load.
|
||||||
|
const IS_EXPO_GO =
|
||||||
|
Constants.executionEnvironment === ExecutionEnvironment.StoreClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-load expo-notifications. Возвращает модуль или null в Expo Go.
|
||||||
|
* Кешируем результат, чтобы не делать require повторно.
|
||||||
|
*/
|
||||||
|
let _cached: any | null | undefined = undefined;
|
||||||
|
function getNotifications(): any | null {
|
||||||
|
if (_cached !== undefined) return _cached;
|
||||||
|
if (IS_EXPO_GO) { _cached = null; return null; }
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
_cached = require('expo-notifications');
|
||||||
|
} catch {
|
||||||
|
_cached = null;
|
||||||
|
}
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler ставим лениво при первом обращении (а не на module-top'е),
|
||||||
|
// т.к. require сам по себе подтянет модуль — в Expo Go его не дергаем.
|
||||||
|
let _handlerInstalled = false;
|
||||||
|
function installHandler() {
|
||||||
|
if (_handlerInstalled) return;
|
||||||
|
const N = getNotifications();
|
||||||
|
if (!N) return;
|
||||||
|
N.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
shouldPlaySound: false,
|
||||||
|
shouldSetBadge: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
_handlerInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
useEffect(() => {
|
||||||
|
const N = getNotifications();
|
||||||
|
if (!N) return; // Expo Go — no-op
|
||||||
|
|
||||||
|
installHandler();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const existing = await N.getPermissionsAsync();
|
||||||
|
if (existing.status !== 'granted' && existing.canAskAgain !== false) {
|
||||||
|
await N.requestPermissionsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
// Channel — обязателен для Android 8+ чтобы уведомления показывались.
|
||||||
|
await N.setNotificationChannelAsync('messages', {
|
||||||
|
name: 'Messages',
|
||||||
|
importance: N.AndroidImportance.HIGH,
|
||||||
|
vibrationPattern: [0, 200, 100, 200],
|
||||||
|
lightColor: '#1d9bf0',
|
||||||
|
sound: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fail-safe — notifications не критичны
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Tap-to-open listener.
|
||||||
|
const sub = N.addNotificationResponseReceivedListener((resp: any) => {
|
||||||
|
const data = resp?.notification?.request?.content?.data as
|
||||||
|
{ contactAddress?: string } | undefined;
|
||||||
|
if (data?.contactAddress) {
|
||||||
|
router.push(`/(app)/chats/${data.contactAddress}` as never);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { try { sub.remove(); } catch { /* ignore */ } };
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать локальное уведомление о новом сообщении. Вызывается из
|
||||||
|
* global-inbox-listener'а когда приходит envelope от peer'а.
|
||||||
|
*
|
||||||
|
* content не содержит текста — только "New message" как generic label
|
||||||
|
* (см. privacy-note в doc'е выше).
|
||||||
|
*/
|
||||||
|
export async function notifyIncoming(params: {
|
||||||
|
contactAddress: string;
|
||||||
|
senderName: string;
|
||||||
|
unreadCount: number;
|
||||||
|
}) {
|
||||||
|
const N = getNotifications();
|
||||||
|
if (!N) return; // Expo Go — no-op
|
||||||
|
const { contactAddress, senderName, unreadCount } = params;
|
||||||
|
try {
|
||||||
|
await N.scheduleNotificationAsync({
|
||||||
|
identifier: `inbox:${contactAddress}`, // replaces previous for same contact
|
||||||
|
content: {
|
||||||
|
title: senderName,
|
||||||
|
body: unreadCount === 1
|
||||||
|
? 'New message'
|
||||||
|
: `${unreadCount} new messages`,
|
||||||
|
data: { contactAddress },
|
||||||
|
},
|
||||||
|
trigger: null, // immediate
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Fail silently — если OS не дала permission, notification не
|
||||||
|
// покажется. Не ломаем send-flow.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss notification для контакта (вызывается когда чат открыт). */
|
||||||
|
export async function clearContactNotifications(contactAddress: string) {
|
||||||
|
const N = getNotifications();
|
||||||
|
if (!N) return;
|
||||||
|
try {
|
||||||
|
await N.dismissNotificationAsync(`inbox:${contactAddress}`);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
61
client-app/hooks/useWellKnownContracts.ts
Normal file
61
client-app/hooks/useWellKnownContracts.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Auto-discover canonical system contracts from the node so the user doesn't
|
||||||
|
* have to paste contract IDs into settings by hand.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. On app boot (and whenever nodeUrl changes), call GET /api/well-known-contracts
|
||||||
|
* 2. If the node advertises a `username_registry` and the user has not
|
||||||
|
* manually set `settings.contractId`, auto-populate it and persist.
|
||||||
|
* 3. A user-supplied contractId is never overwritten — so power users can
|
||||||
|
* still pin a non-canonical deployment from settings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { fetchWellKnownContracts } from '@/lib/api';
|
||||||
|
import { saveSettings } from '@/lib/storage';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
export function useWellKnownContracts() {
|
||||||
|
const nodeUrl = useStore(s => s.settings.nodeUrl);
|
||||||
|
const contractId = useStore(s => s.settings.contractId);
|
||||||
|
const settings = useStore(s => s.settings);
|
||||||
|
const setSettings = useStore(s => s.setSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!nodeUrl) return;
|
||||||
|
const res = await fetchWellKnownContracts();
|
||||||
|
if (cancelled || !res) return;
|
||||||
|
|
||||||
|
const registry = res.contracts['username_registry'];
|
||||||
|
if (!registry) return;
|
||||||
|
|
||||||
|
// Always keep the stored contractId in sync with what the node reports
|
||||||
|
// as canonical. If the user resets their chain or we migrate from a
|
||||||
|
// WASM contract to the native one, the stale contract_id cached in
|
||||||
|
// local storage would otherwise keep the client trying to call a
|
||||||
|
// contract that no longer exists on this chain.
|
||||||
|
//
|
||||||
|
// To still support intentional overrides: the UI's "advanced" section
|
||||||
|
// allows pasting a specific ID — and since that also writes to
|
||||||
|
// settings.contractId, the loop converges back to whatever the node
|
||||||
|
// says after a short delay. Operators who want a hard override should
|
||||||
|
// either run a patched node or pin the value with a wrapper config
|
||||||
|
// outside the app.
|
||||||
|
if (registry.contract_id !== contractId) {
|
||||||
|
const next = { ...settings, contractId: registry.contract_id };
|
||||||
|
setSettings({ contractId: registry.contract_id });
|
||||||
|
await saveSettings(next);
|
||||||
|
console.log('[well-known] synced username_registry =', registry.contract_id,
|
||||||
|
'(was:', contractId || '<empty>', ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// Re-run when the node URL changes (user switched networks) or when
|
||||||
|
// contractId is cleared.
|
||||||
|
}, [nodeUrl, contractId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
}
|
||||||
851
client-app/lib/api.ts
Normal file
851
client-app/lib/api.ts
Normal file
@@ -0,0 +1,851 @@
|
|||||||
|
/**
|
||||||
|
* DChain REST API client.
|
||||||
|
* All requests go to the configured node URL (e.g. http://192.168.1.10:8081).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Envelope, TxRecord, NetStats, Contact } from './types';
|
||||||
|
import { base64ToBytes, bytesToBase64, bytesToHex, hexToBytes } from './crypto';
|
||||||
|
|
||||||
|
// ─── Base ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _nodeUrl = 'http://localhost:8081';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listeners invoked AFTER _nodeUrl changes. The WS client registers here so
|
||||||
|
* that switching nodes in Settings tears down the old socket and re-dials
|
||||||
|
* the new one (without this, a user who pointed their app at node A would
|
||||||
|
* keep receiving A's events forever after flipping to B).
|
||||||
|
*/
|
||||||
|
const nodeUrlListeners = new Set<(url: string) => void>();
|
||||||
|
|
||||||
|
export function setNodeUrl(url: string) {
|
||||||
|
const normalised = url.replace(/\/$/, '');
|
||||||
|
if (_nodeUrl === normalised) return;
|
||||||
|
_nodeUrl = normalised;
|
||||||
|
for (const fn of nodeUrlListeners) {
|
||||||
|
try { fn(_nodeUrl); } catch { /* ignore — listeners are best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeUrl(): string {
|
||||||
|
return _nodeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a callback for node-URL changes. Returns an unsubscribe fn. */
|
||||||
|
export function onNodeUrlChange(fn: (url: string) => void): () => void {
|
||||||
|
nodeUrlListeners.add(fn);
|
||||||
|
return () => { nodeUrlListeners.delete(fn); };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${_nodeUrl}${path}`);
|
||||||
|
if (!res.ok) throw new Error(`GET ${path} → ${res.status}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced error reporter for POST failures. The node's `jsonErr` writes
|
||||||
|
* `{"error": "..."}` as the response body; we parse that out so the UI layer
|
||||||
|
* can show a meaningful message instead of a raw status code.
|
||||||
|
*
|
||||||
|
* Rate-limit and timestamp-skew rejections produce specific strings the UI
|
||||||
|
* can translate to user-friendly Russian via matcher functions below.
|
||||||
|
*/
|
||||||
|
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${_nodeUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
// Try to extract {"error":"..."} payload for a cleaner message.
|
||||||
|
let detail = text;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (parsed?.error) detail = parsed.error;
|
||||||
|
} catch { /* keep raw text */ }
|
||||||
|
// Include HTTP status so `humanizeTxError` can branch on 429/400/etc.
|
||||||
|
throw new Error(`${res.status}: ${detail}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a submission error from `post()` / `submitTx()` into a user-facing
|
||||||
|
* Russian message with actionable hints. Preserves the raw detail at the end
|
||||||
|
* so advanced users can still copy the original for support.
|
||||||
|
*/
|
||||||
|
export function humanizeTxError(e: unknown): string {
|
||||||
|
const raw = e instanceof Error ? e.message : String(e);
|
||||||
|
if (raw.startsWith('429')) {
|
||||||
|
return 'Too many requests to the node. Wait a couple of seconds and try again.';
|
||||||
|
}
|
||||||
|
if (raw.startsWith('400') && raw.includes('timestamp')) {
|
||||||
|
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')) {
|
||||||
|
return 'Transaction signature is invalid. Try again; if this persists the client and node versions may be incompatible.';
|
||||||
|
}
|
||||||
|
if (raw.startsWith('400')) {
|
||||||
|
return `Node rejected transaction: ${raw.replace(/^400:\s*/, '')}`;
|
||||||
|
}
|
||||||
|
if (raw.startsWith('5')) {
|
||||||
|
return `Node error (${raw}). Please try again later.`;
|
||||||
|
}
|
||||||
|
// Network-level
|
||||||
|
if (raw.toLowerCase().includes('network request failed')) {
|
||||||
|
return 'Cannot reach the node. Check the URL in settings and that the server is online.';
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chain API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getNetStats(): Promise<NetStats> {
|
||||||
|
return get<NetStats>('/api/netstats');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddrResponse {
|
||||||
|
balance_ut: number;
|
||||||
|
balance: string;
|
||||||
|
transactions: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
from: string;
|
||||||
|
to?: string;
|
||||||
|
amount_ut: number;
|
||||||
|
fee_ut: number;
|
||||||
|
time: string; // ISO-8601 e.g. "2025-01-01T12:00:00Z"
|
||||||
|
block_index: number;
|
||||||
|
}>;
|
||||||
|
tx_count: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBalance(pubkey: string): Promise<number> {
|
||||||
|
const data = await get<AddrResponse>(`/api/address/${pubkey}`);
|
||||||
|
return data.balance_ut ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction JSON.
|
||||||
|
* Key facts:
|
||||||
|
* - `payload` is base64-encoded JSON bytes (Go []byte → base64 in JSON)
|
||||||
|
* - `signature` is base64-encoded Ed25519 sig (Go []byte → base64 in JSON)
|
||||||
|
* - `timestamp` is RFC3339 string (Go time.Time → string in JSON)
|
||||||
|
* - There is NO nonce field; dedup is by `id`
|
||||||
|
*/
|
||||||
|
export interface RawTx {
|
||||||
|
id: string; // "tx-<nanoseconds>" or sha256-based
|
||||||
|
type: string; // "TRANSFER", "CONTACT_REQUEST", etc.
|
||||||
|
from: string; // hex Ed25519 pub key
|
||||||
|
to: string; // hex Ed25519 pub key (empty string if N/A)
|
||||||
|
amount: number; // µT (uint64)
|
||||||
|
fee: number; // µT (uint64)
|
||||||
|
memo?: string; // optional
|
||||||
|
payload: string; // base64(json.Marshal(TypeSpecificPayload))
|
||||||
|
signature: string; // base64(ed25519.Sign(canonical_bytes, priv))
|
||||||
|
timestamp: string; // RFC3339 e.g. "2025-01-01T12:00:00Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
|
||||||
|
console.log('[submitTx] →', {
|
||||||
|
id: tx.id,
|
||||||
|
type: tx.type,
|
||||||
|
from: tx.from.slice(0, 12) + '…',
|
||||||
|
to: tx.to ? tx.to.slice(0, 12) + '…' : '',
|
||||||
|
amount: tx.amount,
|
||||||
|
fee: tx.fee,
|
||||||
|
timestamp: tx.timestamp,
|
||||||
|
transport: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try the WebSocket path first: no HTTP round-trip, and we get a proper
|
||||||
|
// submit_ack correlated back to our tx id. Falls through to HTTP if WS is
|
||||||
|
// unavailable (old node, disconnected, timeout, etc.) so legacy setups
|
||||||
|
// keep working.
|
||||||
|
try {
|
||||||
|
// Lazy import avoids a circular dep with lib/ws.ts (which itself
|
||||||
|
// imports getNodeUrl from this module).
|
||||||
|
const { getWSClient } = await import('./ws');
|
||||||
|
const ws = getWSClient();
|
||||||
|
if (ws.isConnected()) {
|
||||||
|
try {
|
||||||
|
const res = await ws.submitTx(tx);
|
||||||
|
console.log('[submitTx] ← accepted via WS', res);
|
||||||
|
return { id: res.id || tx.id, status: 'accepted' };
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[submitTx] WS path failed, falling back to HTTP:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* circular import edge case — ignore and use HTTP */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await post<{ id: string; status: string }>('/api/tx', tx);
|
||||||
|
console.log('[submitTx] ← accepted via HTTP', res);
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[submitTx] ← rejected', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[]> {
|
||||||
|
const data = await get<AddrResponse>(`/api/address/${pubkey}?limit=${limit}`);
|
||||||
|
return (data.transactions ?? []).map(tx => ({
|
||||||
|
hash: tx.id,
|
||||||
|
type: tx.type,
|
||||||
|
from: tx.from,
|
||||||
|
to: tx.to,
|
||||||
|
amount: tx.amount_ut,
|
||||||
|
fee: tx.fee_ut,
|
||||||
|
// Convert ISO-8601 string → unix seconds
|
||||||
|
timestamp: tx.time ? Math.floor(new Date(tx.time).getTime() / 1000) : 0,
|
||||||
|
status: 'confirmed' as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Relay API ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Endpoints are mounted at the ROOT of the node HTTP server (not under /api):
|
||||||
|
// POST /relay/broadcast — publish pre-sealed envelope (proper E2E)
|
||||||
|
// GET /relay/inbox — fetch envelopes addressed to <pub>
|
||||||
|
//
|
||||||
|
// Why /relay/broadcast, not /relay/send?
|
||||||
|
// /relay/send takes plaintext (msg_b64) и SEAL'ит его ключом релей-ноды —
|
||||||
|
// это ломает end-to-end шифрование (получатель не сможет расшифровать
|
||||||
|
// своим ключом). Для E2E всегда используем /relay/broadcast с уже
|
||||||
|
// запечатанным на клиенте envelope'ом.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of envelope item returned by GET /relay/inbox (server item type).
|
||||||
|
* Go `[]byte` поля сериализуются как base64 в JSON — поэтому `nonce` и
|
||||||
|
* `ciphertext` приходят base64, а не hex. Мы декодируем их в hex для
|
||||||
|
* совместимости с crypto.ts (decryptMessage принимает hex).
|
||||||
|
*/
|
||||||
|
interface InboxItemWire {
|
||||||
|
id: string;
|
||||||
|
sender_pub: string;
|
||||||
|
recipient_pub: string;
|
||||||
|
fee_ut?: number;
|
||||||
|
sent_at: number;
|
||||||
|
sent_at_human?: string;
|
||||||
|
nonce: string; // base64
|
||||||
|
ciphertext: string; // base64
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InboxResponseWire {
|
||||||
|
pub: string;
|
||||||
|
count: number;
|
||||||
|
has_more: boolean;
|
||||||
|
items: InboxItemWire[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клиент собирает envelope через encryptMessage и шлёт на /relay/broadcast.
|
||||||
|
* Серверный формат: `{envelope: <relay.Envelope JSON>}`. Nonce/ciphertext
|
||||||
|
* там — base64 (Go []byte), а у нас в crypto.ts — hex, так что на wire
|
||||||
|
* конвертим hex→bytes→base64.
|
||||||
|
*/
|
||||||
|
export async function sendEnvelope(params: {
|
||||||
|
senderPub: string; // X25519 hex
|
||||||
|
recipientPub: string; // X25519 hex
|
||||||
|
nonce: string; // hex
|
||||||
|
ciphertext: string; // hex
|
||||||
|
senderEd25519Pub?: string; // optional — для будущих fee-релеев
|
||||||
|
}): Promise<{ id: string; status: string }> {
|
||||||
|
const sentAt = Math.floor(Date.now() / 1000);
|
||||||
|
const nonceB64 = bytesToBase64(hexToBytes(params.nonce));
|
||||||
|
const ctB64 = bytesToBase64(hexToBytes(params.ciphertext));
|
||||||
|
|
||||||
|
// envelope.id — 16 байт, hex. Сервер только проверяет что поле не
|
||||||
|
// пустое и использует его как ключ mailbox'а. Первые 16 байт nonce
|
||||||
|
// уже криптографически-случайны (nacl.randomBytes), так что берём их.
|
||||||
|
const id = bytesToHex(hexToBytes(params.nonce).slice(0, 16));
|
||||||
|
|
||||||
|
return post<{ id: string; status: string }>('/relay/broadcast', {
|
||||||
|
envelope: {
|
||||||
|
id,
|
||||||
|
sender_pub: params.senderPub,
|
||||||
|
recipient_pub: params.recipientPub,
|
||||||
|
sender_ed25519_pub: params.senderEd25519Pub ?? '',
|
||||||
|
fee_ut: 0,
|
||||||
|
fee_sig: null,
|
||||||
|
nonce: nonceB64,
|
||||||
|
ciphertext: ctB64,
|
||||||
|
sent_at: sentAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch envelopes адресованные нам из relay-почтовика.
|
||||||
|
* Server: `GET /relay/inbox?pub=<x25519hex>` → `{pub, count, has_more, items}`.
|
||||||
|
* Нормализуем item'ы к clientскому Envelope type: sent_at → timestamp,
|
||||||
|
* base64 nonce/ciphertext → hex.
|
||||||
|
*/
|
||||||
|
export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
|
||||||
|
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519PubHex}`);
|
||||||
|
const items = Array.isArray(resp?.items) ? resp.items : [];
|
||||||
|
return items.map((it): Envelope => ({
|
||||||
|
id: it.id,
|
||||||
|
sender_pub: it.sender_pub,
|
||||||
|
recipient_pub: it.recipient_pub,
|
||||||
|
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
||||||
|
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
||||||
|
timestamp: it.sent_at ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Contact requests (on-chain) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps blockchain.ContactInfo returned by GET /relay/contacts?pub=...
|
||||||
|
* The response shape is { pub, count, contacts: ContactInfo[] }.
|
||||||
|
*/
|
||||||
|
export interface ContactRequestRaw {
|
||||||
|
requester_pub: string; // Ed25519 pubkey of requester
|
||||||
|
requester_addr: string; // DChain address (DC…)
|
||||||
|
status: string; // "pending" | "accepted" | "blocked"
|
||||||
|
intro: string; // plaintext intro message (may be empty)
|
||||||
|
fee_ut: number; // anti-spam fee paid in µT
|
||||||
|
tx_id: string; // transaction ID
|
||||||
|
created_at: number; // unix seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchContactRequests(edPubHex: string): Promise<ContactRequestRaw[]> {
|
||||||
|
const data = await get<{ contacts: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPubHex}`);
|
||||||
|
return data.contacts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Identity API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IdentityInfo {
|
||||||
|
pub_key: string;
|
||||||
|
address: string;
|
||||||
|
x25519_pub: string; // hex Curve25519 key; empty string if not published
|
||||||
|
nickname: string;
|
||||||
|
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. */
|
||||||
|
export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> {
|
||||||
|
try {
|
||||||
|
return await get<IdentityInfo>(`/api/identity/${pubkeyOrAddr}`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contract API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response shape from GET /api/contracts/{id}/state/{key}.
|
||||||
|
* The node handler (node/api_contract.go:handleContractState) returns either:
|
||||||
|
* { value_b64: null, value_hex: null, ... } when the key is missing
|
||||||
|
* or
|
||||||
|
* { value_b64: "...", value_hex: "...", value_u64?: 0 } when the key exists.
|
||||||
|
*/
|
||||||
|
interface ContractStateResponse {
|
||||||
|
contract_id: string;
|
||||||
|
key: string;
|
||||||
|
value_b64: string | null;
|
||||||
|
value_hex: string | null;
|
||||||
|
value_u64?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a hex string (lowercase/uppercase) back to the original string value
|
||||||
|
* it represents. The username registry contract stores values as plain ASCII
|
||||||
|
* bytes (pubkey hex strings / username strings), so `value_hex` on the wire
|
||||||
|
* is the hex-encoding of UTF-8 bytes. We hex-decode to bytes, then interpret
|
||||||
|
* those bytes as UTF-8.
|
||||||
|
*/
|
||||||
|
function hexToUtf8(hex: string): string {
|
||||||
|
if (hex.length % 2 !== 0) return '';
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||||
|
}
|
||||||
|
// TextDecoder is available in Hermes / RN's JS runtime.
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8').decode(bytes);
|
||||||
|
} catch {
|
||||||
|
// Fallback for environments without TextDecoder.
|
||||||
|
let s = '';
|
||||||
|
for (const b of bytes) s += String.fromCharCode(b);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** username → address (hex pubkey). Returns null if unregistered. */
|
||||||
|
export async function resolveUsername(contractId: string, username: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/name:${username}`);
|
||||||
|
if (!data.value_hex) return null;
|
||||||
|
const decoded = hexToUtf8(data.value_hex).trim();
|
||||||
|
return decoded || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** address (hex pubkey) → username. Returns null if this address hasn't registered a name. */
|
||||||
|
export async function reverseResolve(contractId: string, address: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/addr:${address}`);
|
||||||
|
if (!data.value_hex) return null;
|
||||||
|
const decoded = hexToUtf8(data.value_hex).trim();
|
||||||
|
return decoded || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Well-known contracts ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-entry shape returned by GET /api/well-known-contracts.
|
||||||
|
* Matches node/api_well_known.go:WellKnownContract.
|
||||||
|
*/
|
||||||
|
export interface WellKnownContract {
|
||||||
|
contract_id: string;
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
deployed_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from GET /api/well-known-contracts.
|
||||||
|
* `contracts` is keyed by ABI name (e.g. "username_registry").
|
||||||
|
*/
|
||||||
|
export interface WellKnownResponse {
|
||||||
|
count: number;
|
||||||
|
contracts: Record<string, WellKnownContract>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the node's view of canonical system contracts so the client doesn't
|
||||||
|
* have to force the user to paste contract IDs into settings.
|
||||||
|
*
|
||||||
|
* The node returns the earliest-deployed contract per ABI name; this means
|
||||||
|
* every peer in the same chain reports the same mapping.
|
||||||
|
*
|
||||||
|
* Returns `null` on failure (old node, network hiccup, endpoint missing).
|
||||||
|
*/
|
||||||
|
export async function fetchWellKnownContracts(): Promise<WellKnownResponse | null> {
|
||||||
|
try {
|
||||||
|
return await get<WellKnownResponse>('/api/well-known-contracts');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Node version / update-check ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The three calls below let the client:
|
||||||
|
// 1. fetchNodeVersion() — see what tag/commit/features the connected node
|
||||||
|
// exposes. Used on first boot + on every chain-switch so we can warn if
|
||||||
|
// a required feature is missing.
|
||||||
|
// 2. checkNodeVersion(required) — thin wrapper that returns {supported,
|
||||||
|
// missing} by diffing a client-expected feature list against the node's.
|
||||||
|
// 3. fetchUpdateCheck() — ask the node whether its operator has a newer
|
||||||
|
// release available from their configured release source (Gitea). For
|
||||||
|
// messenger UX this is purely informational ("the node you're on is N
|
||||||
|
// versions behind"), never used to update the node automatically.
|
||||||
|
|
||||||
|
/** The shape returned by GET /api/well-known-version. */
|
||||||
|
export interface NodeVersionInfo {
|
||||||
|
node_version: string;
|
||||||
|
protocol_version: number;
|
||||||
|
features: string[];
|
||||||
|
chain_id?: string;
|
||||||
|
build?: {
|
||||||
|
tag: string;
|
||||||
|
commit: string;
|
||||||
|
date: string;
|
||||||
|
dirty: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client-expected protocol version. Bumped only when wire-protocol breaks. */
|
||||||
|
export const CLIENT_PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum feature set this client build relies on. A node missing any of
|
||||||
|
* these is considered "unsupported" — caller should surface an upgrade
|
||||||
|
* prompt to the user instead of silently failing on the first feature call.
|
||||||
|
*/
|
||||||
|
export const CLIENT_REQUIRED_FEATURES = [
|
||||||
|
'chain_id',
|
||||||
|
'feed_v2', // social feed (v2.0.0) — PostCard, timeline, forYou
|
||||||
|
'identity_registry',
|
||||||
|
'media_scrub', // server-side EXIF strip — we rely on this for privacy
|
||||||
|
'onboarding_api',
|
||||||
|
'relay_broadcast', // /relay/broadcast for E2E envelopes (not /relay/send)
|
||||||
|
'relay_mailbox',
|
||||||
|
'ws_submit_tx',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** GET /api/well-known-version. Returns null on failure (old node, network hiccup). */
|
||||||
|
export async function fetchNodeVersion(): Promise<NodeVersionInfo | null> {
|
||||||
|
try {
|
||||||
|
return await get<NodeVersionInfo>('/api/well-known-version');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the connected node supports this client's required features
|
||||||
|
* and protocol version. Returns a decision blob the UI can render directly.
|
||||||
|
*
|
||||||
|
* { supported: true } → everything fine
|
||||||
|
* { supported: false, reason: "...", ... } → show update prompt
|
||||||
|
* { supported: null, reason: "unreachable" } → couldn't reach the endpoint,
|
||||||
|
* likely old node — assume OK
|
||||||
|
* but warn quietly.
|
||||||
|
*/
|
||||||
|
export async function checkNodeVersion(
|
||||||
|
required: string[] = CLIENT_REQUIRED_FEATURES,
|
||||||
|
): Promise<{
|
||||||
|
supported: boolean | null;
|
||||||
|
reason?: string;
|
||||||
|
missing?: string[];
|
||||||
|
info?: NodeVersionInfo;
|
||||||
|
}> {
|
||||||
|
const info = await fetchNodeVersion();
|
||||||
|
if (!info) {
|
||||||
|
return { supported: null, reason: 'unreachable' };
|
||||||
|
}
|
||||||
|
if (info.protocol_version !== CLIENT_PROTOCOL_VERSION) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
reason: `protocol v${info.protocol_version} but client expects v${CLIENT_PROTOCOL_VERSION}`,
|
||||||
|
info,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const have = new Set(info.features || []);
|
||||||
|
const missing = required.filter((f) => !have.has(f));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
reason: `node missing features: ${missing.join(', ')}`,
|
||||||
|
missing,
|
||||||
|
info,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { supported: true, info };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shape returned by GET /api/update-check. */
|
||||||
|
export interface UpdateCheckResponse {
|
||||||
|
current: { tag: string; commit: string; date: string; dirty: string };
|
||||||
|
latest?: { tag: string; commit?: string; url?: string; published_at?: string };
|
||||||
|
update_available: boolean;
|
||||||
|
checked_at: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/update-check. Returns null when:
|
||||||
|
* - the node operator hasn't configured DCHAIN_UPDATE_SOURCE_URL (503),
|
||||||
|
* - upstream Gitea call failed (502),
|
||||||
|
* - request errored out.
|
||||||
|
* All three are non-fatal for the client; the UI just doesn't render the
|
||||||
|
* "update available" banner.
|
||||||
|
*/
|
||||||
|
export async function fetchUpdateCheck(): Promise<UpdateCheckResponse | null> {
|
||||||
|
try {
|
||||||
|
return await get<UpdateCheckResponse>('/api/update-check');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transaction builder helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { signBase64 } from './crypto';
|
||||||
|
|
||||||
|
/** Minimum blockchain tx fee paid to the block validator (matches blockchain.MinFee = 1000 µT). */
|
||||||
|
const MIN_TX_FEE = 1000;
|
||||||
|
|
||||||
|
const _encoder = new TextEncoder();
|
||||||
|
|
||||||
|
/** RFC3339 timestamp with second precision — matches Go time.Time JSON output. */
|
||||||
|
function rfc3339Now(): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMilliseconds(0);
|
||||||
|
// toISOString() gives "2025-01-01T12:00:00.000Z" → replace ".000Z" with "Z"
|
||||||
|
return d.toISOString().replace('.000Z', 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unique transaction ID (nanoseconds-like using Date.now + random). */
|
||||||
|
function newTxID(): string {
|
||||||
|
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical bytes for signing — must match identity.txSignBytes in Go exactly.
|
||||||
|
*
|
||||||
|
* Go struct field order: id, type, from, to, amount, fee, payload, timestamp.
|
||||||
|
* JS JSON.stringify preserves insertion order, so we rely on that here.
|
||||||
|
*/
|
||||||
|
function txCanonicalBytes(tx: {
|
||||||
|
id: string; type: string; from: string; to: string;
|
||||||
|
amount: number; fee: number; payload: string; timestamp: string;
|
||||||
|
}): Uint8Array {
|
||||||
|
const s = JSON.stringify({
|
||||||
|
id: tx.id,
|
||||||
|
type: tx.type,
|
||||||
|
from: tx.from,
|
||||||
|
to: tx.to,
|
||||||
|
amount: tx.amount,
|
||||||
|
fee: tx.fee,
|
||||||
|
payload: tx.payload,
|
||||||
|
timestamp: tx.timestamp,
|
||||||
|
});
|
||||||
|
return _encoder.encode(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode a JS string (UTF-8) to base64. */
|
||||||
|
function strToBase64(s: string): string {
|
||||||
|
return bytesToBase64(_encoder.encode(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTransferTx(params: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
amount: number;
|
||||||
|
fee: number;
|
||||||
|
privKey: string;
|
||||||
|
memo?: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payloadObj = params.memo ? { memo: params.memo } : {};
|
||||||
|
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||||
|
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'TRANSFER', from: params.from, to: params.to,
|
||||||
|
amount: params.amount, fee: params.fee, payload, timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, type: 'TRANSFER', from: params.from, to: params.to,
|
||||||
|
amount: params.amount, fee: params.fee,
|
||||||
|
memo: params.memo,
|
||||||
|
payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CONTACT_REQUEST transaction.
|
||||||
|
*
|
||||||
|
* blockchain.Transaction fields:
|
||||||
|
* Amount = contactFee — anti-spam fee, paid directly to recipient (>= 5000 µT)
|
||||||
|
* Fee = MIN_TX_FEE — blockchain tx fee to the block validator (1000 µT)
|
||||||
|
* Payload = ContactRequestPayload { intro? } as base64 JSON bytes
|
||||||
|
*/
|
||||||
|
export function buildContactRequestTx(params: {
|
||||||
|
from: string; // sender Ed25519 pubkey
|
||||||
|
to: string; // recipient Ed25519 pubkey
|
||||||
|
contactFee: number; // anti-spam amount paid to recipient (>= 5000 µT)
|
||||||
|
intro?: string; // optional plaintext intro message (≤ 280 chars)
|
||||||
|
privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
// Payload matches ContactRequestPayload{Intro: "..."} in Go
|
||||||
|
const payloadObj = params.intro ? { intro: params.intro } : {};
|
||||||
|
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||||
|
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
|
||||||
|
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
|
||||||
|
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ACCEPT_CONTACT transaction.
|
||||||
|
* AcceptContactPayload is an empty struct in Go — no fields needed.
|
||||||
|
*/
|
||||||
|
export function buildAcceptContactTx(params: {
|
||||||
|
from: string; // acceptor Ed25519 pubkey (us — the recipient of the request)
|
||||||
|
to: string; // requester Ed25519 pubkey
|
||||||
|
privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify({})); // AcceptContactPayload{}
|
||||||
|
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contract call ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Minimum base fee for CALL_CONTRACT (matches blockchain.MinCallFee). */
|
||||||
|
const MIN_CALL_FEE = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CALL_CONTRACT transaction.
|
||||||
|
*
|
||||||
|
* Payload shape (CallContractPayload):
|
||||||
|
* { contract_id, method, args_json?, gas_limit }
|
||||||
|
*
|
||||||
|
* `amount` is the payment attached to the call and made available to the
|
||||||
|
* contract as `tx.Amount`. Whether it's collected depends on the contract
|
||||||
|
* — e.g. username_registry.register requires exactly 10_000 µT. Contracts
|
||||||
|
* that don't need payment should be called with `amount: 0` (default).
|
||||||
|
*
|
||||||
|
* The on-chain tx envelope carries `amount` openly, so the explorer shows
|
||||||
|
* the exact cost of a call rather than hiding it in a contract-internal
|
||||||
|
* debit — this was the UX motivation for this field.
|
||||||
|
*
|
||||||
|
* `fee` is the NETWORK fee paid to the block validator (not the contract).
|
||||||
|
* `gas` costs are additional and billed at the live gas price.
|
||||||
|
*/
|
||||||
|
export function buildCallContractTx(params: {
|
||||||
|
from: string;
|
||||||
|
contractId: string;
|
||||||
|
method: string;
|
||||||
|
args?: unknown[]; // JSON-serializable arguments
|
||||||
|
amount?: number; // µT attached to the call (default 0)
|
||||||
|
gasLimit?: number; // default 1_000_000
|
||||||
|
privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const amount = params.amount ?? 0;
|
||||||
|
|
||||||
|
const argsJson = params.args && params.args.length > 0
|
||||||
|
? JSON.stringify(params.args)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const payloadObj = {
|
||||||
|
contract_id: params.contractId,
|
||||||
|
method: params.method,
|
||||||
|
args_json: argsJson,
|
||||||
|
gas_limit: params.gasLimit ?? 1_000_000,
|
||||||
|
};
|
||||||
|
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||||
|
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'CALL_CONTRACT', from: params.from, to: '',
|
||||||
|
amount, fee: MIN_CALL_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, type: 'CALL_CONTRACT', from: params.from, to: '',
|
||||||
|
amount, fee: MIN_CALL_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat registration fee for a username, in µT.
|
||||||
|
*
|
||||||
|
* The native username_registry charges a single flat fee (10 000 µT = 0.01 T)
|
||||||
|
* per register() call regardless of name length, replacing the earlier
|
||||||
|
* length-based formula. Flat pricing is easier to communicate and the
|
||||||
|
* 4-char minimum (enforced both in the client UI and the on-chain contract)
|
||||||
|
* already removes the squatting pressure that tiered pricing mitigated.
|
||||||
|
*/
|
||||||
|
export const USERNAME_REGISTRATION_FEE = 10_000;
|
||||||
|
|
||||||
|
/** Minimum/maximum allowed username length. Match blockchain/native_username.go. */
|
||||||
|
export const MIN_USERNAME_LENGTH = 4;
|
||||||
|
export const MAX_USERNAME_LENGTH = 32;
|
||||||
|
|
||||||
|
/** @deprecated Kept for backward compatibility; always returns the flat fee. */
|
||||||
|
export function usernameRegistrationFee(_name: string): number {
|
||||||
|
return USERNAME_REGISTRATION_FEE;
|
||||||
|
}
|
||||||
168
client-app/lib/crypto.ts
Normal file
168
client-app/lib/crypto.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Cryptographic operations for DChain messenger.
|
||||||
|
*
|
||||||
|
* Ed25519 — transaction signing (via TweetNaCl sign)
|
||||||
|
* X25519 — Diffie-Hellman key exchange for NaCl box
|
||||||
|
* NaCl box — authenticated encryption for relay messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import nacl from 'tweetnacl';
|
||||||
|
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
|
||||||
|
import { getRandomBytes } from 'expo-crypto';
|
||||||
|
import type { KeyFile } from './types';
|
||||||
|
|
||||||
|
// ─── PRNG ─────────────────────────────────────────────────────────────────────
|
||||||
|
// TweetNaCl looks for window.crypto which doesn't exist in React Native/Hermes.
|
||||||
|
// Wire nacl to expo-crypto which uses the platform's secure RNG natively.
|
||||||
|
nacl.setPRNG((output: Uint8Array, length: number) => {
|
||||||
|
const bytes = getRandomBytes(length);
|
||||||
|
for (let i = 0; i < length; i++) output[i] = bytes[i];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function hexToBytes(hex: string): Uint8Array {
|
||||||
|
if (hex.length % 2 !== 0) throw new Error('odd hex length');
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Key generation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new identity: Ed25519 signing keys + X25519 encryption keys.
|
||||||
|
* Returns a KeyFile compatible with the Go node format.
|
||||||
|
*/
|
||||||
|
export function generateKeyFile(): KeyFile {
|
||||||
|
// Ed25519 for signing / blockchain identity
|
||||||
|
const signKP = nacl.sign.keyPair();
|
||||||
|
|
||||||
|
// X25519 for NaCl box encryption
|
||||||
|
// nacl.box.keyPair() returns Curve25519 keys
|
||||||
|
const boxKP = nacl.box.keyPair();
|
||||||
|
|
||||||
|
return {
|
||||||
|
pub_key: bytesToHex(signKP.publicKey),
|
||||||
|
priv_key: bytesToHex(signKP.secretKey),
|
||||||
|
x25519_pub: bytesToHex(boxKP.publicKey),
|
||||||
|
x25519_priv: bytesToHex(boxKP.secretKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NaCl box encryption ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a plaintext message using NaCl box.
|
||||||
|
* Sender uses their X25519 secret key + recipient's X25519 public key.
|
||||||
|
* Returns { nonce, ciphertext } as hex strings.
|
||||||
|
*/
|
||||||
|
export function encryptMessage(
|
||||||
|
plaintext: string,
|
||||||
|
senderSecretHex: string,
|
||||||
|
recipientPubHex: string,
|
||||||
|
): { nonce: string; ciphertext: string } {
|
||||||
|
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||||
|
const message = decodeUTF8(plaintext);
|
||||||
|
const secretKey = hexToBytes(senderSecretHex);
|
||||||
|
const publicKey = hexToBytes(recipientPubHex);
|
||||||
|
|
||||||
|
const box = nacl.box(message, nonce, publicKey, secretKey);
|
||||||
|
return {
|
||||||
|
nonce: bytesToHex(nonce),
|
||||||
|
ciphertext: bytesToHex(box),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a NaCl box.
|
||||||
|
* Recipient uses their X25519 secret key + sender's X25519 public key.
|
||||||
|
*/
|
||||||
|
export function decryptMessage(
|
||||||
|
ciphertextHex: string,
|
||||||
|
nonceHex: string,
|
||||||
|
senderPubHex: string,
|
||||||
|
recipientSecHex: string,
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
const ciphertext = hexToBytes(ciphertextHex);
|
||||||
|
const nonce = hexToBytes(nonceHex);
|
||||||
|
const senderPub = hexToBytes(senderPubHex);
|
||||||
|
const secretKey = hexToBytes(recipientSecHex);
|
||||||
|
|
||||||
|
const plain = nacl.box.open(ciphertext, nonce, senderPub, secretKey);
|
||||||
|
if (!plain) return null;
|
||||||
|
return encodeUTF8(plain);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ed25519 signing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign arbitrary data with the Ed25519 private key.
|
||||||
|
* Returns signature as hex.
|
||||||
|
*/
|
||||||
|
export function sign(data: Uint8Array, privKeyHex: string): string {
|
||||||
|
const secretKey = hexToBytes(privKeyHex);
|
||||||
|
const sig = nacl.sign.detached(data, secretKey);
|
||||||
|
return bytesToHex(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign arbitrary data with the Ed25519 private key.
|
||||||
|
* Returns signature as base64 — this is the format the Go blockchain node
|
||||||
|
* expects ([]byte fields are base64 in JSON).
|
||||||
|
*/
|
||||||
|
export function signBase64(data: Uint8Array, privKeyHex: string): string {
|
||||||
|
const secretKey = hexToBytes(privKeyHex);
|
||||||
|
const sig = nacl.sign.detached(data, secretKey);
|
||||||
|
return bytesToBase64(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode bytes as base64. Works on Hermes (btoa is available since RN 0.71). */
|
||||||
|
export function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode base64 → bytes. Accepts both standard and URL-safe variants (the
|
||||||
|
* node's /relay/inbox returns `[]byte` fields marshalled via Go's default
|
||||||
|
* json.Marshal which uses standard base64).
|
||||||
|
*/
|
||||||
|
export function base64ToBytes(b64: string): Uint8Array {
|
||||||
|
const binary = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
const out = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an Ed25519 signature.
|
||||||
|
*/
|
||||||
|
export function verify(data: Uint8Array, sigHex: string, pubKeyHex: string): boolean {
|
||||||
|
try {
|
||||||
|
return nacl.sign.detached.verify(data, hexToBytes(sigHex), hexToBytes(pubKeyHex));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Address helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Truncate a long hex address for display: 8...8 */
|
||||||
|
export function shortAddr(hex: string, chars = 8): string {
|
||||||
|
if (hex.length <= chars * 2 + 3) return hex;
|
||||||
|
return `${hex.slice(0, chars)}…${hex.slice(-chars)}`;
|
||||||
|
}
|
||||||
67
client-app/lib/dates.ts
Normal file
67
client-app/lib/dates.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Date / time форматирование для UI мессенджера.
|
||||||
|
*
|
||||||
|
* Все функции принимают **unix-seconds** (совместимо с `Message.timestamp`,
|
||||||
|
* который тоже в секундах). Для ms-таймштампов делаем нормализацию внутри.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// English short month names ("Jun 17, 2025").
|
||||||
|
const MONTHS_SHORT = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
|
];
|
||||||
|
|
||||||
|
function sameDay(a: Date, b: Date): boolean {
|
||||||
|
return (
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-bucket label for chat separators.
|
||||||
|
* "Today" / "Yesterday" / "Jun 17, 2025"
|
||||||
|
*
|
||||||
|
* @param ts unix-seconds
|
||||||
|
*/
|
||||||
|
export function dateBucket(ts: number): string {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const yday = new Date(); yday.setDate(now.getDate() - 1);
|
||||||
|
if (sameDay(d, now)) return 'Today';
|
||||||
|
if (sameDay(d, yday)) return 'Yesterday';
|
||||||
|
return `${MONTHS_SHORT[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Короткое relative-time под углом bubble ("29m", "14m", "3h", "2d", "12:40").
|
||||||
|
*
|
||||||
|
* @param ts unix-seconds
|
||||||
|
*/
|
||||||
|
export function relTime(ts: number): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - ts * 1000;
|
||||||
|
if (diff < 60_000) return 'now';
|
||||||
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
|
||||||
|
if (diff < 24 * 3_600_000) return `${Math.floor(diff / 3_600_000)}h`;
|
||||||
|
if (diff < 7 * 24 * 3_600_000) return `${Math.floor(diff / (24 * 3_600_000))}d`;
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Похоже на relTime, но принимает как unix-seconds, так и unix-ms.
|
||||||
|
* Используется в chat-list tiles (там timestamp бывает в ms от addedAt).
|
||||||
|
*/
|
||||||
|
export function formatWhen(ts: number): string {
|
||||||
|
// Heuristic: > 1e12 → уже в ms, иначе seconds.
|
||||||
|
const sec = ts > 1e12 ? Math.floor(ts / 1000) : ts;
|
||||||
|
return relTime(sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "HH:MM" — одна и та же локаль, без дня. */
|
||||||
|
export function formatHM(ts: number): string {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
498
client-app/lib/feed.ts
Normal file
498
client-app/lib/feed.ts
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
/**
|
||||||
|
* Feed client — HTTP wrappers + tx builders for the v2.0.0 social feed.
|
||||||
|
*
|
||||||
|
* Flow for publishing a post:
|
||||||
|
* 1. Build the post body (text + optional pre-compressed attachment).
|
||||||
|
* 2. Sign "publish:<post_id>:<sha256(body)>:<ts>" with the author's
|
||||||
|
* Ed25519 key.
|
||||||
|
* 3. POST /feed/publish — server verifies sig, scrubs metadata from
|
||||||
|
* any image attachment, stores the body, returns
|
||||||
|
* { post_id, content_hash, size, estimated_fee_ut, hashtags }.
|
||||||
|
* 4. Submit CREATE_POST tx on-chain with THE RETURNED content_hash +
|
||||||
|
* size + hosting_relay. Fee = server's estimate (server credits
|
||||||
|
* the full amount to the hosting relay).
|
||||||
|
*
|
||||||
|
* For reads we hit /feed/timeline, /feed/foryou, /feed/trending,
|
||||||
|
* /feed/author/{pub}, /feed/hashtag/{tag}. All return a list of
|
||||||
|
* FeedPostItem (chain metadata enriched with body + stats).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getNodeUrl, submitTx, type RawTx } from './api';
|
||||||
|
import {
|
||||||
|
bytesToBase64, bytesToHex, hexToBytes, signBase64, sign as signHex,
|
||||||
|
} from './crypto';
|
||||||
|
|
||||||
|
// ─── Types (mirrors node/api_feed.go shapes) ──────────────────────────────
|
||||||
|
|
||||||
|
/** Single post as returned by /feed/author, /feed/timeline, etc. */
|
||||||
|
export interface FeedPostItem {
|
||||||
|
post_id: string;
|
||||||
|
author: string; // hex Ed25519
|
||||||
|
content: string;
|
||||||
|
content_type?: string; // "text/plain" | "text/markdown"
|
||||||
|
hashtags?: string[];
|
||||||
|
reply_to?: string;
|
||||||
|
quote_of?: string;
|
||||||
|
created_at: number; // unix seconds
|
||||||
|
size: number;
|
||||||
|
hosting_relay: string;
|
||||||
|
views: number;
|
||||||
|
likes: number;
|
||||||
|
has_attachment: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostStats {
|
||||||
|
post_id: string;
|
||||||
|
views: number;
|
||||||
|
likes: number;
|
||||||
|
liked_by_me?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishResponse {
|
||||||
|
post_id: string;
|
||||||
|
hosting_relay: string;
|
||||||
|
content_hash: string; // hex sha256 over scrubbed bytes
|
||||||
|
size: number;
|
||||||
|
hashtags: string[];
|
||||||
|
estimated_fee_ut: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineResponse {
|
||||||
|
count: number;
|
||||||
|
posts: FeedPostItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTTP helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getJSON<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${getNodeUrl()}${path}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`GET ${path} → ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${getNodeUrl()}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
let detail = text;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (parsed?.error) detail = parsed.error;
|
||||||
|
} catch { /* keep raw */ }
|
||||||
|
throw new Error(`POST ${path} → ${res.status}: ${detail}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Publish flow ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a post_id from author + nanoseconds-ish entropy + content prefix.
|
||||||
|
* Must match the server-side hash trick (`sha256(author-nanos-content)[:16]`).
|
||||||
|
* Details don't matter — server only checks the id is non-empty. We just
|
||||||
|
* need uniqueness across the author's own posts.
|
||||||
|
*/
|
||||||
|
async function computePostID(author: string, content: string): Promise<string> {
|
||||||
|
const { digestStringAsync, CryptoDigestAlgorithm, CryptoEncoding } =
|
||||||
|
await import('expo-crypto');
|
||||||
|
const seed = `${author}-${Date.now()}${Math.floor(Math.random() * 1e9)}-${content.slice(0, 64)}`;
|
||||||
|
const hex = await digestStringAsync(
|
||||||
|
CryptoDigestAlgorithm.SHA256,
|
||||||
|
seed,
|
||||||
|
{ encoding: CryptoEncoding.HEX },
|
||||||
|
);
|
||||||
|
return hex.slice(0, 32); // 16 bytes = 32 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sha256 of a UTF-8 string (optionally with appended bytes for attachments).
|
||||||
|
* Returns hex. Uses expo-crypto for native speed.
|
||||||
|
*/
|
||||||
|
async function sha256Hex(content: string, attachment?: Uint8Array): Promise<string> {
|
||||||
|
const { digest, CryptoDigestAlgorithm } = await import('expo-crypto');
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const contentBytes = encoder.encode(content);
|
||||||
|
const combined = new Uint8Array(
|
||||||
|
contentBytes.length + (attachment ? attachment.length : 0),
|
||||||
|
);
|
||||||
|
combined.set(contentBytes, 0);
|
||||||
|
if (attachment) combined.set(attachment, contentBytes.length);
|
||||||
|
|
||||||
|
const buf = await digest(CryptoDigestAlgorithm.SHA256, combined);
|
||||||
|
// ArrayBuffer → hex
|
||||||
|
const view = new Uint8Array(buf);
|
||||||
|
return bytesToHex(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishParams {
|
||||||
|
author: string; // hex Ed25519 pubkey
|
||||||
|
privKey: string; // hex Ed25519 privkey
|
||||||
|
content: string;
|
||||||
|
attachment?: Uint8Array;
|
||||||
|
attachmentMIME?: string; // "image/jpeg" | "image/png" | "video/mp4" | ...
|
||||||
|
replyTo?: string;
|
||||||
|
quoteOf?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a post: POSTs /feed/publish with a signed body. Returns the
|
||||||
|
* server's response so the caller can submit a matching CREATE_POST tx.
|
||||||
|
*
|
||||||
|
* Client is expected to have compressed the attachment FIRST (see
|
||||||
|
* `lib/media.ts`) — this function does not re-compress, only signs and
|
||||||
|
* uploads. Server will scrub metadata regardless.
|
||||||
|
*/
|
||||||
|
export async function publishPost(p: PublishParams): Promise<PublishResponse> {
|
||||||
|
const postID = await computePostID(p.author, p.content);
|
||||||
|
const clientHash = await sha256Hex(p.content, p.attachment);
|
||||||
|
const ts = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Sign: "publish:<post_id>:<raw_content_hash>:<ts>"
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const msg = encoder.encode(`publish:${postID}:${clientHash}:${ts}`);
|
||||||
|
const sig = signBase64(msg, p.privKey);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
post_id: postID,
|
||||||
|
author: p.author,
|
||||||
|
content: p.content,
|
||||||
|
content_type: 'text/plain',
|
||||||
|
attachment_b64: p.attachment ? bytesToBase64(p.attachment) : undefined,
|
||||||
|
attachment_mime: p.attachmentMIME ?? undefined,
|
||||||
|
reply_to: p.replyTo,
|
||||||
|
quote_of: p.quoteOf,
|
||||||
|
sig,
|
||||||
|
ts,
|
||||||
|
};
|
||||||
|
|
||||||
|
return postJSON<PublishResponse>('/feed/publish', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full publish flow: POST /feed/publish → on-chain CREATE_POST tx.
|
||||||
|
* Returns the final post_id (same as server response; echoed for UX).
|
||||||
|
*/
|
||||||
|
export async function publishAndCommit(p: PublishParams): Promise<string> {
|
||||||
|
const pub = await publishPost(p);
|
||||||
|
const tx = buildCreatePostTx({
|
||||||
|
from: p.author,
|
||||||
|
privKey: p.privKey,
|
||||||
|
postID: pub.post_id,
|
||||||
|
contentHash: pub.content_hash,
|
||||||
|
size: pub.size,
|
||||||
|
hostingRelay: pub.hosting_relay,
|
||||||
|
fee: pub.estimated_fee_ut,
|
||||||
|
replyTo: p.replyTo,
|
||||||
|
quoteOf: p.quoteOf,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
return pub.post_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Read endpoints ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchPost(postID: string): Promise<FeedPostItem | null> {
|
||||||
|
try {
|
||||||
|
return await getJSON<FeedPostItem>(`/feed/post/${postID}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (/→\s*404\b/.test(String(e?.message))) return null;
|
||||||
|
if (/→\s*410\b/.test(String(e?.message))) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStats(postID: string, me?: string): Promise<PostStats | null> {
|
||||||
|
try {
|
||||||
|
const qs = me ? `?me=${me}` : '';
|
||||||
|
return await getJSON<PostStats>(`/feed/post/${postID}/stats${qs}`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the off-chain view counter. Fire-and-forget — failures here
|
||||||
|
* are not fatal to the UX, so we swallow errors.
|
||||||
|
*/
|
||||||
|
export async function bumpView(postID: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await postJSON<unknown>(`/feed/post/${postID}/view`, undefined);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthorPosts(
|
||||||
|
pub: string, opts: { limit?: number; before?: number } = {},
|
||||||
|
): Promise<FeedPostItem[]> {
|
||||||
|
const limit = opts.limit ?? 30;
|
||||||
|
const qs = opts.before
|
||||||
|
? `?limit=${limit}&before=${opts.before}`
|
||||||
|
: `?limit=${limit}`;
|
||||||
|
const resp = await getJSON<TimelineResponse>(`/feed/author/${pub}${qs}`);
|
||||||
|
return resp.posts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTimeline(
|
||||||
|
followerPub: string, opts: { limit?: number; before?: number } = {},
|
||||||
|
): Promise<FeedPostItem[]> {
|
||||||
|
const limit = opts.limit ?? 30;
|
||||||
|
let qs = `?follower=${followerPub}&limit=${limit}`;
|
||||||
|
if (opts.before) qs += `&before=${opts.before}`;
|
||||||
|
const resp = await getJSON<TimelineResponse>(`/feed/timeline${qs}`);
|
||||||
|
return resp.posts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchForYou(pub: string, limit = 30): Promise<FeedPostItem[]> {
|
||||||
|
const resp = await getJSON<TimelineResponse>(`/feed/foryou?pub=${pub}&limit=${limit}`);
|
||||||
|
return resp.posts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTrending(windowHours = 24, limit = 30): Promise<FeedPostItem[]> {
|
||||||
|
const resp = await getJSON<TimelineResponse>(`/feed/trending?window=${windowHours}&limit=${limit}`);
|
||||||
|
return resp.posts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHashtag(tag: string, limit = 30): Promise<FeedPostItem[]> {
|
||||||
|
const cleanTag = tag.replace(/^#/, '');
|
||||||
|
const resp = await getJSON<TimelineResponse>(`/feed/hashtag/${encodeURIComponent(cleanTag)}?limit=${limit}`);
|
||||||
|
return resp.posts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transaction builders ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// These match the blockchain.Event* payloads 1-to-1 and produce already-
|
||||||
|
// signed RawTx objects ready for submitTx.
|
||||||
|
|
||||||
|
/** RFC3339 second-precision timestamp — matches Go time.Time default JSON. */
|
||||||
|
function rfc3339Now(): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMilliseconds(0);
|
||||||
|
return d.toISOString().replace('.000Z', 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
function newTxID(): string {
|
||||||
|
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _encoder = new TextEncoder();
|
||||||
|
|
||||||
|
function txCanonicalBytes(tx: {
|
||||||
|
id: string; type: string; from: string; to: string;
|
||||||
|
amount: number; fee: number; payload: string; timestamp: string;
|
||||||
|
}): Uint8Array {
|
||||||
|
return _encoder.encode(JSON.stringify({
|
||||||
|
id: tx.id,
|
||||||
|
type: tx.type,
|
||||||
|
from: tx.from,
|
||||||
|
to: tx.to,
|
||||||
|
amount: tx.amount,
|
||||||
|
fee: tx.fee,
|
||||||
|
payload: tx.payload,
|
||||||
|
timestamp: tx.timestamp,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function strToBase64(s: string): string {
|
||||||
|
return bytesToBase64(_encoder.encode(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CREATE_POST tx. content_hash is the HEX sha256 returned by /feed/publish;
|
||||||
|
* must be converted to base64 bytes for the on-chain payload (Go []byte →
|
||||||
|
* base64 in JSON).
|
||||||
|
*/
|
||||||
|
export function buildCreatePostTx(params: {
|
||||||
|
from: string;
|
||||||
|
privKey: string;
|
||||||
|
postID: string;
|
||||||
|
contentHash: string; // hex from server
|
||||||
|
size: number;
|
||||||
|
hostingRelay: string;
|
||||||
|
fee: number;
|
||||||
|
replyTo?: string;
|
||||||
|
quoteOf?: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payloadObj = {
|
||||||
|
post_id: params.postID,
|
||||||
|
content_hash: bytesToBase64(hexToBytes(params.contentHash)),
|
||||||
|
size: params.size,
|
||||||
|
hosting_relay: params.hostingRelay,
|
||||||
|
reply_to: params.replyTo ?? '',
|
||||||
|
quote_of: params.quoteOf ?? '',
|
||||||
|
};
|
||||||
|
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'CREATE_POST', from: params.from, to: '',
|
||||||
|
amount: 0, fee: params.fee, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'CREATE_POST', from: params.from, to: '',
|
||||||
|
amount: 0, fee: params.fee, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeletePostTx(params: {
|
||||||
|
from: string;
|
||||||
|
privKey: string;
|
||||||
|
postID: string;
|
||||||
|
fee?: number;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
|
||||||
|
const fee = params.fee ?? 1000;
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'DELETE_POST', from: params.from, to: '',
|
||||||
|
amount: 0, fee, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'DELETE_POST', from: params.from, to: '',
|
||||||
|
amount: 0, fee, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFollowTx(params: {
|
||||||
|
from: string;
|
||||||
|
privKey: string;
|
||||||
|
target: string;
|
||||||
|
fee?: number;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64('{}');
|
||||||
|
const fee = params.fee ?? 1000;
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'FOLLOW', from: params.from, to: params.target,
|
||||||
|
amount: 0, fee, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'FOLLOW', from: params.from, to: params.target,
|
||||||
|
amount: 0, fee, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUnfollowTx(params: {
|
||||||
|
from: string;
|
||||||
|
privKey: string;
|
||||||
|
target: string;
|
||||||
|
fee?: number;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64('{}');
|
||||||
|
const fee = params.fee ?? 1000;
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'UNFOLLOW', from: params.from, to: params.target,
|
||||||
|
amount: 0, fee, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'UNFOLLOW', from: params.from, to: params.target,
|
||||||
|
amount: 0, fee, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLikePostTx(params: {
|
||||||
|
from: string;
|
||||||
|
privKey: string;
|
||||||
|
postID: string;
|
||||||
|
fee?: number;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
|
||||||
|
const fee = params.fee ?? 1000;
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'LIKE_POST', from: params.from, to: '',
|
||||||
|
amount: 0, fee, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'LIKE_POST', from: params.from, to: '',
|
||||||
|
amount: 0, fee, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUnlikePostTx(params: {
|
||||||
|
from: string;
|
||||||
|
privKey: string;
|
||||||
|
postID: string;
|
||||||
|
fee?: number;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
|
||||||
|
const fee = params.fee ?? 1000;
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'UNLIKE_POST', from: params.from, to: '',
|
||||||
|
amount: 0, fee, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'UNLIKE_POST', from: params.from, to: '',
|
||||||
|
amount: 0, fee, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── High-level helpers (combine build + submit) ─────────────────────────
|
||||||
|
|
||||||
|
export async function likePost(params: { from: string; privKey: string; postID: string }) {
|
||||||
|
await submitTx(buildLikePostTx(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlikePost(params: { from: string; privKey: string; postID: string }) {
|
||||||
|
await submitTx(buildUnlikePostTx(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function followUser(params: { from: string; privKey: string; target: string }) {
|
||||||
|
await submitTx(buildFollowTx(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unfollowUser(params: { from: string; privKey: string; target: string }) {
|
||||||
|
await submitTx(buildUnfollowTx(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePost(params: { from: string; privKey: string; postID: string }) {
|
||||||
|
await submitTx(buildDeletePostTx(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatting helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Convert µT to display token amount (0.xxx T). */
|
||||||
|
export function formatFee(feeUT: number): string {
|
||||||
|
const t = feeUT / 1_000_000;
|
||||||
|
if (t < 0.001) return `${feeUT} µT`;
|
||||||
|
return `${t.toFixed(4)} T`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relative time formatter, Twitter-like ("5m", "3h", "Dec 15"). */
|
||||||
|
export function formatRelativeTime(unixSeconds: number): string {
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const delta = now - unixSeconds;
|
||||||
|
if (delta < 60) return 'just now';
|
||||||
|
if (delta < 3600) return `${Math.floor(delta / 60)}m`;
|
||||||
|
if (delta < 86400) return `${Math.floor(delta / 3600)}h`;
|
||||||
|
if (delta < 7 * 86400) return `${Math.floor(delta / 86400)}d`;
|
||||||
|
const d = new Date(unixSeconds * 1000);
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact count formatter ("1.2K", "3.4M"). */
|
||||||
|
export function formatCount(n: number): string {
|
||||||
|
if (n < 1000) return String(n);
|
||||||
|
if (n < 1_000_000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}K`;
|
||||||
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent unused-import lint when nothing else touches signHex.
|
||||||
|
export const _feedSignRaw = signHex;
|
||||||
145
client-app/lib/forwardPost.ts
Normal file
145
client-app/lib/forwardPost.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Forward a feed post into a direct chat as a "post reference" message.
|
||||||
|
*
|
||||||
|
* What the receiver sees
|
||||||
|
* ----------------------
|
||||||
|
* A special chat bubble rendering a compact card:
|
||||||
|
* [avatar] @author "post excerpt…" [📷 if has image]
|
||||||
|
*
|
||||||
|
* Tapping the card opens the full post detail. Design is modelled on
|
||||||
|
* VK/Twitter's "shared post" embed: visually distinct from a plain
|
||||||
|
* message so the user sees at a glance that this came from the feed,
|
||||||
|
* not from the sender directly.
|
||||||
|
*
|
||||||
|
* Transport
|
||||||
|
* ---------
|
||||||
|
* Same encrypted envelope as a normal chat message. The payload is
|
||||||
|
* plaintext JSON with a discriminator:
|
||||||
|
*
|
||||||
|
* { "kind": "post_ref", "post_id": "...", "author": "...",
|
||||||
|
* "excerpt": "first 120 chars of body", "has_image": true }
|
||||||
|
*
|
||||||
|
* The receiver's useMessages / useGlobalInbox hooks detect the JSON
|
||||||
|
* shape after decryption and assign it to Message.postRef for rendering.
|
||||||
|
* Plain-text messages stay wrapped in the same envelope format — the
|
||||||
|
* only difference is whether the decrypted body parses as our JSON
|
||||||
|
* schema.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { encryptMessage } from './crypto';
|
||||||
|
import { sendEnvelope } from './api';
|
||||||
|
import { appendMessage } from './storage';
|
||||||
|
import { useStore } from './store';
|
||||||
|
import { randomId } from './utils';
|
||||||
|
import type { Contact, Message } from './types';
|
||||||
|
import type { FeedPostItem } from './feed';
|
||||||
|
|
||||||
|
const POST_REF_MARKER = 'dchain-post-ref';
|
||||||
|
const EXCERPT_MAX = 120;
|
||||||
|
|
||||||
|
export interface PostRefPayload {
|
||||||
|
kind: typeof POST_REF_MARKER;
|
||||||
|
post_id: string;
|
||||||
|
author: string;
|
||||||
|
excerpt: string;
|
||||||
|
has_image: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialise a post ref for the wire. */
|
||||||
|
export function encodePostRef(post: FeedPostItem): string {
|
||||||
|
const payload: PostRefPayload = {
|
||||||
|
kind: POST_REF_MARKER,
|
||||||
|
post_id: post.post_id,
|
||||||
|
author: post.author,
|
||||||
|
excerpt: truncate(post.content, EXCERPT_MAX),
|
||||||
|
has_image: !!post.has_attachment,
|
||||||
|
};
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to parse an incoming plaintext message as a post reference.
|
||||||
|
* Returns null if the payload isn't the expected shape — the caller
|
||||||
|
* then treats it as a normal text message.
|
||||||
|
*/
|
||||||
|
export function tryParsePostRef(plaintext: string): PostRefPayload | null {
|
||||||
|
const trimmed = plaintext.trim();
|
||||||
|
if (!trimmed.startsWith('{')) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as Partial<PostRefPayload>;
|
||||||
|
if (parsed.kind !== POST_REF_MARKER) return null;
|
||||||
|
if (!parsed.post_id || !parsed.author) return null;
|
||||||
|
return {
|
||||||
|
kind: POST_REF_MARKER,
|
||||||
|
post_id: String(parsed.post_id),
|
||||||
|
author: String(parsed.author),
|
||||||
|
excerpt: String(parsed.excerpt ?? ''),
|
||||||
|
has_image: !!parsed.has_image,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward `post` to each of the given contacts as a post-ref message.
|
||||||
|
* Creates a fresh envelope per recipient (can't fan-out a single
|
||||||
|
* ciphertext — each recipient's x25519 key seals differently) and
|
||||||
|
* drops a mirrored Message into our local chat history so the user
|
||||||
|
* sees the share in their own view too.
|
||||||
|
*
|
||||||
|
* Contacts without an x25519 public key are skipped with a warning
|
||||||
|
* instead of failing the whole batch.
|
||||||
|
*/
|
||||||
|
export async function forwardPostToContacts(params: {
|
||||||
|
post: FeedPostItem;
|
||||||
|
contacts: Contact[];
|
||||||
|
keyFile: { pub_key: string; priv_key: string; x25519_pub: string; x25519_priv: string };
|
||||||
|
}): Promise<{ ok: number; failed: number }> {
|
||||||
|
const { post, contacts, keyFile } = params;
|
||||||
|
const appendMsg = useStore.getState().appendMessage;
|
||||||
|
const body = encodePostRef(post);
|
||||||
|
|
||||||
|
let ok = 0, failed = 0;
|
||||||
|
for (const c of contacts) {
|
||||||
|
if (!c.x25519Pub) { failed++; continue; }
|
||||||
|
try {
|
||||||
|
const { nonce, ciphertext } = encryptMessage(
|
||||||
|
body, keyFile.x25519_priv, c.x25519Pub,
|
||||||
|
);
|
||||||
|
await sendEnvelope({
|
||||||
|
senderPub: keyFile.x25519_pub,
|
||||||
|
recipientPub: c.x25519Pub,
|
||||||
|
senderEd25519Pub: keyFile.pub_key,
|
||||||
|
nonce, ciphertext,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirror into local history so the sender sees "you shared this".
|
||||||
|
const mirrored: Message = {
|
||||||
|
id: randomId(),
|
||||||
|
from: keyFile.x25519_pub,
|
||||||
|
text: '', // postRef carries all the content
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
mine: true,
|
||||||
|
postRef: {
|
||||||
|
postID: post.post_id,
|
||||||
|
author: post.author,
|
||||||
|
excerpt: truncate(post.content, EXCERPT_MAX),
|
||||||
|
hasImage: !!post.has_attachment,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
appendMsg(c.address, mirrored);
|
||||||
|
await appendMessage(c.address, mirrored);
|
||||||
|
ok++;
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, n: number): string {
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.length <= n) return s;
|
||||||
|
return s.slice(0, n).trimEnd() + '…';
|
||||||
|
}
|
||||||
101
client-app/lib/storage.ts
Normal file
101
client-app/lib/storage.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Persistent storage for keys and app settings.
|
||||||
|
* On mobile: expo-secure-store for key material, AsyncStorage for settings.
|
||||||
|
* On web: falls back to localStorage (dev only).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import type { KeyFile, Contact, NodeSettings } from './types';
|
||||||
|
|
||||||
|
// ─── Keys ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const KEYFILE_KEY = 'dchain_keyfile';
|
||||||
|
const CONTACTS_KEY = 'dchain_contacts';
|
||||||
|
const SETTINGS_KEY = 'dchain_settings';
|
||||||
|
const CHATS_KEY = 'dchain_chats';
|
||||||
|
|
||||||
|
/** Save the key file in secure storage (encrypted on device). */
|
||||||
|
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(KEYFILE_KEY, JSON.stringify(kf));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load key file. Returns null if not set. */
|
||||||
|
export async function loadKeyFile(): Promise<KeyFile | null> {
|
||||||
|
const raw = await SecureStore.getItemAsync(KEYFILE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw) as KeyFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete key file (logout / factory reset). */
|
||||||
|
export async function deleteKeyFile(): Promise<void> {
|
||||||
|
await SecureStore.deleteItemAsync(KEYFILE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Node settings ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: NodeSettings = {
|
||||||
|
nodeUrl: 'http://localhost:8081',
|
||||||
|
contractId: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadSettings(): Promise<NodeSettings> {
|
||||||
|
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
|
||||||
|
if (!raw) return DEFAULT_SETTINGS;
|
||||||
|
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings(s: Partial<NodeSettings>): Promise<void> {
|
||||||
|
const current = await loadSettings();
|
||||||
|
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...current, ...s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contacts ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function loadContacts(): Promise<Contact[]> {
|
||||||
|
const raw = await AsyncStorage.getItem(CONTACTS_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as Contact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveContact(c: Contact): Promise<void> {
|
||||||
|
const contacts = await loadContacts();
|
||||||
|
const idx = contacts.findIndex(x => x.address === c.address);
|
||||||
|
if (idx >= 0) contacts[idx] = c;
|
||||||
|
else contacts.push(c);
|
||||||
|
await AsyncStorage.setItem(CONTACTS_KEY, JSON.stringify(contacts));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteContact(address: string): Promise<void> {
|
||||||
|
const contacts = await loadContacts();
|
||||||
|
await AsyncStorage.setItem(
|
||||||
|
CONTACTS_KEY,
|
||||||
|
JSON.stringify(contacts.filter(c => c.address !== address)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Message cache (per-chat local store) ────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CachedMessage {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: number;
|
||||||
|
mine: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMessages(chatId: string): Promise<CachedMessage[]> {
|
||||||
|
const raw = await AsyncStorage.getItem(`${CHATS_KEY}_${chatId}`);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as CachedMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendMessage(chatId: string, msg: CachedMessage): Promise<void> {
|
||||||
|
const msgs = await loadMessages(chatId);
|
||||||
|
// Deduplicate by id
|
||||||
|
if (msgs.find(m => m.id === msg.id)) return;
|
||||||
|
msgs.push(msg);
|
||||||
|
// Keep last 500 messages per chat
|
||||||
|
const trimmed = msgs.slice(-500);
|
||||||
|
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
|
||||||
|
}
|
||||||
128
client-app/lib/store.ts
Normal file
128
client-app/lib/store.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Global app state via Zustand.
|
||||||
|
* Keeps runtime state; persistent data lives in storage.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { KeyFile, Contact, Chat, Message, ContactRequest, NodeSettings } from './types';
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
// Identity
|
||||||
|
keyFile: KeyFile | null;
|
||||||
|
username: string | null;
|
||||||
|
setKeyFile: (kf: KeyFile | null) => void;
|
||||||
|
setUsername: (u: string | null) => void;
|
||||||
|
|
||||||
|
// Node settings
|
||||||
|
settings: NodeSettings;
|
||||||
|
setSettings: (s: Partial<NodeSettings>) => void;
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
contacts: Contact[];
|
||||||
|
setContacts: (contacts: Contact[]) => void;
|
||||||
|
upsertContact: (c: Contact) => void;
|
||||||
|
|
||||||
|
// Chats (derived from contacts + messages)
|
||||||
|
chats: Chat[];
|
||||||
|
setChats: (chats: Chat[]) => void;
|
||||||
|
|
||||||
|
// Active chat messages
|
||||||
|
messages: Record<string, Message[]>; // key: contactAddress
|
||||||
|
setMessages: (chatId: string, msgs: Message[]) => void;
|
||||||
|
appendMessage: (chatId: string, msg: Message) => void;
|
||||||
|
|
||||||
|
// Contact requests (pending)
|
||||||
|
requests: ContactRequest[];
|
||||||
|
setRequests: (reqs: ContactRequest[]) => void;
|
||||||
|
|
||||||
|
// Balance
|
||||||
|
balance: number;
|
||||||
|
setBalance: (b: number) => void;
|
||||||
|
|
||||||
|
// Loading / error states
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (v: boolean) => void;
|
||||||
|
error: string | null;
|
||||||
|
setError: (e: string | null) => void;
|
||||||
|
|
||||||
|
// Nonce cache (to avoid refetching)
|
||||||
|
nonce: number;
|
||||||
|
setNonce: (n: number) => void;
|
||||||
|
|
||||||
|
// Per-contact unread counter (reset on chat open, bumped on peer msg arrive).
|
||||||
|
// Keyed by contactAddress (Ed25519 pubkey hex).
|
||||||
|
unreadByContact: Record<string, number>;
|
||||||
|
incrementUnread: (contactAddress: string) => void;
|
||||||
|
clearUnread: (contactAddress: string) => void;
|
||||||
|
/** Bootstrap state: `true` after initial loadKeyFile + loadSettings done. */
|
||||||
|
booted: boolean;
|
||||||
|
setBooted: (b: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<AppState>((set, get) => ({
|
||||||
|
keyFile: null,
|
||||||
|
username: null,
|
||||||
|
setKeyFile: (kf) => set({ keyFile: kf }),
|
||||||
|
setUsername: (u) => set({ username: u }),
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
nodeUrl: 'http://localhost:8081',
|
||||||
|
contractId: '',
|
||||||
|
},
|
||||||
|
setSettings: (s) => set(state => ({ settings: { ...state.settings, ...s } })),
|
||||||
|
|
||||||
|
contacts: [],
|
||||||
|
setContacts: (contacts) => set({ contacts }),
|
||||||
|
upsertContact: (c) => set(state => {
|
||||||
|
const idx = state.contacts.findIndex(x => x.address === c.address);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const updated = [...state.contacts];
|
||||||
|
updated[idx] = c;
|
||||||
|
return { contacts: updated };
|
||||||
|
}
|
||||||
|
return { contacts: [...state.contacts, c] };
|
||||||
|
}),
|
||||||
|
|
||||||
|
chats: [],
|
||||||
|
setChats: (chats) => set({ chats }),
|
||||||
|
|
||||||
|
messages: {},
|
||||||
|
setMessages: (chatId, msgs) => set(state => ({
|
||||||
|
messages: { ...state.messages, [chatId]: msgs },
|
||||||
|
})),
|
||||||
|
appendMessage: (chatId, msg) => set(state => {
|
||||||
|
const current = state.messages[chatId] ?? [];
|
||||||
|
if (current.find(m => m.id === msg.id)) return {};
|
||||||
|
return { messages: { ...state.messages, [chatId]: [...current, msg] } };
|
||||||
|
}),
|
||||||
|
|
||||||
|
requests: [],
|
||||||
|
setRequests: (reqs) => set({ requests: reqs }),
|
||||||
|
|
||||||
|
balance: 0,
|
||||||
|
setBalance: (b) => set({ balance: b }),
|
||||||
|
|
||||||
|
loading: false,
|
||||||
|
setLoading: (v) => set({ loading: v }),
|
||||||
|
error: null,
|
||||||
|
setError: (e) => set({ error: e }),
|
||||||
|
|
||||||
|
nonce: 0,
|
||||||
|
setNonce: (n) => set({ nonce: n }),
|
||||||
|
|
||||||
|
unreadByContact: {},
|
||||||
|
incrementUnread: (addr) => set(state => ({
|
||||||
|
unreadByContact: {
|
||||||
|
...state.unreadByContact,
|
||||||
|
[addr]: (state.unreadByContact[addr] ?? 0) + 1,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
clearUnread: (addr) => set(state => {
|
||||||
|
if (!state.unreadByContact[addr]) return {};
|
||||||
|
const next = { ...state.unreadByContact };
|
||||||
|
delete next[addr];
|
||||||
|
return { unreadByContact: next };
|
||||||
|
}),
|
||||||
|
booted: false,
|
||||||
|
setBooted: (b) => set({ booted: b }),
|
||||||
|
}));
|
||||||
165
client-app/lib/types.ts
Normal file
165
client-app/lib/types.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// ─── Key material ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface KeyFile {
|
||||||
|
pub_key: string; // hex Ed25519 public key (32 bytes)
|
||||||
|
priv_key: string; // hex Ed25519 private key (64 bytes)
|
||||||
|
x25519_pub: string; // hex X25519 public key (32 bytes)
|
||||||
|
x25519_priv: string; // hex X25519 private key (32 bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contact ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип беседы в v2.0.0 — только direct (1:1 E2E чат). Каналы убраны в
|
||||||
|
* пользу публичной ленты (см. lib/feed.ts). Поле `kind` осталось ради
|
||||||
|
* обратной совместимости со старыми записями в AsyncStorage; новые
|
||||||
|
* контакты не пишут его.
|
||||||
|
*/
|
||||||
|
export type ContactKind = 'direct' | 'group';
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
address: string; // Ed25519 pubkey hex — blockchain address
|
||||||
|
x25519Pub: string; // X25519 pubkey hex — encryption key
|
||||||
|
username?: string; // @name from registry contract
|
||||||
|
alias?: string; // local nickname
|
||||||
|
addedAt: number; // unix ms
|
||||||
|
/** Legacy field (kept for backward compat with existing AsyncStorage). */
|
||||||
|
kind?: ContactKind;
|
||||||
|
/** Количество непрочитанных — опционально, проставляется WS read-receipt'ами. */
|
||||||
|
unread?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Messages ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Envelope {
|
||||||
|
/** sha256(nonce||ciphertext)[:16] hex — stable server-assigned id. */
|
||||||
|
id: string;
|
||||||
|
sender_pub: string; // X25519 hex
|
||||||
|
recipient_pub: string; // X25519 hex
|
||||||
|
nonce: string; // hex 24 bytes
|
||||||
|
ciphertext: string; // hex NaCl box
|
||||||
|
timestamp: number; // unix seconds (server's sent_at, normalised client-side)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вложение к сообщению. MVP — хранится как URI на локальной файловой
|
||||||
|
* системе клиента (expo-image-picker / expo-document-picker / expo-av
|
||||||
|
* возвращают именно такие URI). Wire-формат для передачи attachment'ов
|
||||||
|
* через relay-envelope ещё не финализирован — пока этот тип для UI'а и
|
||||||
|
* локального отображения.
|
||||||
|
*
|
||||||
|
* Формат по kind:
|
||||||
|
* image — width/height опциональны (image-picker их отдаёт)
|
||||||
|
* video — same + duration в секундах
|
||||||
|
* voice — duration в секундах, нет дизайна превью кроме waveform-stub
|
||||||
|
* file — name + size в байтах, тип через mime
|
||||||
|
*/
|
||||||
|
export type AttachmentKind = 'image' | 'video' | 'voice' | 'file';
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
kind: AttachmentKind;
|
||||||
|
uri: string; // локальный file:// URI или https:// (incoming decoded)
|
||||||
|
mime?: string; // 'image/jpeg', 'application/pdf', …
|
||||||
|
name?: string; // имя файла (для file)
|
||||||
|
size?: number; // байты (для file)
|
||||||
|
width?: number; // image/video
|
||||||
|
height?: number; // image/video
|
||||||
|
duration?: number; // seconds (video/voice)
|
||||||
|
/** Для kind='video' — рендерить как круглое видео-сообщение (Telegram-style). */
|
||||||
|
circle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
from: string; // X25519 pubkey of sender
|
||||||
|
text: string;
|
||||||
|
timestamp: number;
|
||||||
|
mine: boolean;
|
||||||
|
/** true если сообщение было отредактировано. Показываем "Edited" в углу. */
|
||||||
|
edited?: boolean;
|
||||||
|
/**
|
||||||
|
* Для mine=true — true если получатель его прочитал.
|
||||||
|
* UI: пустая галочка = отправлено, filled = прочитано.
|
||||||
|
* Для mine=false не используется.
|
||||||
|
*/
|
||||||
|
read?: boolean;
|
||||||
|
/** Одно вложение. Multi-attach пока не поддерживается — будет массивом. */
|
||||||
|
attachment?: Attachment;
|
||||||
|
/**
|
||||||
|
* Если сообщение — ответ на другое, здесь лежит ссылка + short preview
|
||||||
|
* того оригинала. id используется для scroll-to + highlight; text/author
|
||||||
|
* — для рендера "quoted"-блока внутри текущего bubble'а без запроса
|
||||||
|
* исходного сообщения (копия замороженная в момент ответа).
|
||||||
|
*/
|
||||||
|
replyTo?: {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
author: string; // @username / alias / "you"
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Ссылка на пост из ленты. Если присутствует — сообщение рендерится как
|
||||||
|
* карточка-превью поста (аватар автора, хэндл, текст-excerpt, картинка
|
||||||
|
* если есть). Тап на карточку → открывается полный пост. Сценарий — юзер
|
||||||
|
* нажал Share в ленте и отправил пост в этот чат/ЛС.
|
||||||
|
*
|
||||||
|
* Содержимое (автор, excerpt) дублируется тут, чтобы карточку можно было
|
||||||
|
* рендерить оффлайн / когда у хостящей релей-ноды пропал пост — чат
|
||||||
|
* остаётся читаемым независимо от жизни ленты.
|
||||||
|
*/
|
||||||
|
postRef?: {
|
||||||
|
postID: string;
|
||||||
|
author: string; // Ed25519 hex — для чипа имени в карточке
|
||||||
|
excerpt: string; // первые 120 символов тела поста
|
||||||
|
hasImage?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Chat {
|
||||||
|
contactAddress: string; // Ed25519 pubkey hex
|
||||||
|
contactX25519: string; // X25519 pubkey hex
|
||||||
|
username?: string;
|
||||||
|
alias?: string;
|
||||||
|
lastMessage?: string;
|
||||||
|
lastTime?: number;
|
||||||
|
unread: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contact request ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ContactRequest {
|
||||||
|
from: string; // Ed25519 pubkey hex
|
||||||
|
x25519Pub: string; // X25519 pubkey hex; empty until fetched from identity
|
||||||
|
username?: string;
|
||||||
|
intro: string; // plaintext intro (stored on-chain)
|
||||||
|
timestamp: number;
|
||||||
|
txHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transaction ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TxRecord {
|
||||||
|
hash: string;
|
||||||
|
type: string;
|
||||||
|
from: string;
|
||||||
|
to?: string;
|
||||||
|
amount?: number;
|
||||||
|
fee: number;
|
||||||
|
timestamp: number;
|
||||||
|
status: 'confirmed' | 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Node info ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NetStats {
|
||||||
|
total_blocks: number;
|
||||||
|
total_txs: number;
|
||||||
|
peer_count: number;
|
||||||
|
chain_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeSettings {
|
||||||
|
nodeUrl: string;
|
||||||
|
contractId: string; // username_registry contract
|
||||||
|
}
|
||||||
53
client-app/lib/utils.ts
Normal file
53
client-app/lib/utils.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
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[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format µT amount to human-readable string */
|
||||||
|
export function formatAmount(microTokens: number | undefined | null): string {
|
||||||
|
if (microTokens == null) return '—';
|
||||||
|
if (microTokens >= 1_000_000) return `${(microTokens / 1_000_000).toFixed(2)} T`;
|
||||||
|
if (microTokens >= 1_000) return `${(microTokens / 1_000).toFixed(1)} mT`;
|
||||||
|
return `${microTokens} µT`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format unix seconds to relative time */
|
||||||
|
export function relativeTime(unixSeconds: number | undefined | null): string {
|
||||||
|
if (!unixSeconds) return '';
|
||||||
|
const diff = Date.now() / 1000 - unixSeconds;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return new Date(unixSeconds * 1000).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format unix seconds to HH:MM */
|
||||||
|
export function formatTime(unixSeconds: number | undefined | null): string {
|
||||||
|
if (!unixSeconds) return '';
|
||||||
|
return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a random nonce string */
|
||||||
|
export function randomId(): string {
|
||||||
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
}
|
||||||
401
client-app/lib/ws.ts
Normal file
401
client-app/lib/ws.ts
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* DChain WebSocket client — replaces balance / inbox / contacts polling with
|
||||||
|
* server-push. Matches `node/ws.go` exactly.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const ws = getWSClient();
|
||||||
|
* ws.connect(); // idempotent
|
||||||
|
* const off = ws.subscribe('addr:ab12…', ev => { ... });
|
||||||
|
* // later:
|
||||||
|
* off(); // unsubscribe + stop handler
|
||||||
|
* ws.disconnect();
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Auto-reconnect with exponential backoff (1s → 30s cap).
|
||||||
|
* - Re-subscribes all topics after a reconnect.
|
||||||
|
* - `hello` frame exposes chain_id + tip_height for connection state UI.
|
||||||
|
* - Degrades silently if the endpoint returns 501 (old node without WS).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getNodeUrl, onNodeUrlChange } from './api';
|
||||||
|
import { sign } from './crypto';
|
||||||
|
|
||||||
|
export type WSEventName =
|
||||||
|
| 'hello'
|
||||||
|
| 'block'
|
||||||
|
| 'tx'
|
||||||
|
| 'contract_log'
|
||||||
|
| 'inbox'
|
||||||
|
| 'typing'
|
||||||
|
| 'pong'
|
||||||
|
| 'error'
|
||||||
|
| 'subscribed'
|
||||||
|
| 'submit_ack'
|
||||||
|
| 'lag';
|
||||||
|
|
||||||
|
export interface WSFrame {
|
||||||
|
event: WSEventName;
|
||||||
|
data?: unknown;
|
||||||
|
topic?: string;
|
||||||
|
msg?: string;
|
||||||
|
chain_id?: string;
|
||||||
|
tip_height?: number;
|
||||||
|
/** Server-issued nonce in the hello frame; client signs it for auth. */
|
||||||
|
auth_nonce?: string;
|
||||||
|
// submit_ack fields
|
||||||
|
id?: string;
|
||||||
|
status?: 'accepted' | 'rejected';
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler = (frame: WSFrame) => void;
|
||||||
|
|
||||||
|
class WSClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private url: string | null = null;
|
||||||
|
private reconnectMs: number = 1000;
|
||||||
|
private closing: boolean = false;
|
||||||
|
|
||||||
|
/** topic → set of handlers interested in frames for this topic */
|
||||||
|
private handlers: Map<string, Set<Handler>> = new Map();
|
||||||
|
/** topics we want the server to push — replayed on every reconnect */
|
||||||
|
private wantedTopics: Set<string> = new Set();
|
||||||
|
|
||||||
|
private connectionListeners: Set<(ok: boolean, err?: string) => void> = new Set();
|
||||||
|
private helloInfo: { chainId?: string; tipHeight?: number; authNonce?: string } = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credentials used for auto-auth on every (re)connect. The signer runs on
|
||||||
|
* each hello frame so scoped subscriptions (addr:*, inbox:*) are accepted.
|
||||||
|
* Without these, subscribe requests to scoped topics get rejected by the
|
||||||
|
* server; global topics (blocks, tx, …) still work unauthenticated.
|
||||||
|
*/
|
||||||
|
private authCreds: { pubKey: string; privKey: string } | null = null;
|
||||||
|
|
||||||
|
/** Current connection state (read-only for UI). */
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.ws?.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
getHelloInfo(): { chainId?: string; tipHeight?: number } {
|
||||||
|
return this.helloInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to a connection-state listener — fires on connect/disconnect. */
|
||||||
|
onConnectionChange(cb: (ok: boolean, err?: string) => void): () => void {
|
||||||
|
this.connectionListeners.add(cb);
|
||||||
|
return () => this.connectionListeners.delete(cb) as unknown as void;
|
||||||
|
}
|
||||||
|
private fireConnectionChange(ok: boolean, err?: string) {
|
||||||
|
for (const cb of this.connectionListeners) {
|
||||||
|
try { cb(ok, err); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the Ed25519 keypair used for auto-auth. The signer runs on each
|
||||||
|
* (re)connect against the server-issued nonce so the connection is bound
|
||||||
|
* to this identity. Pass null to disable auth (only global topics will
|
||||||
|
* work — useful for observers).
|
||||||
|
*/
|
||||||
|
setAuthCreds(creds: { pubKey: string; privKey: string } | null): void {
|
||||||
|
this.authCreds = creds;
|
||||||
|
// If we're already connected, kick off auth immediately.
|
||||||
|
if (creds && this.isConnected() && this.helloInfo.authNonce) {
|
||||||
|
this.sendAuth(this.helloInfo.authNonce);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent connect. Call once on app boot. */
|
||||||
|
connect(): void {
|
||||||
|
const base = getNodeUrl();
|
||||||
|
const newURL = base.replace(/^http/, 'ws') + '/api/ws';
|
||||||
|
if (this.ws) {
|
||||||
|
const state = this.ws.readyState;
|
||||||
|
// Already pointing at this URL and connected / connecting — nothing to do.
|
||||||
|
if (this.url === newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// URL changed (operator flipped nodes in settings) — tear down and
|
||||||
|
// re-dial. Existing subscriptions live in wantedTopics and will be
|
||||||
|
// replayed after the new onopen fires.
|
||||||
|
if (this.url !== newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
|
||||||
|
try { this.ws.close(); } catch { /* noop */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.closing = false;
|
||||||
|
this.url = newURL;
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(this.url);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.fireConnectionChange(false, e?.message ?? 'ws construct failed');
|
||||||
|
this.scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.reconnectMs = 1000; // reset backoff
|
||||||
|
this.fireConnectionChange(true);
|
||||||
|
// Replay all wanted subscriptions.
|
||||||
|
for (const topic of this.wantedTopics) {
|
||||||
|
this.sendRaw({ op: 'subscribe', topic });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (ev) => {
|
||||||
|
let frame: WSFrame;
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frame.event === 'hello') {
|
||||||
|
this.helloInfo = {
|
||||||
|
chainId: frame.chain_id,
|
||||||
|
tipHeight: frame.tip_height,
|
||||||
|
authNonce: frame.auth_nonce,
|
||||||
|
};
|
||||||
|
// Auto-authenticate if credentials are set. The server binds this
|
||||||
|
// connection to the signed pubkey so scoped subscriptions (addr:*,
|
||||||
|
// inbox:*) get through. On reconnect a new nonce is issued, so the
|
||||||
|
// auth dance repeats transparently.
|
||||||
|
if (this.authCreds && frame.auth_nonce) {
|
||||||
|
this.sendAuth(frame.auth_nonce);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dispatch to all handlers for any topic that could match this frame.
|
||||||
|
// We use a simple predicate: look at the frame to decide which topics it
|
||||||
|
// was fanned out to, then fire every matching handler.
|
||||||
|
for (const topic of this.topicsForFrame(frame)) {
|
||||||
|
const set = this.handlers.get(topic);
|
||||||
|
if (!set) continue;
|
||||||
|
for (const h of set) {
|
||||||
|
try { h(frame); } catch (e) { console.warn('[ws] handler error', e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (e: any) => {
|
||||||
|
this.fireConnectionChange(false, 'ws error');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.ws = null;
|
||||||
|
this.fireConnectionChange(false);
|
||||||
|
if (!this.closing) this.scheduleReconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.closing = true;
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(); } catch { /* noop */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a topic. Returns an `off()` function that unsubscribes AND
|
||||||
|
* removes the handler. If multiple callers subscribe to the same topic,
|
||||||
|
* the server is only notified on the first and last caller.
|
||||||
|
*/
|
||||||
|
subscribe(topic: string, handler: Handler): () => void {
|
||||||
|
let set = this.handlers.get(topic);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.handlers.set(topic, set);
|
||||||
|
}
|
||||||
|
set.add(handler);
|
||||||
|
|
||||||
|
// Notify server only on the first handler for this topic.
|
||||||
|
if (!this.wantedTopics.has(topic)) {
|
||||||
|
this.wantedTopics.add(topic);
|
||||||
|
if (this.isConnected()) {
|
||||||
|
this.sendRaw({ op: 'subscribe', topic });
|
||||||
|
} else {
|
||||||
|
this.connect(); // lazy-connect on first subscribe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const s = this.handlers.get(topic);
|
||||||
|
if (!s) return;
|
||||||
|
s.delete(handler);
|
||||||
|
if (s.size === 0) {
|
||||||
|
this.handlers.delete(topic);
|
||||||
|
this.wantedTopics.delete(topic);
|
||||||
|
if (this.isConnected()) {
|
||||||
|
this.sendRaw({ op: 'unsubscribe', topic });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force a keepalive ping. Useful for debugging. */
|
||||||
|
ping(): void {
|
||||||
|
this.sendRaw({ op: 'ping' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a typing indicator to another user. Recipient is their X25519 pubkey
|
||||||
|
* (the one used for inbox encryption). Ephemeral — no ack, no retry; just
|
||||||
|
* fire and forget. Call on each keystroke but throttle to once per 2-3s
|
||||||
|
* at the caller side so we don't flood the WS with frames.
|
||||||
|
*/
|
||||||
|
sendTyping(recipientX25519: string): void {
|
||||||
|
if (!this.isConnected()) return;
|
||||||
|
try {
|
||||||
|
this.ws!.send(JSON.stringify({ op: 'typing', to: recipientX25519 }));
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a signed transaction over the WebSocket and resolve once the
|
||||||
|
* server returns a `submit_ack`. Saves the HTTP round-trip on every tx
|
||||||
|
* and gives the UI immediate accept/reject feedback.
|
||||||
|
*
|
||||||
|
* Rejects if:
|
||||||
|
* - WS is not connected (caller should fall back to HTTP)
|
||||||
|
* - Server returns `status: "rejected"` — `reason` is surfaced as error msg
|
||||||
|
* - No ack within `timeoutMs` (default 10 s)
|
||||||
|
*/
|
||||||
|
submitTx(tx: unknown, timeoutMs = 10_000): Promise<{ id: string }> {
|
||||||
|
if (!this.isConnected()) {
|
||||||
|
return Promise.reject(new Error('WS not connected'));
|
||||||
|
}
|
||||||
|
const reqId = 's_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const off = this.subscribe('$system', (frame) => {
|
||||||
|
if (frame.event !== 'submit_ack' || frame.id !== reqId) return;
|
||||||
|
off();
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (frame.status === 'accepted') {
|
||||||
|
// `msg` carries the server-confirmed tx id.
|
||||||
|
resolve({ id: typeof frame.msg === 'string' ? frame.msg : '' });
|
||||||
|
} else {
|
||||||
|
reject(new Error(frame.reason || 'submit_tx rejected'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
off();
|
||||||
|
reject(new Error('submit_tx timeout (' + timeoutMs + 'ms)'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws!.send(JSON.stringify({ op: 'submit_tx', tx, id: reqId }));
|
||||||
|
} catch (e: any) {
|
||||||
|
off();
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error('WS send failed: ' + (e?.message ?? 'unknown')));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.closing) return;
|
||||||
|
const delay = Math.min(this.reconnectMs, 30_000);
|
||||||
|
this.reconnectMs = Math.min(this.reconnectMs * 2, 30_000);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.closing) this.connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendRaw(cmd: { op: string; topic?: string }): void {
|
||||||
|
if (!this.isConnected()) return;
|
||||||
|
try { this.ws!.send(JSON.stringify(cmd)); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign the server nonce with our Ed25519 private key and send the `auth`
|
||||||
|
* op. The server binds this connection to `authCreds.pubKey`; subsequent
|
||||||
|
* subscribe requests to `addr:<pubKey>` / `inbox:<my_x25519>` are accepted.
|
||||||
|
*/
|
||||||
|
private sendAuth(nonce: string): void {
|
||||||
|
if (!this.authCreds || !this.isConnected()) return;
|
||||||
|
try {
|
||||||
|
const bytes = new TextEncoder().encode(nonce);
|
||||||
|
const sig = sign(bytes, this.authCreds.privKey);
|
||||||
|
this.ws!.send(JSON.stringify({
|
||||||
|
op: 'auth',
|
||||||
|
pubkey: this.authCreds.pubKey,
|
||||||
|
sig,
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ws] auth send failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an incoming frame, enumerate every topic that handlers could have
|
||||||
|
* subscribed to and still be interested. This mirrors the fan-out logic in
|
||||||
|
* node/ws.go:EmitBlock / EmitTx / EmitContractLog.
|
||||||
|
*/
|
||||||
|
private topicsForFrame(frame: WSFrame): string[] {
|
||||||
|
switch (frame.event) {
|
||||||
|
case 'block':
|
||||||
|
return ['blocks'];
|
||||||
|
case 'tx': {
|
||||||
|
const d = frame.data as { from?: string; to?: string } | undefined;
|
||||||
|
const topics = ['tx'];
|
||||||
|
if (d?.from) topics.push('addr:' + d.from);
|
||||||
|
if (d?.to && d.to !== d.from) topics.push('addr:' + d.to);
|
||||||
|
return topics;
|
||||||
|
}
|
||||||
|
case 'contract_log': {
|
||||||
|
const d = frame.data as { contract_id?: string } | undefined;
|
||||||
|
const topics = ['contract_log'];
|
||||||
|
if (d?.contract_id) topics.push('contract:' + d.contract_id);
|
||||||
|
return topics;
|
||||||
|
}
|
||||||
|
case 'inbox': {
|
||||||
|
// Node fans inbox events to `inbox` + `inbox:<recipient_x25519>`;
|
||||||
|
// we mirror that here so both firehose listeners and address-scoped
|
||||||
|
// subscribers see the event.
|
||||||
|
const d = frame.data as { recipient_pub?: string } | undefined;
|
||||||
|
const topics = ['inbox'];
|
||||||
|
if (d?.recipient_pub) topics.push('inbox:' + d.recipient_pub);
|
||||||
|
return topics;
|
||||||
|
}
|
||||||
|
case 'typing': {
|
||||||
|
// Server fans to `typing:<to>` only (the recipient).
|
||||||
|
const d = frame.data as { to?: string } | undefined;
|
||||||
|
return d?.to ? ['typing:' + d.to] : [];
|
||||||
|
}
|
||||||
|
// Control-plane events — no topic fan-out; use a pseudo-topic so UI
|
||||||
|
// can listen for them via subscribe('$system', ...).
|
||||||
|
case 'hello':
|
||||||
|
case 'pong':
|
||||||
|
case 'error':
|
||||||
|
case 'subscribed':
|
||||||
|
case 'submit_ack':
|
||||||
|
case 'lag':
|
||||||
|
return ['$system'];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _singleton: WSClient | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the app-wide WebSocket client. Safe to call from any component;
|
||||||
|
* `.connect()` is idempotent.
|
||||||
|
*
|
||||||
|
* On first creation we register a node-URL listener so flipping the node
|
||||||
|
* in Settings tears down the existing socket and dials the new one — the
|
||||||
|
* user's active subscriptions (addr:*, inbox:*) replay automatically.
|
||||||
|
*/
|
||||||
|
export function getWSClient(): WSClient {
|
||||||
|
if (!_singleton) {
|
||||||
|
_singleton = new WSClient();
|
||||||
|
onNodeUrlChange(() => {
|
||||||
|
// Fire and forget — connect() is idempotent and handles stale URLs.
|
||||||
|
_singleton!.connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _singleton;
|
||||||
|
}
|
||||||
6
client-app/metro.config.js
Normal file
6
client-app/metro.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
const { withNativeWind } = require('nativewind/metro');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
module.exports = withNativeWind(config, { input: './global.css' });
|
||||||
1
client-app/nativewind-env.d.ts
vendored
Normal file
1
client-app/nativewind-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="nativewind/types" />
|
||||||
11482
client-app/package-lock.json
generated
Normal file
11482
client-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
client-app/package.json
Normal file
61
client-app/package.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "dchain-messenger",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/metro-runtime": "~6.1.2",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-community/netinfo": "11.4.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"expo": "~54.0.0",
|
||||||
|
"expo-asset": "~12.0.12",
|
||||||
|
"expo-audio": "~1.1.1",
|
||||||
|
"expo-camera": "~17.0.10",
|
||||||
|
"expo-clipboard": "~8.0.8",
|
||||||
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-crypto": "~15.0.8",
|
||||||
|
"expo-document-picker": "~14.0.8",
|
||||||
|
"expo-file-system": "~19.0.21",
|
||||||
|
"expo-font": "~14.0.11",
|
||||||
|
"expo-image-manipulator": "~14.0.8",
|
||||||
|
"expo-image-picker": "~17.0.10",
|
||||||
|
"expo-linking": "~8.0.11",
|
||||||
|
"expo-notifications": "~0.32.16",
|
||||||
|
"expo-router": "~6.0.23",
|
||||||
|
"expo-secure-store": "~15.0.8",
|
||||||
|
"expo-sharing": "~14.0.8",
|
||||||
|
"expo-splash-screen": "~31.0.13",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-video": "~3.0.16",
|
||||||
|
"expo-web-browser": "~15.0.10",
|
||||||
|
"nativewind": "^4.1.23",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-web": "^0.21.0",
|
||||||
|
"react-native-worklets": "0.5.1",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
|
"tweetnacl-util": "^0.15.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.25.2",
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"babel-preset-expo": "~54.0.10",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
client-app/tailwind.config.js
Normal file
35
client-app/tailwind.config.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
//
|
||||||
|
// DChain messenger — dark-first palette inspired by the X-style Messages screen.
|
||||||
|
//
|
||||||
|
// `bg` is true black for OLED. `surface` / `surface2` lift for tiles,
|
||||||
|
// inputs and pressed states. `accent` is the icy blue used by the
|
||||||
|
// composer FAB and active filter pills.
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./app/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'./components/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'./hooks/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'./lib/**/*.{js,jsx,ts,tsx}',
|
||||||
|
],
|
||||||
|
presets: [require('nativewind/preset')],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
bg: '#000000',
|
||||||
|
bg2: '#0a0a0a',
|
||||||
|
surface: '#111111',
|
||||||
|
surface2: '#1a1a1a',
|
||||||
|
line: '#1f1f1f',
|
||||||
|
text: '#ffffff',
|
||||||
|
subtext: '#8b8b8b',
|
||||||
|
muted: '#5a5a5a',
|
||||||
|
accent: '#1d9bf0',
|
||||||
|
ok: '#3ba55d',
|
||||||
|
warn: '#f0b35a',
|
||||||
|
err: '#f4212e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
9
client-app/tsconfig.json
Normal file
9
client-app/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
cmd/node/main.go
190
cmd/node/main.go
@@ -29,6 +29,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -41,6 +43,7 @@ import (
|
|||||||
"go-blockchain/consensus"
|
"go-blockchain/consensus"
|
||||||
"go-blockchain/economy"
|
"go-blockchain/economy"
|
||||||
"go-blockchain/identity"
|
"go-blockchain/identity"
|
||||||
|
"go-blockchain/media"
|
||||||
"go-blockchain/node"
|
"go-blockchain/node"
|
||||||
"go-blockchain/node/version"
|
"go-blockchain/node/version"
|
||||||
"go-blockchain/p2p"
|
"go-blockchain/p2p"
|
||||||
@@ -77,6 +80,10 @@ func main() {
|
|||||||
registerRelay := flag.Bool("register-relay", envBoolOr("DCHAIN_REGISTER_RELAY", false), "submit REGISTER_RELAY tx on startup (env: DCHAIN_REGISTER_RELAY)")
|
registerRelay := flag.Bool("register-relay", envBoolOr("DCHAIN_REGISTER_RELAY", false), "submit REGISTER_RELAY tx on startup (env: DCHAIN_REGISTER_RELAY)")
|
||||||
relayFee := flag.Uint64("relay-fee", envUint64Or("DCHAIN_RELAY_FEE", 1_000), "relay fee per message in µT (env: DCHAIN_RELAY_FEE)")
|
relayFee := flag.Uint64("relay-fee", envUint64Or("DCHAIN_RELAY_FEE", 1_000), "relay fee per message in µT (env: DCHAIN_RELAY_FEE)")
|
||||||
mailboxDB := flag.String("mailbox-db", envOr("DCHAIN_MAILBOX_DB", "./mailboxdata"), "BadgerDB directory for relay mailbox (env: DCHAIN_MAILBOX_DB)")
|
mailboxDB := flag.String("mailbox-db", envOr("DCHAIN_MAILBOX_DB", "./mailboxdata"), "BadgerDB directory for relay mailbox (env: DCHAIN_MAILBOX_DB)")
|
||||||
|
feedDB := flag.String("feed-db", envOr("DCHAIN_FEED_DB", "./feeddata"), "BadgerDB directory for social-feed post bodies (env: DCHAIN_FEED_DB)")
|
||||||
|
feedTTLDays := flag.Int("feed-ttl-days", int(envUint64Or("DCHAIN_FEED_TTL_DAYS", 30)), "how long feed posts are retained before auto-eviction (env: DCHAIN_FEED_TTL_DAYS)")
|
||||||
|
mediaSidecarURL := flag.String("media-sidecar-url", envOr("DCHAIN_MEDIA_SIDECAR_URL", ""), "URL of the media scrubber sidecar (FFmpeg-based video/audio re-encoder). Empty = images only (env: DCHAIN_MEDIA_SIDECAR_URL)")
|
||||||
|
allowUnscrubbedVideo := flag.Bool("allow-unscrubbed-video", envBoolOr("DCHAIN_ALLOW_UNSCRUBBED_VIDEO", false), "accept video uploads without server-side metadata scrubbing (only when no sidecar is configured). DANGEROUS — leaves EXIF/GPS/author tags intact (env: DCHAIN_ALLOW_UNSCRUBBED_VIDEO)")
|
||||||
govContractID := flag.String("governance-contract", envOr("DCHAIN_GOVERNANCE_CONTRACT", ""), "governance contract ID for dynamic chain parameters (env: DCHAIN_GOVERNANCE_CONTRACT)")
|
govContractID := flag.String("governance-contract", envOr("DCHAIN_GOVERNANCE_CONTRACT", ""), "governance contract ID for dynamic chain parameters (env: DCHAIN_GOVERNANCE_CONTRACT)")
|
||||||
joinSeedURL := flag.String("join", envOr("DCHAIN_JOIN", ""), "bootstrap from a running node: comma-separated HTTP URLs (env: DCHAIN_JOIN)")
|
joinSeedURL := flag.String("join", envOr("DCHAIN_JOIN", ""), "bootstrap from a running node: comma-separated HTTP URLs (env: DCHAIN_JOIN)")
|
||||||
// Observer mode: the node participates in the P2P network, applies
|
// Observer mode: the node participates in the P2P network, applies
|
||||||
@@ -109,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()
|
||||||
|
|
||||||
@@ -123,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.
|
||||||
@@ -634,6 +655,27 @@ func main() {
|
|||||||
go mailbox.RunGC()
|
go mailbox.RunGC()
|
||||||
log.Printf("[NODE] relay mailbox: %s", *mailboxDB)
|
log.Printf("[NODE] relay mailbox: %s", *mailboxDB)
|
||||||
|
|
||||||
|
// --- Feed mailbox (social-feed post bodies, v2.0.0) ---
|
||||||
|
feedTTL := time.Duration(*feedTTLDays) * 24 * time.Hour
|
||||||
|
feedQuotaBytes := int64(*feedDiskMB) * 1024 * 1024
|
||||||
|
feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL, feedQuotaBytes)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[NODE] feed mailbox: %v", err)
|
||||||
|
}
|
||||||
|
defer feedMailbox.Close()
|
||||||
|
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
|
||||||
// event immediately so they no longer need to poll /relay/inbox.
|
// event immediately so they no longer need to poll /relay/inbox.
|
||||||
@@ -854,8 +896,6 @@ func main() {
|
|||||||
GetNFT: chain.NFT,
|
GetNFT: chain.NFT,
|
||||||
GetNFTs: chain.NFTs,
|
GetNFTs: chain.NFTs,
|
||||||
NFTsByOwner: chain.NFTsByOwner,
|
NFTsByOwner: chain.NFTsByOwner,
|
||||||
GetChannel: chain.Channel,
|
|
||||||
GetChannelMembers: chain.ChannelMembers,
|
|
||||||
Events: sseHub,
|
Events: sseHub,
|
||||||
WS: wsHub,
|
WS: wsHub,
|
||||||
// Onboarding: expose libp2p peers + chain_id so new nodes/clients can
|
// Onboarding: expose libp2p peers + chain_id so new nodes/clients can
|
||||||
@@ -920,6 +960,36 @@ func main() {
|
|||||||
ContactRequests: func(pubKey string) ([]blockchain.ContactInfo, error) {
|
ContactRequests: func(pubKey string) ([]blockchain.ContactInfo, error) {
|
||||||
return chain.ContactRequests(pubKey)
|
return chain.ContactRequests(pubKey)
|
||||||
},
|
},
|
||||||
|
ResolveX25519: func(ed25519PubHex string) string {
|
||||||
|
info, err := chain.Identity(ed25519PubHex)
|
||||||
|
if err != nil || info == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return info.X25519PubKey
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media scrubber — strips EXIF/GPS/author/camera metadata from every
|
||||||
|
// uploaded image in-process, and forwards video/audio to the FFmpeg
|
||||||
|
// sidecar when configured. Mandatory for all /feed/publish traffic.
|
||||||
|
scrubber := media.NewScrubber(media.SidecarConfig{URL: *mediaSidecarURL})
|
||||||
|
if *mediaSidecarURL != "" {
|
||||||
|
log.Printf("[NODE] media sidecar: %s", *mediaSidecarURL)
|
||||||
|
} else {
|
||||||
|
log.Printf("[NODE] media sidecar: not configured (images scrubbed in-process; video/audio %s)",
|
||||||
|
map[bool]string{true: "stored unscrubbed (DANGEROUS)", false: "rejected"}[*allowUnscrubbedVideo])
|
||||||
|
}
|
||||||
|
|
||||||
|
feedConfig := node.FeedConfig{
|
||||||
|
Mailbox: feedMailbox,
|
||||||
|
HostingRelayPub: id.PubKeyHex(),
|
||||||
|
Scrubber: scrubber,
|
||||||
|
AllowUnscrubbedVideo: *allowUnscrubbedVideo,
|
||||||
|
GetPost: chain.Post,
|
||||||
|
LikeCount: chain.LikeCount,
|
||||||
|
HasLiked: chain.HasLiked,
|
||||||
|
PostsByAuthor: chain.PostsByAuthor,
|
||||||
|
Following: chain.Following,
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -942,6 +1012,7 @@ func main() {
|
|||||||
if err := stats.ListenAndServe(*statsAddr, statsQuery, func(mux *http.ServeMux) {
|
if err := stats.ListenAndServe(*statsAddr, statsQuery, func(mux *http.ServeMux) {
|
||||||
node.RegisterExplorerRoutes(mux, explorerQuery, routeFlags)
|
node.RegisterExplorerRoutes(mux, explorerQuery, routeFlags)
|
||||||
node.RegisterRelayRoutes(mux, relayConfig)
|
node.RegisterRelayRoutes(mux, relayConfig)
|
||||||
|
node.RegisterFeedRoutes(mux, feedConfig)
|
||||||
|
|
||||||
// POST /api/governance/link — link deployed contracts at runtime.
|
// POST /api/governance/link — link deployed contracts at runtime.
|
||||||
// Body: {"governance": "<id>"}
|
// Body: {"governance": "<id>"}
|
||||||
@@ -1248,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)
|
||||||
@@ -1277,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)
|
||||||
}
|
}
|
||||||
@@ -1397,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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
35
docker/media-sidecar/Dockerfile
Normal file
35
docker/media-sidecar/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# media-sidecar — FFmpeg-based metadata scrubber for DChain node.
|
||||||
|
#
|
||||||
|
# Build: docker build -t dchain/media-sidecar -f docker/media-sidecar/Dockerfile .
|
||||||
|
# Run: docker run -p 8090:8090 dchain/media-sidecar
|
||||||
|
# Compose: see docker-compose.yml; node points DCHAIN_MEDIA_SIDECAR_URL at it.
|
||||||
|
#
|
||||||
|
# Stage 1 — build a tiny static Go binary.
|
||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
# 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).
|
||||||
|
COPY docker/media-sidecar/main.go ./main.go
|
||||||
|
RUN go mod init dchain-media-sidecar 2>/dev/null || true
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/media-sidecar ./main.go
|
||||||
|
|
||||||
|
# Stage 2 — runtime with ffmpeg. Alpine has a lean ffmpeg build (~90 MB
|
||||||
|
# total image, most of it codecs we actually need).
|
||||||
|
FROM alpine:3.19
|
||||||
|
RUN apk add --no-cache ffmpeg ca-certificates \
|
||||||
|
&& addgroup -S dchain && adduser -S -G dchain dchain
|
||||||
|
COPY --from=build /out/media-sidecar /usr/local/bin/media-sidecar
|
||||||
|
|
||||||
|
USER dchain
|
||||||
|
EXPOSE 8090
|
||||||
|
|
||||||
|
# Pin sensible defaults; operator overrides via docker-compose env.
|
||||||
|
ENV LISTEN_ADDR=:8090 \
|
||||||
|
FFMPEG_BIN=ffmpeg \
|
||||||
|
MAX_INPUT_MB=32 \
|
||||||
|
JOB_TIMEOUT_SECS=60
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:8090/healthz || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/media-sidecar"]
|
||||||
201
docker/media-sidecar/main.go
Normal file
201
docker/media-sidecar/main.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// Media scrubber sidecar — tiny HTTP service that re-encodes video/audio
|
||||||
|
// through ffmpeg with all metadata stripped. Runs alongside the DChain
|
||||||
|
// node in docker-compose; the node calls it via DCHAIN_MEDIA_SIDECAR_URL.
|
||||||
|
//
|
||||||
|
// Contract (matches media.Scrubber in the node):
|
||||||
|
//
|
||||||
|
// POST /scrub/video Content-Type: video/* body: raw bytes
|
||||||
|
// → 200, Content-Type: video/mp4, body: cleaned bytes
|
||||||
|
// POST /scrub/audio Content-Type: audio/* body: raw bytes
|
||||||
|
// → 200, Content-Type: audio/ogg, body: cleaned bytes
|
||||||
|
//
|
||||||
|
// ffmpeg flags of note:
|
||||||
|
//
|
||||||
|
// -map_metadata -1 drop ALL metadata streams (title, author, encoder,
|
||||||
|
// GPS location atoms, XMP blocks, etc.)
|
||||||
|
// -map 0:v -map 0:a keep only video and audio streams — dumps attached
|
||||||
|
// pictures, subtitles, data channels that might carry
|
||||||
|
// hidden info
|
||||||
|
// -movflags +faststart
|
||||||
|
// put MOOV atom at the front so clients can start
|
||||||
|
// playback before the full download lands
|
||||||
|
// -c:v libx264 -crf 28 -preset fast
|
||||||
|
// h264 with aggressive-but-not-painful CRF; ~70-80%
|
||||||
|
// size reduction on phone-camera source
|
||||||
|
// -c:a libopus -b:a 64k
|
||||||
|
// opus at 64 kbps is transparent for speech, fine
|
||||||
|
// for music at feed quality
|
||||||
|
//
|
||||||
|
// Environment:
|
||||||
|
//
|
||||||
|
// LISTEN_ADDR default ":8090"
|
||||||
|
// FFMPEG_BIN default "ffmpeg" (must be in PATH)
|
||||||
|
// MAX_INPUT_MB default 32 — reject anything larger pre-ffmpeg
|
||||||
|
// JOB_TIMEOUT_SECS default 60
|
||||||
|
//
|
||||||
|
// The service is deliberately dumb: no queuing, no DB, no state. If you
|
||||||
|
// need higher throughput, run N replicas behind a TCP load balancer.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
addr := envOr("LISTEN_ADDR", ":8090")
|
||||||
|
ffmpegBin := envOr("FFMPEG_BIN", "ffmpeg")
|
||||||
|
maxInputMB := envInt("MAX_INPUT_MB", 32)
|
||||||
|
jobTimeoutSecs := envInt("JOB_TIMEOUT_SECS", 60)
|
||||||
|
|
||||||
|
// Fail fast if ffmpeg is missing — easier to debug at container start
|
||||||
|
// than to surface cryptic errors per-request.
|
||||||
|
if _, err := exec.LookPath(ffmpegBin); err != nil {
|
||||||
|
log.Fatalf("ffmpeg not found in PATH (looked for %q): %v", ffmpegBin, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &server{
|
||||||
|
ffmpegBin: ffmpegBin,
|
||||||
|
maxInputSize: int64(maxInputMB) * 1024 * 1024,
|
||||||
|
jobTimeout: time.Duration(jobTimeoutSecs) * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/scrub/video", srv.scrubVideo)
|
||||||
|
mux.HandleFunc("/scrub/audio", srv.scrubAudio)
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("media-sidecar: listening on %s, ffmpeg=%s, max_input=%d MiB, timeout=%ds",
|
||||||
|
addr, ffmpegBin, maxInputMB, jobTimeoutSecs)
|
||||||
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
|
log.Fatalf("ListenAndServe: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type server struct {
|
||||||
|
ffmpegBin string
|
||||||
|
maxInputSize int64
|
||||||
|
jobTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) scrubVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := s.readLimited(r)
|
||||||
|
if err != nil {
|
||||||
|
httpErr(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), s.jobTimeout)
|
||||||
|
defer cancel()
|
||||||
|
// Video path: re-encode with metadata strip, H.264 CRF 28, opus audio.
|
||||||
|
// Output format is MP4 (widest client compatibility).
|
||||||
|
args := []string{
|
||||||
|
"-hide_banner", "-loglevel", "error",
|
||||||
|
"-i", "pipe:0",
|
||||||
|
"-map", "0:v", "-map", "0:a?",
|
||||||
|
"-map_metadata", "-1",
|
||||||
|
"-c:v", "libx264", "-preset", "fast", "-crf", "28",
|
||||||
|
"-c:a", "libopus", "-b:a", "64k",
|
||||||
|
"-movflags", "+faststart+frag_keyframe",
|
||||||
|
"-f", "mp4",
|
||||||
|
"pipe:1",
|
||||||
|
}
|
||||||
|
out, ffErr, err := s.runFFmpeg(ctx, args, body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("video scrub failed: %v | stderr=%s", err, ffErr)
|
||||||
|
httpErr(w, "ffmpeg failed: "+err.Error(), http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "video/mp4")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
|
||||||
|
_, _ = w.Write(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) scrubAudio(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := s.readLimited(r)
|
||||||
|
if err != nil {
|
||||||
|
httpErr(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), s.jobTimeout)
|
||||||
|
defer cancel()
|
||||||
|
args := []string{
|
||||||
|
"-hide_banner", "-loglevel", "error",
|
||||||
|
"-i", "pipe:0",
|
||||||
|
"-vn", "-map", "0:a",
|
||||||
|
"-map_metadata", "-1",
|
||||||
|
"-c:a", "libopus", "-b:a", "64k",
|
||||||
|
"-f", "ogg",
|
||||||
|
"pipe:1",
|
||||||
|
}
|
||||||
|
out, ffErr, err := s.runFFmpeg(ctx, args, body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("audio scrub failed: %v | stderr=%s", err, ffErr)
|
||||||
|
httpErr(w, "ffmpeg failed: "+err.Error(), http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "audio/ogg")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
|
||||||
|
_, _ = w.Write(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) runFFmpeg(ctx context.Context, args []string, input []byte) ([]byte, string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, s.ffmpegBin, args...)
|
||||||
|
cmd.Stdin = bytes.NewReader(input)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return nil, stderr.String(), err
|
||||||
|
}
|
||||||
|
return stdout.Bytes(), stderr.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) readLimited(r *http.Request) ([]byte, error) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
return nil, fmt.Errorf("method not allowed")
|
||||||
|
}
|
||||||
|
limited := io.LimitReader(r.Body, s.maxInputSize+1)
|
||||||
|
buf, err := io.ReadAll(limited)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
if int64(len(buf)) > s.maxInputSize {
|
||||||
|
return nil, fmt.Errorf("input exceeds %d bytes", s.maxInputSize)
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpErr(w http.ResponseWriter, msg string, status int) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(k, d string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
func envInt(k string, d int) int {
|
||||||
|
v := os.Getenv(k)
|
||||||
|
if v == "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ Swagger UI на `/swagger`. Эти два эндпоинта можно выкл
|
|||||||
|---------|----------|
|
|---------|----------|
|
||||||
| [chain.md](chain.md) | Блоки, транзакции, балансы, адреса, netstats, validators |
|
| [chain.md](chain.md) | Блоки, транзакции, балансы, адреса, netstats, validators |
|
||||||
| [contracts.md](contracts.md) | Деплой, вызов, state, логи контрактов |
|
| [contracts.md](contracts.md) | Деплой, вызов, state, логи контрактов |
|
||||||
| [relay.md](relay.md) | Relay-mailbox: отправка/приём encrypted envelopes |
|
| [relay.md](relay.md) | Relay-mailbox: 1:1 E2E-шифрованные envelopes |
|
||||||
|
| [feed.md](feed.md) | Социальная лента: публикация постов, лайки, подписки, рекомендации, хэштеги (v2.0.0) |
|
||||||
|
|
||||||
## Discovery / metadata (always on)
|
## Discovery / metadata (always on)
|
||||||
|
|
||||||
|
|||||||
279
docs/api/feed.md
Normal file
279
docs/api/feed.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# Feed API (v2.0.0)
|
||||||
|
|
||||||
|
REST API для социальной ленты: публикация постов, лента подписок,
|
||||||
|
рекомендации, трендинг, хэштеги. Введено в **v2.0.0**, заменило
|
||||||
|
channel-модель.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
Лента устроена гибридно — **метаданные on-chain**, **тела постов
|
||||||
|
off-chain** в relay feed-mailbox:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ on-chain state │ │ feed mailbox (BadgerDB)│
|
||||||
|
│ │ │ │
|
||||||
|
│ post:<id> → │ │ feedpost:<id> → │
|
||||||
|
│ {author, size, │ POST │ {content, attachment, │
|
||||||
|
│ content_hash, │ /feed/publish │ hashtags, type, ts} │
|
||||||
|
│ hosting_relay, │───────────────────►│ │
|
||||||
|
│ reply_to?, │ │ feedview:<id> → │
|
||||||
|
│ deleted?} │ │ uint64 (counter) │
|
||||||
|
│ │ │ │
|
||||||
|
│ follow:<A>:<B> │ │ feedtag:<tag>:<ts>:<id> │
|
||||||
|
│ like:<post>:<liker>│ │ (hashtag index) │
|
||||||
|
│ likecount:<post> │ │ │
|
||||||
|
└─────────────────────┘ └──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Разделение решает два противоречия: on-chain-состояние обеспечивает
|
||||||
|
provable авторство + экономические стимулы (оплата за байт); off-chain
|
||||||
|
тела позволяют хранить мегабайты картинок без раздувания истории
|
||||||
|
блоков. Hosting relay получает плату автора (через CREATE_POST tx);
|
||||||
|
другие ноды могут реплицировать через gossipsub (роадмап).
|
||||||
|
|
||||||
|
## Оплата постов
|
||||||
|
|
||||||
|
Автор поста платит `BasePostFee + Size × PostByteFee` µT за публикацию.
|
||||||
|
Полный fee кредитуется на баланс hosting-релея.
|
||||||
|
|
||||||
|
| Константа | Значение | Примечание |
|
||||||
|
|-----------|----------|-----------|
|
||||||
|
| `BasePostFee` | 1 000 µT (0.001 T) | Совпадает с MinFee (block-validation floor) |
|
||||||
|
| `PostByteFee` | 1 µT/byte | Размер = контент + attachment + ~128 B метаданных |
|
||||||
|
| `MaxPostSize` | 256 KiB | Hard cap, больше — 413 на `/feed/publish` |
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
- 200-байтовый текстовый пост → ~1 328 µT (~0.0013 T)
|
||||||
|
- Текст + 30 KiB WebP-картинка → ~31 848 µT (~0.032 T)
|
||||||
|
- Видео 200 KiB → ~205 128 µT (~0.21 T)
|
||||||
|
|
||||||
|
## Server-side metadata scrubbing
|
||||||
|
|
||||||
|
**Обязательная** для всех аттачментов на `/feed/publish`:
|
||||||
|
|
||||||
|
- **Изображения** (JPEG/PNG/GIF/WebP): decode → downscale до 1080 px
|
||||||
|
→ re-encode как JPEG Q=75 через stdlib. EXIF/GPS/ICC/XMP/MakerNote
|
||||||
|
стираются by construction (stdlib JPEG encoder не пишет их).
|
||||||
|
- **Видео/аудио**: переадресуется во внешний **FFmpeg-сайдкар**
|
||||||
|
(контейнер `docker/media-sidecar/`). Если сайдкар не настроен через
|
||||||
|
`DCHAIN_MEDIA_SIDECAR_URL` и оператор не выставил
|
||||||
|
`DCHAIN_ALLOW_UNSCRUBBED_VIDEO`, видео-аплоад отклоняется с 503.
|
||||||
|
- **MIME mismatch**: если magic-байты не соответствуют заявленному
|
||||||
|
`Content-Type` — 400. Защита от PDF'а, замаскированного под картинку.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### `POST /feed/publish`
|
||||||
|
|
||||||
|
Загружает тело поста, запускает скраббинг, сохраняет в feed-mailbox,
|
||||||
|
возвращает метаданные для последующей on-chain CREATE_POST tx.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"post_id": "<hex 16 B>",
|
||||||
|
"author": "<ed25519_hex>",
|
||||||
|
"content": "Text of the post",
|
||||||
|
"content_type": "text/plain",
|
||||||
|
"attachment_b64": "<base64 image/video/audio>",
|
||||||
|
"attachment_mime": "image/jpeg",
|
||||||
|
"reply_to": "<optional parent post_id>",
|
||||||
|
"quote_of": "<optional quoted post_id>",
|
||||||
|
"sig": "<base64 Ed25519 sig>",
|
||||||
|
"ts": 1710000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`sig = Ed25519.Sign(author_priv, "publish:<post_id>:<raw_content_hash_hex>:<ts>")`
|
||||||
|
|
||||||
|
где `raw_content_hash = sha256(content + raw_attachment_bytes)` —
|
||||||
|
клиентский хэш **до** серверного скраба.
|
||||||
|
|
||||||
|
`ts` в пределах ±5 минут от серверного времени (anti-replay).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"post_id": "<echo>",
|
||||||
|
"hosting_relay": "<ed25519_hex of this node>",
|
||||||
|
"content_hash": "<hex sha256 of post-scrub bytes>",
|
||||||
|
"size": 1234,
|
||||||
|
"hashtags": ["golang", "dchain"],
|
||||||
|
"estimated_fee_ut": 2234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Клиент затем собирает CREATE_POST tx с этими полями и сабмитит на
|
||||||
|
`/api/tx`. См. `buildCreatePostTx` в клиентской библиотеке.
|
||||||
|
|
||||||
|
### `GET /feed/post/{id}`
|
||||||
|
|
||||||
|
Тело поста со статами.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"post_id": "...",
|
||||||
|
"author": "...",
|
||||||
|
"content": "...",
|
||||||
|
"content_type": "text/plain",
|
||||||
|
"hashtags": ["golang"],
|
||||||
|
"created_at": 1710000000,
|
||||||
|
"reply_to": "",
|
||||||
|
"quote_of": "",
|
||||||
|
"attachment": "<base64 bytes>",
|
||||||
|
"attachment_mime": "image/jpeg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** 404 если не найден; 410 если on-chain soft-deleted.
|
||||||
|
|
||||||
|
### `GET /feed/post/{id}/attachment`
|
||||||
|
|
||||||
|
Сырые байты аттачмента с корректным `Content-Type`. Используется
|
||||||
|
нативным image-loader'ом клиента:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<img src="http://node/feed/post/abc/attachment" />
|
||||||
|
```
|
||||||
|
|
||||||
|
`Cache-Control: public, max-age=3600, immutable` — content-addressed
|
||||||
|
ресурс, кэш безопасен.
|
||||||
|
|
||||||
|
### `POST /feed/post/{id}/view`
|
||||||
|
|
||||||
|
Инкремент off-chain счётчика просмотров. Fire-and-forget, не требует
|
||||||
|
подписи (per-view tx был бы нереалистичен).
|
||||||
|
|
||||||
|
**Response:** `{"post_id": "...", "views": 42}`
|
||||||
|
|
||||||
|
### `GET /feed/post/{id}/stats?me=<pub>`
|
||||||
|
|
||||||
|
Агрегат для отображения. `me` опционален — если передан, в ответе
|
||||||
|
будет `liked_by_me`.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"post_id": "...",
|
||||||
|
"views": 127,
|
||||||
|
"likes": 42,
|
||||||
|
"liked_by_me": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /feed/author/{pub}?limit=N&before=<ts>`
|
||||||
|
|
||||||
|
Посты конкретного автора, newest-first.
|
||||||
|
|
||||||
|
Пагинация: передавайте `before=<created_at>` самого старого уже
|
||||||
|
загруженного поста, чтобы получить следующую страницу.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"author": "<pub>", "count": 20, "posts": [FeedPostItem]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /feed/timeline?follower=<pub>&limit=N&before=<ts>`
|
||||||
|
|
||||||
|
Лента подписок: merged посты всех, на кого подписан follower, newest-
|
||||||
|
first. Требует, чтобы on-chain FOLLOW-транзакции уже скоммитились.
|
||||||
|
|
||||||
|
Пагинация через `before` идентично `/feed/author`.
|
||||||
|
|
||||||
|
### `GET /feed/trending?window=24&limit=N`
|
||||||
|
|
||||||
|
Топ постов за последние N часов, ранжированы по `likes × 3 + views`.
|
||||||
|
|
||||||
|
`window` — часы (1..168), по умолчанию 24.
|
||||||
|
|
||||||
|
Пагинация **не поддерживается** — это топ-срез, не список.
|
||||||
|
|
||||||
|
### `GET /feed/foryou?pub=<pub>&limit=N`
|
||||||
|
|
||||||
|
Рекомендации. Простая эвристика v2.0.0:
|
||||||
|
|
||||||
|
1. Кандидаты: посты последних 48 часов
|
||||||
|
2. Исключить: авторы, на которых уже подписан; уже лайкнутые; свои
|
||||||
|
3. Ранжировать: `likes × 3 + views + 1` (seed чтобы свежее вылезало)
|
||||||
|
|
||||||
|
Никаких ML — база для `v2.2.0 Feed algorithm` (half-life decay,
|
||||||
|
mutual-follow boost, hashtag-affinity).
|
||||||
|
|
||||||
|
### `GET /feed/hashtag/{tag}?limit=N`
|
||||||
|
|
||||||
|
Посты, помеченные хэштегом `#tag`. Tag case-insensitive.
|
||||||
|
Хэштеги авто-индексируются на `/feed/publish` из текста (regex
|
||||||
|
`#[A-Za-z0-9_\p{L}]{1,40}`, dedup, cap 8 per post).
|
||||||
|
|
||||||
|
## On-chain события
|
||||||
|
|
||||||
|
Клиент сабмитит эти транзакции через обычный `/api/tx` endpoint.
|
||||||
|
|
||||||
|
### `CREATE_POST`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "CREATE_POST",
|
||||||
|
"fee": <estimated_fee_ut>,
|
||||||
|
"payload": {
|
||||||
|
"post_id": "...",
|
||||||
|
"content_hash": "<base64 32-byte sha256>",
|
||||||
|
"size": 1234,
|
||||||
|
"hosting_relay": "<ed25519_hex>",
|
||||||
|
"reply_to": "",
|
||||||
|
"quote_of": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Валидации on-chain: `size > 0 && size <= MaxPostSize`,
|
||||||
|
`fee >= BasePostFee + size × PostByteFee`, `reply_to` и `quote_of`
|
||||||
|
взаимно исключают друг друга. Дубликат `post_id` отклоняется.
|
||||||
|
|
||||||
|
### `DELETE_POST`
|
||||||
|
```json
|
||||||
|
{"type": "DELETE_POST", "fee": 1000, "payload": {"post_id": "..."}}
|
||||||
|
```
|
||||||
|
Только автор может удалить. Soft-delete (post остаётся в chain, но
|
||||||
|
помечен `deleted=true`; читатели получают 410).
|
||||||
|
|
||||||
|
### `FOLLOW` / `UNFOLLOW`
|
||||||
|
```json
|
||||||
|
{"type": "FOLLOW", "to": "<target_ed25519>", "fee": 1000, "payload": {}}
|
||||||
|
```
|
||||||
|
Двусторонний on-chain индекс: `follow:<A>:<B>` + `followin:<B>:<A>`.
|
||||||
|
`UNFOLLOW` зеркально удаляет оба ключа.
|
||||||
|
|
||||||
|
### `LIKE_POST` / `UNLIKE_POST`
|
||||||
|
```json
|
||||||
|
{"type": "LIKE_POST", "fee": 1000, "payload": {"post_id": "..."}}
|
||||||
|
```
|
||||||
|
Дубль-лайк отклоняется. Кэшированный counter `likecount:<post>`
|
||||||
|
хранит O(1) счётчик.
|
||||||
|
|
||||||
|
## Pricing / economics (резюме)
|
||||||
|
|
||||||
|
| Операция | Cost | Куда уходит |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `CREATE_POST` | 1000 + size×1 µT | Hosting relay |
|
||||||
|
| `DELETE_POST` | 1000 µT | Block validator |
|
||||||
|
| `FOLLOW` / `UNFOLLOW` | 1000 µT | Block validator |
|
||||||
|
| `LIKE_POST` / `UNLIKE_POST` | 1000 µT | Block validator |
|
||||||
|
| `/feed/post/{id}/view` | 0 | Off-chain counter |
|
||||||
|
|
||||||
|
Причина такого разделения: основные бизнес-действия (публикация) едут
|
||||||
|
на «хостера контента», а мелкие социальные tx (лайк/подписка) — на
|
||||||
|
валидатора сети, как обычные tx.
|
||||||
|
|
||||||
|
## Клиентские вспомогательные функции
|
||||||
|
|
||||||
|
В `client-app/lib/feed.ts`:
|
||||||
|
- `publishPost(params)` — подпись + POST /feed/publish
|
||||||
|
- `publishAndCommit(params)` — publish + CREATE_POST tx в одну операцию
|
||||||
|
- `fetchTimeline`, `fetchForYou`, `fetchTrending`, `fetchAuthorPosts`,
|
||||||
|
`fetchHashtag`, `fetchPost`, `fetchStats`, `bumpView`
|
||||||
|
- `buildCreatePostTx`, `buildDeletePostTx`, `buildFollowTx`,
|
||||||
|
`buildUnfollowTx`, `buildLikePostTx`, `buildUnlikePostTx`
|
||||||
|
- `likePost`, `unlikePost`, `followUser`, `unfollowUser`, `deletePost`
|
||||||
|
(build + submitTx bundled)
|
||||||
@@ -1,28 +1,83 @@
|
|||||||
# Relay API
|
# Relay API
|
||||||
|
|
||||||
REST API для работы с шифрованными сообщениями через relay-сеть.
|
REST API для работы с зашифрованными 1:1-сообщениями через relay-сеть.
|
||||||
|
|
||||||
Сообщения шифруются E2E с использованием NaCl (X25519 + XSalsa20-Poly1305). Relay хранит зашифрованные конверты и доставляет их получателям.
|
Сообщения шифруются E2E с использованием NaCl (X25519 + XSalsa20-Poly1305).
|
||||||
|
Relay хранит зашифрованные конверты до 7 дней (настраиваемо через
|
||||||
|
`DCHAIN_MAILBOX_TTL_DAYS`) и доставляет их получателям по запросу или
|
||||||
|
через WebSocket push.
|
||||||
|
|
||||||
## Отправить сообщение
|
> **Это API для 1:1 чатов.** Для публичных постов и ленты — см.
|
||||||
|
> [`feed.md`](feed.md).
|
||||||
|
|
||||||
### `POST /relay/send`
|
Server-side guarantees (v1.0.x hardening):
|
||||||
|
- `envelope.ID` пересчитывается канонически (`sha256(nonce||ct)[:16]`)
|
||||||
|
на `mailbox.Store` — защита от content-replay.
|
||||||
|
- `envelope.SentAt` переписывается серверным `time.Now().Unix()` — клиент
|
||||||
|
не может back-date/future-date сообщения.
|
||||||
|
- `/relay/broadcast` и `/relay/inbox/*` обёрнуты в rate-limiter
|
||||||
|
(`withSubmitTxGuards` / `withReadLimit`); одиночный атакующий не может
|
||||||
|
через FIFO-eviction выбросить реальные сообщения получателя.
|
||||||
|
- `DELETE /relay/inbox/{id}` требует Ed25519-подпись владельца, связанную
|
||||||
|
с x25519-ключом через identity-реестр.
|
||||||
|
|
||||||
Зашифровать и отправить сообщение получателю.
|
## Опубликовать envelope
|
||||||
|
|
||||||
|
### `POST /relay/broadcast` — **рекомендованный E2E-путь**
|
||||||
|
|
||||||
|
Клиент запечатывает сообщение через NaCl `box` своим X25519-privkey на
|
||||||
|
публичный ключ получателя → отправляет готовый envelope. Сервер
|
||||||
|
проверяет размер, канонизирует id/timestamp, сохраняет в mailbox и
|
||||||
|
гошепит пирам.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"envelope": {
|
||||||
|
"id": "<hex>",
|
||||||
|
"sender_pub": "<x25519_hex>",
|
||||||
|
"recipient_pub": "<x25519_hex>",
|
||||||
|
"sender_ed25519_pub": "<ed25519_hex>",
|
||||||
|
"fee_ut": 0,
|
||||||
|
"fee_sig": null,
|
||||||
|
"nonce": "<base64 24B>",
|
||||||
|
"ciphertext": "<base64 NaCl box>",
|
||||||
|
"sent_at": 1710000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `{"id": "<canonical_id>", "status": "broadcast"}`
|
||||||
|
|
||||||
|
> Поле `envelope.id` сервер **перезапишет** на
|
||||||
|
> `hex(sha256(nonce||ciphertext)[:16])`; клиент может прислать любое
|
||||||
|
> непустое значение.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8081/relay/broadcast \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @envelope.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Отправить plaintext через релей (legacy / non-E2E)
|
||||||
|
|
||||||
|
### `POST /relay/send` — **⚠ НЕ E2E**
|
||||||
|
|
||||||
|
Нода запечатывает сообщение **своим** X25519-ключом. Получатель не
|
||||||
|
сможет расшифровать без знания privkey релея, и отправитель не
|
||||||
|
аутентифицируется. Оставлено для backward-compat; в продакшн-чатах
|
||||||
|
используйте `/relay/broadcast`.
|
||||||
|
|
||||||
**Request body:**
|
**Request body:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"recipient_pub": "<x25519_hex>",
|
"recipient_pub": "<x25519_hex>",
|
||||||
"msg_b64": "<base64_encoded_message>"
|
"msg_b64": "<base64_encoded_plaintext>"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Поле | Тип | Описание |
|
|
||||||
|------|-----|---------|
|
|
||||||
| `recipient_pub` | string | X25519 public key получателя (hex) |
|
|
||||||
| `msg_b64` | string | Сообщение в base64 |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MSG=$(echo -n "Hello, Bob!" | base64)
|
MSG=$(echo -n "Hello, Bob!" | base64)
|
||||||
curl -X POST http://localhost:8081/relay/send \
|
curl -X POST http://localhost:8081/relay/send \
|
||||||
@@ -30,22 +85,15 @@ curl -X POST http://localhost:8081/relay/send \
|
|||||||
-d "{\"recipient_pub\":\"$BOB_X25519\",\"msg_b64\":\"$MSG\"}"
|
-d "{\"recipient_pub\":\"$BOB_X25519\",\"msg_b64\":\"$MSG\"}"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:** `{"id": "env-abc123", "recipient_pub": "...", "status": "sent"}`
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "env-abc123",
|
|
||||||
"recipient_pub": "...",
|
|
||||||
"status": "sent"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Broadcast конверта
|
## Broadcast конверта (legacy alias)
|
||||||
|
|
||||||
### `POST /relay/broadcast`
|
### `POST /relay/broadcast`
|
||||||
|
|
||||||
Опубликовать pre-sealed конверт (для light-клиентов, которые шифруют на своей стороне).
|
См. выше. Это основной E2E-путь.
|
||||||
|
|
||||||
**Request body:**
|
**Request body:**
|
||||||
```json
|
```json
|
||||||
@@ -132,18 +180,35 @@ curl "http://localhost:8081/relay/inbox/count?pub=$MY_X25519"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `DELETE /relay/inbox/{envID}?pub=<hex>`
|
### `DELETE /relay/inbox/{envID}?pub=<x25519hex>`
|
||||||
|
|
||||||
Удалить сообщение из inbox.
|
Удалить сообщение из inbox. **Требует подписи** Ed25519-ключа, чья
|
||||||
|
identity связана с `?pub=<x25519>` через on-chain identity-реестр —
|
||||||
|
защита от grief-DELETE любым знающим ваш pubkey.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ed25519_pub": "<hex>",
|
||||||
|
"sig": "<base64 Ed25519 signature>",
|
||||||
|
"ts": 1710000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Где `sig = Ed25519.Sign(priv, "inbox-delete:<envID>:<x25519pub>:<ts>")`.
|
||||||
|
`ts` должен быть в пределах ±5 минут от серверного времени (anti-replay).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X DELETE "http://localhost:8081/relay/inbox/env-abc123?pub=$MY_X25519"
|
# подпись: printf 'inbox-delete:env-abc123:%s:%d' "$MY_X25519" "$TS" | sign
|
||||||
|
curl -X DELETE "http://localhost:8081/relay/inbox/env-abc123?pub=$MY_X25519" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"ed25519_pub\":\"$MY_ED25519\",\"sig\":\"$SIG\",\"ts\":$TS}"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:** `{"id": "env-abc123", "status": "deleted"}`
|
||||||
```json
|
|
||||||
{"id": "env-abc123", "status": "deleted"}
|
**Errors:** `403` если подпись/идентичность не совпадает; `503` если
|
||||||
```
|
на ноде не настроен identity-resolver (DCHAIN_DISABLE_INBOX_DELETE).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
|
|
||||||
DChain — это L1-блокчейн для децентрализованного мессенджера. Архитектура разделена на четыре слоя:
|
DChain — это L1-блокчейн для децентрализованного мессенджера + социальной ленты. Архитектура разделена на четыре слоя:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ L3 Application — messaging, usernames, auctions, escrow │
|
│ L3 Application — messaging, social feed, usernames, │
|
||||||
|
│ auctions, escrow │
|
||||||
│ (Smart contracts: username_registry, governance, auction, │
|
│ (Smart contracts: username_registry, governance, auction, │
|
||||||
│ escrow; deployed on-chain via DEPLOY_CONTRACT) │
|
│ escrow; native feed events; deployed on-chain) │
|
||||||
├─────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────┤
|
||||||
│ L2 Transport — relay mailbox, E2E NaCl encryption │
|
│ L2 Transport — relay mailbox (1:1 E2E) + │
|
||||||
│ (relay/, mailbox, GossipSub envelopes, RELAY_PROOF tx) │
|
│ feed mailbox (public posts + attachments)│
|
||||||
|
│ (relay/, media scrubber, GossipSub envelopes, RELAY_PROOF) │
|
||||||
├─────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────┤
|
||||||
│ L1 Chain — PBFT consensus, WASM VM, BadgerDB │
|
│ L1 Chain — PBFT consensus, WASM VM, BadgerDB │
|
||||||
│ (blockchain/, consensus/, vm/, identity/) │
|
│ (blockchain/, consensus/, vm/, identity/) │
|
||||||
@@ -189,6 +191,50 @@ Alice node1 (relay) Bob
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Социальная лента (v2.0.0)
|
||||||
|
|
||||||
|
Публичные посты в Twitter/VK-стиле. Заменила channel-модель (каналы
|
||||||
|
удалены полностью). Живёт гибридно — метаданные on-chain, тела в relay
|
||||||
|
feed-mailbox:
|
||||||
|
|
||||||
|
```
|
||||||
|
Alice node1 (hosting relay) Bob
|
||||||
|
│ │ │
|
||||||
|
│─ POST /feed/publish ──────▶│ │
|
||||||
|
│ (body + EXIF-scrub) │ │
|
||||||
|
│ │ store in feed-mailbox │
|
||||||
|
│ │ (TTL 30 days) │
|
||||||
|
│ │ │
|
||||||
|
│◀── response: content_hash, │ │
|
||||||
|
│ estimated_fee_ut, id │ │
|
||||||
|
│ │ │
|
||||||
|
│─ CREATE_POST tx ──────────▶│── PBFT commit ──────────▶ │
|
||||||
|
│ (fee = 1000 + size × 1) │ │
|
||||||
|
│ │ on-chain: post:<id> │
|
||||||
|
│ │ │
|
||||||
|
│ │ │─ GET /feed/timeline
|
||||||
|
│ │◀── merge followed authors │ (from chain)
|
||||||
|
│ │ │
|
||||||
|
│ │── GET /feed/post/{id} ───▶│
|
||||||
|
│ │ (body from mailbox) │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Экономика:**
|
||||||
|
- Автор платит `BasePostFee(1000) + size × PostByteFee(1)` µT
|
||||||
|
- Вся плата уходит `hosting_relay` пубкею — компенсация за хранение
|
||||||
|
- `MaxPostSize = 256 KiB` — hard cap, защищает мейлбокс от abuse
|
||||||
|
|
||||||
|
**Метаданные:**
|
||||||
|
- EXIF/GPS/camera-info удаляется обязательно на сервере (in-proc для
|
||||||
|
изображений, через FFmpeg-сайдкар для видео)
|
||||||
|
- Лайки / follows — on-chain транзакции (provable + anti-Sybil fee)
|
||||||
|
- Просмотры — off-chain counter в relay (on-chain было бы абсурдно)
|
||||||
|
- Хэштеги — авто-индекс при publish, inverted-index в BadgerDB
|
||||||
|
|
||||||
|
Детали — [api/feed.md](api/feed.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Динамические валидаторы
|
## Динамические валидаторы
|
||||||
|
|
||||||
Сет валидаторов хранится on-chain в `validator:<pubkey>`. Любой текущий валидатор может добавить или убрать другого (ADD_VALIDATOR / REMOVE_VALIDATOR tx). После commit такого блока PBFT-движок перезагружает сет без рестарта ноды.
|
Сет валидаторов хранится on-chain в `validator:<pubkey>`. Любой текущий валидатор может добавить или убрать другого (ADD_VALIDATOR / REMOVE_VALIDATOR tx). После commit такого блока PBFT-движок перезагружает сет без рестарта ноды.
|
||||||
|
|||||||
@@ -147,8 +147,12 @@ txchron:<block20d>:<seq04d> → tx_id (recent-tx index)
|
|||||||
balance:<pubkey> → uint64 (µT)
|
balance:<pubkey> → uint64 (µT)
|
||||||
stake:<pubkey> → uint64 (µT)
|
stake:<pubkey> → uint64 (µT)
|
||||||
id:<pubkey> → JSON RegisterKeyPayload
|
id:<pubkey> → JSON RegisterKeyPayload
|
||||||
chan:<channelID> → JSON CreateChannelPayload
|
post:<postID> → JSON PostRecord (v2.0.0 social feed)
|
||||||
chan-member:<ch>:<pub> → ""
|
postbyauthor:<pub>:<ts>:<id> → "" (chrono index, newest-first scan)
|
||||||
|
follow:<A>:<B> → "" (A follows B)
|
||||||
|
followin:<B>:<A> → "" (reverse index for Followers())
|
||||||
|
like:<postID>:<liker> → "" (presence)
|
||||||
|
likecount:<postID> → uint64 (cached counter, O(1) reads)
|
||||||
contract:<contractID> → JSON ContractRecord
|
contract:<contractID> → JSON ContractRecord
|
||||||
cstate:<contractID>:<key> → bytes
|
cstate:<contractID>:<key> → bytes
|
||||||
clog:<ct>:<block>:<seq> → JSON ContractLogEntry
|
clog:<ct>:<block>:<seq> → JSON ContractLogEntry
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ curl -s http://localhost:8080/api/well-known-version | jq .
|
|||||||
},
|
},
|
||||||
"protocol_version": 1,
|
"protocol_version": 1,
|
||||||
"features": [
|
"features": [
|
||||||
"access_token", "chain_id", "channels_v1", "contract_logs",
|
"access_token", "chain_id", "contract_logs",
|
||||||
"fan_out", "identity_registry", "native_username_registry",
|
"fan_out", "feed_v2", "identity_registry", "media_scrub",
|
||||||
"onboarding_api", "payment_channels", "relay_mailbox",
|
"native_username_registry", "onboarding_api", "payment_channels",
|
||||||
"ws_submit_tx"
|
"relay_broadcast", "relay_mailbox", "ws_submit_tx"
|
||||||
],
|
],
|
||||||
"chain_id": "dchain-ddb9a7e37fc8"
|
"chain_id": "dchain-ddb9a7e37fc8"
|
||||||
}
|
}
|
||||||
|
|||||||
21
go.mod
21
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module go-blockchain
|
module go-blockchain
|
||||||
|
|
||||||
go 1.21
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dgraph-io/badger/v4 v4.2.0
|
github.com/dgraph-io/badger/v4 v4.2.0
|
||||||
@@ -9,7 +9,12 @@ require (
|
|||||||
github.com/libp2p/go-libp2p-pubsub v0.10.0
|
github.com/libp2p/go-libp2p-pubsub v0.10.0
|
||||||
github.com/multiformats/go-multiaddr v0.12.3
|
github.com/multiformats/go-multiaddr v0.12.3
|
||||||
github.com/tetratelabs/wazero v1.7.3
|
github.com/tetratelabs/wazero v1.7.3
|
||||||
golang.org/x/crypto v0.18.0
|
golang.org/x/crypto v0.49.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/image v0.39.0
|
||||||
|
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -114,12 +119,12 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.26.0 // indirect
|
go.uber.org/zap v1.26.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||||
golang.org/x/mod v0.13.0 // indirect
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
golang.org/x/net v0.17.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.4.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.16.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
golang.org/x/tools v0.14.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
gonum.org/v1/gonum v0.13.0 // indirect
|
gonum.org/v1/gonum v0.13.0 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
lukechampine.com/blake3 v1.2.1 // indirect
|
lukechampine.com/blake3 v1.2.1 // indirect
|
||||||
|
|||||||
36
go.sum
36
go.sum
@@ -123,8 +123,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
|
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
|
||||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||||
@@ -443,11 +443,13 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||||||
golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
|
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||||
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
@@ -459,8 +461,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
|
|||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -479,8 +481,8 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
@@ -494,8 +496,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -517,15 +519,17 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||||
|
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -545,8 +549,8 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK
|
|||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
332
media/scrub.go
Normal file
332
media/scrub.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
// Package media contains metadata scrubbing and re-compression helpers for
|
||||||
|
// files uploaded to the social feed.
|
||||||
|
//
|
||||||
|
// Why this exists
|
||||||
|
// ---------------
|
||||||
|
// Every image file carries an EXIF block that can leak:
|
||||||
|
// - GPS coordinates where the photo was taken
|
||||||
|
// - Camera model, serial number, lens
|
||||||
|
// - Original timestamp (even if the user clears their clock)
|
||||||
|
// - Software name / version
|
||||||
|
// - Author / copyright fields
|
||||||
|
// - A small embedded thumbnail that may leak even after cropping
|
||||||
|
//
|
||||||
|
// Videos and audio have analogous containers (MOV/MP4 atoms, ID3 tags,
|
||||||
|
// Matroska tags). For a social feed that prides itself on privacy we
|
||||||
|
// can't trust the client to have stripped all of it — we scrub again
|
||||||
|
// on the server before persisting the file to the feed mailbox.
|
||||||
|
//
|
||||||
|
// Strategy
|
||||||
|
// --------
|
||||||
|
// Images: decode → strip any ICC profile → re-encode with the stdlib
|
||||||
|
// JPEG/PNG encoders. These encoders DO NOT emit EXIF, so re-encoding is
|
||||||
|
// a complete scrub by construction. Output is JPEG (quality 75) unless
|
||||||
|
// the input is a lossless PNG small enough to keep as PNG.
|
||||||
|
//
|
||||||
|
// Videos: require an external ffmpeg worker (the "media sidecar") —
|
||||||
|
// cannot do this in pure Go without a huge CGo footprint. A tiny HTTP
|
||||||
|
// contract (see docs/media-sidecar.md) lets node operators plug in
|
||||||
|
// compressO-like services behind an env var. If the sidecar is not
|
||||||
|
// configured, videos are stored as-is with a LOG WARNING — the operator
|
||||||
|
// decides whether to accept that risk.
|
||||||
|
//
|
||||||
|
// Magic-byte detection: the claimed Content-Type must match what's
|
||||||
|
// actually in the bytes; mismatches are rejected (prevents a PDF
|
||||||
|
// labelled as image/jpeg from bypassing the scrubber).
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
// Register decoders for the formats we accept.
|
||||||
|
_ "image/gif"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors returned by scrubber.
|
||||||
|
var (
|
||||||
|
// ErrUnsupportedMIME is returned when the caller claims a MIME we
|
||||||
|
// don't know how to scrub.
|
||||||
|
ErrUnsupportedMIME = errors.New("unsupported media type")
|
||||||
|
|
||||||
|
// ErrMIMEMismatch is returned when the bytes don't match the claimed
|
||||||
|
// MIME — blocks a crafted upload from bypassing the scrubber.
|
||||||
|
ErrMIMEMismatch = errors.New("actual bytes don't match claimed content-type")
|
||||||
|
|
||||||
|
// ErrSidecarUnavailable is returned when video scrubbing was required
|
||||||
|
// but no external worker is configured and the operator policy does
|
||||||
|
// not allow unscrubbed video storage.
|
||||||
|
ErrSidecarUnavailable = errors.New("media sidecar required for video scrubbing but not configured")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Image scrubbing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ImageMaxDim caps the larger dimension of a stored image. 1080px is the
|
||||||
|
// "full-HD-ish" sweet spot — larger rarely matters on a phone feed and
|
||||||
|
// drops file size dramatically. The client is expected to have downscaled
|
||||||
|
// already (expo-image-manipulator), but we re-apply the cap server-side
|
||||||
|
// as a defence-in-depth and to guarantee uniform storage cost.
|
||||||
|
const ImageMaxDim = 1080
|
||||||
|
|
||||||
|
// ImageJPEGQuality is the re-encode quality for JPEG output. 75 balances
|
||||||
|
// perceived quality with size — below 60 artifacts become visible, above
|
||||||
|
// 85 we're paying for noise we can't see.
|
||||||
|
const ImageJPEGQuality = 75
|
||||||
|
|
||||||
|
// ScrubImage decodes src, removes all metadata (by way of re-encoding
|
||||||
|
// with the stdlib JPEG encoder), optionally downscales to ImageMaxDim,
|
||||||
|
// and returns the clean JPEG bytes + the canonical MIME the caller
|
||||||
|
// should store.
|
||||||
|
//
|
||||||
|
// claimedMIME is what the client said the file is; if the bytes don't
|
||||||
|
// match, ErrMIMEMismatch is returned. Accepts image/jpeg, image/png,
|
||||||
|
// image/gif, image/webp on input; output is always image/jpeg (one less
|
||||||
|
// branch in the reader, and no decoder has to touch EXIF).
|
||||||
|
func ScrubImage(src []byte, claimedMIME string) (out []byte, outMIME string, err error) {
|
||||||
|
actualMIME := detectMIME(src)
|
||||||
|
if !isImageMIME(actualMIME) {
|
||||||
|
return nil, "", fmt.Errorf("%w: %s", ErrUnsupportedMIME, actualMIME)
|
||||||
|
}
|
||||||
|
if claimedMIME != "" && !mimesCompatible(claimedMIME, actualMIME) {
|
||||||
|
return nil, "", fmt.Errorf("%w: claimed %s, actual %s",
|
||||||
|
ErrMIMEMismatch, claimedMIME, actualMIME)
|
||||||
|
}
|
||||||
|
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(src))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("decode image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downscale if needed. We use a draw-based nearest-neighbour style
|
||||||
|
// approach via stdlib to avoid pulling in x/image/draw unless we need
|
||||||
|
// higher-quality resampling. For feed thumbnails nearest is fine since
|
||||||
|
// content is typically downsampled already.
|
||||||
|
if bounds := img.Bounds(); bounds.Dx() > ImageMaxDim || bounds.Dy() > ImageMaxDim {
|
||||||
|
img = downscale(img, ImageMaxDim)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-encode as JPEG. stdlib's jpeg.Encode writes ZERO metadata —
|
||||||
|
// no EXIF, no ICC, no XMP, no MakerNote. That's the scrub.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: ImageJPEGQuality}); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("encode jpeg: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), "image/jpeg", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downscale returns a new image whose larger dimension equals maxDim,
|
||||||
|
// preserving aspect ratio. Uses stdlib image.NewRGBA + a nearest-neighbour
|
||||||
|
// copy loop — good enough for feed images that are already compressed.
|
||||||
|
func downscale(src image.Image, maxDim int) image.Image {
|
||||||
|
b := src.Bounds()
|
||||||
|
w, h := b.Dx(), b.Dy()
|
||||||
|
var nw, nh int
|
||||||
|
if w >= h {
|
||||||
|
nw = maxDim
|
||||||
|
nh = h * maxDim / w
|
||||||
|
} else {
|
||||||
|
nh = maxDim
|
||||||
|
nw = w * maxDim / h
|
||||||
|
}
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||||
|
for y := 0; y < nh; y++ {
|
||||||
|
sy := b.Min.Y + y*h/nh
|
||||||
|
for x := 0; x < nw; x++ {
|
||||||
|
sx := b.Min.X + x*w/nw
|
||||||
|
dst.Set(x, y, src.At(sx, sy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// pngEncoder is kept for callers that explicitly want lossless output
|
||||||
|
// (future — not used by ScrubImage which always produces JPEG).
|
||||||
|
var pngEncoder = png.Encoder{CompressionLevel: png.BestCompression}
|
||||||
|
|
||||||
|
// ── MIME detection & validation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// detectMIME inspects magic bytes to figure out what the data actually is,
|
||||||
|
// independent of what the caller claimed. Matches the subset of types
|
||||||
|
// stdlib http.DetectContentType handles, refined for our use.
|
||||||
|
func detectMIME(data []byte) string {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// http.DetectContentType handles most formats correctly (JPEG, PNG,
|
||||||
|
// GIF, WebP, MP4, WebM, MP3, OGG). We only refine when needed.
|
||||||
|
return strings.SplitN(http.DetectContentType(data), ";", 2)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageMIME(m string) bool {
|
||||||
|
switch m {
|
||||||
|
case "image/jpeg", "image/png", "image/gif", "image/webp":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVideoMIME(m string) bool {
|
||||||
|
switch m {
|
||||||
|
case "video/mp4", "video/webm", "video/quicktime":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAudioMIME(m string) bool {
|
||||||
|
switch m {
|
||||||
|
case "audio/mpeg", "audio/ogg", "audio/webm", "audio/wav", "audio/mp4":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimesCompatible tolerates small aliases (image/jpg vs image/jpeg, etc.)
|
||||||
|
// so a misspelled client header doesn't cause a 400. Claimed MIME is
|
||||||
|
// the caller's; actual is from magic bytes — we trust magic bytes when
|
||||||
|
// they disagree with a known-silly alias.
|
||||||
|
func mimesCompatible(claimed, actual string) bool {
|
||||||
|
claimed = strings.ToLower(strings.TrimSpace(claimed))
|
||||||
|
if claimed == actual {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
aliases := map[string]string{
|
||||||
|
"image/jpg": "image/jpeg",
|
||||||
|
"image/x-png": "image/png",
|
||||||
|
"video/mov": "video/quicktime",
|
||||||
|
}
|
||||||
|
if canon, ok := aliases[claimed]; ok && canon == actual {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Video scrubbing (sidecar) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SidecarConfig describes how to reach an external media scrubber worker
|
||||||
|
// (typically a tiny FFmpeg-wrapper HTTP service running alongside the
|
||||||
|
// node — see docs/media-sidecar.md). Leaving URL empty disables sidecar
|
||||||
|
// use; callers then decide whether to fall back to "store as-is and warn"
|
||||||
|
// or to reject video uploads entirely.
|
||||||
|
type SidecarConfig struct {
|
||||||
|
// URL is the base URL of the sidecar. Expected routes:
|
||||||
|
//
|
||||||
|
// POST /scrub/video body: raw bytes → returns scrubbed bytes
|
||||||
|
// POST /scrub/audio body: raw bytes → returns scrubbed bytes
|
||||||
|
//
|
||||||
|
// Both MUST strip metadata (-map_metadata -1 in ffmpeg terms) and
|
||||||
|
// re-encode with a sane bitrate cap (default: H.264 CRF 28 for
|
||||||
|
// video, libopus 96k for audio). See the reference implementation
|
||||||
|
// at docker/media-sidecar/ in this repo.
|
||||||
|
URL string
|
||||||
|
|
||||||
|
// Timeout guards against a hung sidecar. 30s is enough for a 5 MB
|
||||||
|
// video on modest hardware; larger inputs should be pre-compressed
|
||||||
|
// by the client.
|
||||||
|
Timeout time.Duration
|
||||||
|
|
||||||
|
// MaxInputBytes caps what we forward to the sidecar (protects
|
||||||
|
// against an attacker tying up the sidecar on a 1 GB upload).
|
||||||
|
MaxInputBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrubber bundles image + sidecar capabilities. Create once at node
|
||||||
|
// startup and reuse.
|
||||||
|
type Scrubber struct {
|
||||||
|
sidecar SidecarConfig
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScrubber returns a Scrubber. sidecar.URL may be empty (image-only
|
||||||
|
// mode) — in that case ScrubVideo / ScrubAudio return ErrSidecarUnavailable.
|
||||||
|
func NewScrubber(sidecar SidecarConfig) *Scrubber {
|
||||||
|
if sidecar.Timeout == 0 {
|
||||||
|
sidecar.Timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
if sidecar.MaxInputBytes == 0 {
|
||||||
|
sidecar.MaxInputBytes = 16 * 1024 * 1024 // 16 MiB input → client should have shrunk
|
||||||
|
}
|
||||||
|
return &Scrubber{
|
||||||
|
sidecar: sidecar,
|
||||||
|
http: &http.Client{
|
||||||
|
Timeout: sidecar.Timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub picks the right strategy based on the actual MIME of the bytes.
|
||||||
|
// Returns the cleaned payload and the canonical MIME to store under.
|
||||||
|
func (s *Scrubber) Scrub(ctx context.Context, src []byte, claimedMIME string) ([]byte, string, error) {
|
||||||
|
actual := detectMIME(src)
|
||||||
|
if claimedMIME != "" && !mimesCompatible(claimedMIME, actual) {
|
||||||
|
return nil, "", fmt.Errorf("%w: claimed %s, actual %s",
|
||||||
|
ErrMIMEMismatch, claimedMIME, actual)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case isImageMIME(actual):
|
||||||
|
// Images handled in-process, no sidecar needed.
|
||||||
|
return ScrubImage(src, claimedMIME)
|
||||||
|
case isVideoMIME(actual):
|
||||||
|
return s.scrubViaSidecar(ctx, "/scrub/video", src, actual)
|
||||||
|
case isAudioMIME(actual):
|
||||||
|
return s.scrubViaSidecar(ctx, "/scrub/audio", src, actual)
|
||||||
|
default:
|
||||||
|
return nil, "", fmt.Errorf("%w: %s", ErrUnsupportedMIME, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrubViaSidecar POSTs src to the configured sidecar route and returns
|
||||||
|
// the response bytes. Errors:
|
||||||
|
// - ErrSidecarUnavailable if sidecar.URL is empty
|
||||||
|
// - wrapping the HTTP error otherwise
|
||||||
|
func (s *Scrubber) scrubViaSidecar(ctx context.Context, path string, src []byte, actual string) ([]byte, string, error) {
|
||||||
|
if s.sidecar.URL == "" {
|
||||||
|
return nil, "", ErrSidecarUnavailable
|
||||||
|
}
|
||||||
|
if int64(len(src)) > s.sidecar.MaxInputBytes {
|
||||||
|
return nil, "", fmt.Errorf("input exceeds sidecar max %d bytes", s.sidecar.MaxInputBytes)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
strings.TrimRight(s.sidecar.URL, "/")+path, bytes.NewReader(src))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build sidecar request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", actual)
|
||||||
|
resp, err := s.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("call sidecar: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return nil, "", fmt.Errorf("sidecar returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
// Limit the reply we buffer — an evil sidecar could try to amplify.
|
||||||
|
const maxReply = 64 * 1024 * 1024 // 64 MiB hard cap
|
||||||
|
out, err := io.ReadAll(io.LimitReader(resp.Body, maxReply))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("read sidecar reply: %w", err)
|
||||||
|
}
|
||||||
|
respMIME := resp.Header.Get("Content-Type")
|
||||||
|
if respMIME == "" {
|
||||||
|
respMIME = actual
|
||||||
|
}
|
||||||
|
return out, strings.SplitN(respMIME, ";", 2)[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSidecarConfigured reports whether video/audio scrubbing is available.
|
||||||
|
// Callers can use this to decide whether to accept video attachments or
|
||||||
|
// reject them with a clear "this node doesn't support video" message.
|
||||||
|
func (s *Scrubber) IsSidecarConfigured() bool {
|
||||||
|
return s.sidecar.URL != ""
|
||||||
|
}
|
||||||
149
media/scrub_test.go
Normal file
149
media/scrub_test.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestScrubImageRemovesEXIF: our scrubber re-encodes via stdlib JPEG, which
|
||||||
|
// does not preserve EXIF by construction. We verify that a crafted input
|
||||||
|
// carrying an EXIF marker produces an output without one.
|
||||||
|
func TestScrubImageRemovesEXIF(t *testing.T) {
|
||||||
|
// Build a JPEG that explicitly contains an APP1 EXIF segment.
|
||||||
|
// Structure: JPEG SOI + APP1 with "Exif\x00\x00" header + real image data.
|
||||||
|
var base bytes.Buffer
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
|
||||||
|
for y := 0; y < 8; y++ {
|
||||||
|
for x := 0; x < 8; x++ {
|
||||||
|
img.Set(x, y, color.RGBA{uint8(x * 32), uint8(y * 32), 128, 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := jpeg.Encode(&base, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||||
|
t.Fatalf("encode base: %v", err)
|
||||||
|
}
|
||||||
|
input := injectEXIF(t, base.Bytes())
|
||||||
|
|
||||||
|
if !bytes.Contains(input, []byte("Exif\x00\x00")) {
|
||||||
|
t.Fatalf("test setup broken: EXIF not injected")
|
||||||
|
}
|
||||||
|
// Also drop an identifiable string in the EXIF payload so we can prove
|
||||||
|
// it's gone.
|
||||||
|
if !bytes.Contains(input, []byte("SECRETGPS")) {
|
||||||
|
t.Fatalf("test setup broken: EXIF marker not injected")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned, mime, err := ScrubImage(input, "image/jpeg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScrubImage: %v", err)
|
||||||
|
}
|
||||||
|
if mime != "image/jpeg" {
|
||||||
|
t.Errorf("mime: got %q, want image/jpeg", mime)
|
||||||
|
}
|
||||||
|
// Verify the scrubbed output doesn't contain our canary string.
|
||||||
|
if bytes.Contains(cleaned, []byte("SECRETGPS")) {
|
||||||
|
t.Errorf("EXIF canary survived scrub — metadata not stripped")
|
||||||
|
}
|
||||||
|
// Verify the output doesn't contain the EXIF segment marker.
|
||||||
|
if bytes.Contains(cleaned, []byte("Exif\x00\x00")) {
|
||||||
|
t.Errorf("EXIF header string survived scrub")
|
||||||
|
}
|
||||||
|
// Output must still be a valid JPEG.
|
||||||
|
if _, err := jpeg.Decode(bytes.NewReader(cleaned)); err != nil {
|
||||||
|
t.Errorf("scrubbed output is not a valid JPEG: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectEXIF splices a synthetic APP1 EXIF segment after the JPEG SOI.
|
||||||
|
// Segment layout: FF E1 <len_hi> <len_lo> "Exif\0\0" + arbitrary payload.
|
||||||
|
// The payload is NOT valid TIFF — that's fine; stdlib JPEG decoder skips
|
||||||
|
// unknown APP1 segments rather than aborting.
|
||||||
|
func injectEXIF(t *testing.T, src []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
if len(src) < 2 || src[0] != 0xFF || src[1] != 0xD8 {
|
||||||
|
t.Fatalf("not a JPEG")
|
||||||
|
}
|
||||||
|
payload := []byte("Exif\x00\x00" + "SECRETGPS-51.5074N-0.1278W-Canon-EOS-R5")
|
||||||
|
segmentLen := len(payload) + 2 // +2 = 2 bytes of len field itself
|
||||||
|
var seg bytes.Buffer
|
||||||
|
seg.Write([]byte{0xFF, 0xE1})
|
||||||
|
seg.WriteByte(byte(segmentLen >> 8))
|
||||||
|
seg.WriteByte(byte(segmentLen & 0xff))
|
||||||
|
seg.Write(payload)
|
||||||
|
out := make([]byte, 0, len(src)+seg.Len())
|
||||||
|
out = append(out, src[:2]...) // SOI
|
||||||
|
out = append(out, seg.Bytes()...)
|
||||||
|
out = append(out, src[2:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScrubImageMIMEMismatch: rejects bytes that don't match claimed MIME.
|
||||||
|
func TestScrubImageMIMEMismatch(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||||
|
jpeg.Encode(&buf, img, nil)
|
||||||
|
// Claim it's a PNG.
|
||||||
|
_, _, err := ScrubImage(buf.Bytes(), "image/png")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected ErrMIMEMismatch, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScrubImageDownscale: images over ImageMaxDim are shrunk.
|
||||||
|
func TestScrubImageDownscale(t *testing.T) {
|
||||||
|
// Make a 2000×1000 image — larger dim 2000 > 1080.
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 2000, 1000))
|
||||||
|
for y := 0; y < 1000; y++ {
|
||||||
|
for x := 0; x < 2000; x++ {
|
||||||
|
img.Set(x, y, color.RGBA{128, 64, 200, 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
cleaned, _, err := ScrubImage(buf.Bytes(), "image/jpeg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScrubImage: %v", err)
|
||||||
|
}
|
||||||
|
decoded, err := jpeg.Decode(bytes.NewReader(cleaned))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode scrubbed: %v", err)
|
||||||
|
}
|
||||||
|
b := decoded.Bounds()
|
||||||
|
if b.Dx() > ImageMaxDim || b.Dy() > ImageMaxDim {
|
||||||
|
t.Errorf("not downscaled: got %dx%d, want max %d", b.Dx(), b.Dy(), ImageMaxDim)
|
||||||
|
}
|
||||||
|
// Aspect ratio roughly preserved (2:1 → 1080:540 with rounding slack).
|
||||||
|
if b.Dx() != ImageMaxDim {
|
||||||
|
t.Errorf("larger dim: got %d, want %d", b.Dx(), ImageMaxDim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectMIME: a few magic-byte cases to ensure magic detection works.
|
||||||
|
func TestDetectMIME(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
data []byte
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]byte("\xff\xd8\xff\xe0garbage"), "image/jpeg"},
|
||||||
|
{[]byte("\x89PNG\r\n\x1a\n..."), "image/png"},
|
||||||
|
{[]byte("GIF89a..."), "image/gif"},
|
||||||
|
{[]byte{}, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := detectMIME(tc.data)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("detectMIME(%q): got %q want %q", string(tc.data[:min(len(tc.data), 12)]), got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
// Package node — channel endpoints.
|
|
||||||
//
|
|
||||||
// `/api/channels/:id/members` returns every Ed25519 pubkey registered as a
|
|
||||||
// channel member together with their current X25519 pubkey (from the
|
|
||||||
// identity registry). Clients sealing a message to a channel iterate this
|
|
||||||
// list and call relay.Seal once per recipient — that's the "fan-out"
|
|
||||||
// group-messaging model (R1 in the roadmap).
|
|
||||||
//
|
|
||||||
// Why enrich with X25519 here rather than making the client do it?
|
|
||||||
// - One HTTP round trip vs N. At 10+ members the latency difference is
|
|
||||||
// significant over mobile networks.
|
|
||||||
// - The server already holds the identity state; no extra DB hops.
|
|
||||||
// - Clients get a stable, already-joined view — if a member hasn't
|
|
||||||
// published an X25519 key yet, we return them with `x25519_pub_key=""`
|
|
||||||
// so the caller knows to skip or retry later.
|
|
||||||
package node
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"go-blockchain/blockchain"
|
|
||||||
"go-blockchain/wallet"
|
|
||||||
)
|
|
||||||
|
|
||||||
func registerChannelAPI(mux *http.ServeMux, q ExplorerQuery) {
|
|
||||||
// GET /api/channels/{id} → channel metadata
|
|
||||||
// GET /api/channels/{id}/members → enriched member list
|
|
||||||
//
|
|
||||||
// One HandleFunc deals with both by sniffing the path suffix.
|
|
||||||
mux.HandleFunc("/api/channels/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/channels/")
|
|
||||||
path = strings.Trim(path, "/")
|
|
||||||
if path == "" {
|
|
||||||
jsonErr(w, fmt.Errorf("channel id required"), 400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(path, "/members"):
|
|
||||||
id := strings.TrimSuffix(path, "/members")
|
|
||||||
handleChannelMembers(w, q, id)
|
|
||||||
default:
|
|
||||||
handleChannelInfo(w, q, path)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleChannelInfo(w http.ResponseWriter, q ExplorerQuery, channelID string) {
|
|
||||||
if q.GetChannel == nil {
|
|
||||||
jsonErr(w, fmt.Errorf("channel queries not configured"), 503)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch, err := q.GetChannel(channelID)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, err, 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ch == nil {
|
|
||||||
jsonErr(w, fmt.Errorf("channel %s not found", channelID), 404)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonOK(w, ch)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleChannelMembers(w http.ResponseWriter, q ExplorerQuery, channelID string) {
|
|
||||||
if q.GetChannelMembers == nil {
|
|
||||||
jsonErr(w, fmt.Errorf("channel queries not configured"), 503)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pubs, err := q.GetChannelMembers(channelID)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, err, 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out := make([]blockchain.ChannelMember, 0, len(pubs))
|
|
||||||
for _, pub := range pubs {
|
|
||||||
member := blockchain.ChannelMember{
|
|
||||||
PubKey: pub,
|
|
||||||
Address: wallet.PubKeyToAddress(pub),
|
|
||||||
}
|
|
||||||
// Best-effort X25519 lookup — skip silently on miss so a member
|
|
||||||
// who hasn't published their identity yet doesn't prevent the
|
|
||||||
// whole list from returning. The sender will just skip them on
|
|
||||||
// fan-out and retry later (after that member does register).
|
|
||||||
if q.IdentityInfo != nil {
|
|
||||||
if info, err := q.IdentityInfo(pub); err == nil && info != nil {
|
|
||||||
member.X25519PubKey = info.X25519Pub
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, member)
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]any{
|
|
||||||
"channel_id": channelID,
|
|
||||||
"count": len(out),
|
|
||||||
"members": out,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -38,6 +38,20 @@ func queryInt(r *http.Request, key string, def int) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryInt64 reads a non-negative int64 query param — typically a unix
|
||||||
|
// timestamp cursor for pagination. Returns def when missing or invalid.
|
||||||
|
func queryInt64(r *http.Request, key string, def int64) int64 {
|
||||||
|
s := r.URL.Query().Get(key)
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// queryIntMin0 parses a query param as a non-negative integer; returns 0 if absent or invalid.
|
// queryIntMin0 parses a query param as a non-negative integer; returns 0 if absent or invalid.
|
||||||
func queryIntMin0(r *http.Request, key string) int {
|
func queryIntMin0(r *http.Request, key string) int {
|
||||||
s := r.URL.Query().Get(key)
|
s := r.URL.Query().Get(key)
|
||||||
|
|||||||
775
node/api_feed.go
Normal file
775
node/api_feed.go
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
package node
|
||||||
|
|
||||||
|
// Feed HTTP endpoints (v2.0.0).
|
||||||
|
//
|
||||||
|
// Mount points:
|
||||||
|
//
|
||||||
|
// POST /feed/publish — store a post body (authenticated)
|
||||||
|
// GET /feed/post/{id} — fetch a post body
|
||||||
|
// GET /feed/post/{id}/stats — {views, likes, liked_by_me?} aggregate
|
||||||
|
// POST /feed/post/{id}/view — increment off-chain view counter
|
||||||
|
// GET /feed/author/{pub} — ?limit=N, posts by an author
|
||||||
|
// GET /feed/timeline — ?follower=<pub>&limit=N, merged feed of follows
|
||||||
|
// GET /feed/trending — ?window=h&limit=N, top by likes + views
|
||||||
|
// GET /feed/foryou — ?pub=<pub>&limit=N, recommendations
|
||||||
|
// GET /feed/hashtag/{tag} — posts matching a hashtag
|
||||||
|
//
|
||||||
|
// Publish flow:
|
||||||
|
// 1. Client POSTs {content, attachment, post_id, author, sig, ts}.
|
||||||
|
// 2. Node verifies sig (Ed25519 over canonical bytes), hashes body,
|
||||||
|
// stores in FeedMailbox, returns hosting_relay + content_hash + size.
|
||||||
|
// 3. Client then submits on-chain CREATE_POST tx with that metadata.
|
||||||
|
// Node charges the fee (base + size×byte_fee) and credits the relay.
|
||||||
|
// 4. Subsequent GET /feed/post/{id} serves the stored body to anyone.
|
||||||
|
//
|
||||||
|
// Why the split? On-chain metadata gives us provable authorship + the
|
||||||
|
// pay-for-storage incentive; off-chain body storage keeps the block
|
||||||
|
// history small. If the hosting relay dies, the on-chain record stays
|
||||||
|
// (with a "body unavailable" fallback on the reader side) — authors can
|
||||||
|
// re-publish to another relay.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-blockchain/blockchain"
|
||||||
|
"go-blockchain/identity"
|
||||||
|
"go-blockchain/media"
|
||||||
|
"go-blockchain/relay"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedConfig wires feed HTTP endpoints to the relay mailbox and the
|
||||||
|
// chain for read-after-write queries.
|
||||||
|
type FeedConfig struct {
|
||||||
|
Mailbox *relay.FeedMailbox
|
||||||
|
|
||||||
|
// HostingRelayPub is this node's Ed25519 pubkey — returned from
|
||||||
|
// /feed/publish so the client knows who to put in CREATE_POST tx.
|
||||||
|
HostingRelayPub string
|
||||||
|
|
||||||
|
// Scrubber strips metadata from image/video/audio attachments before
|
||||||
|
// they are stored. MUST be non-nil; a zero Scrubber (NewScrubber with
|
||||||
|
// empty sidecar URL) still handles images in-process — only video/audio
|
||||||
|
// require sidecar config.
|
||||||
|
Scrubber *media.Scrubber
|
||||||
|
|
||||||
|
// AllowUnscrubbedVideo controls server behaviour when a video upload
|
||||||
|
// arrives and no sidecar is configured. false (default) → reject; true
|
||||||
|
// → store as-is with a warning log. Set via --allow-unscrubbed-video
|
||||||
|
// flag on the node. Leave false in production.
|
||||||
|
AllowUnscrubbedVideo bool
|
||||||
|
|
||||||
|
// Chain lookups (nil-safe; endpoints degrade gracefully).
|
||||||
|
GetPost func(postID string) (*blockchain.PostRecord, error)
|
||||||
|
LikeCount func(postID string) (uint64, error)
|
||||||
|
HasLiked func(postID, likerPub string) (bool, error)
|
||||||
|
PostsByAuthor func(authorPub string, beforeTs int64, limit int) ([]*blockchain.PostRecord, error)
|
||||||
|
Following func(followerPub string) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterFeedRoutes wires feed endpoints onto mux. Writes are rate-limited
|
||||||
|
// via withSubmitTxGuards; reads via withReadLimit (same limiters as /relay).
|
||||||
|
func RegisterFeedRoutes(mux *http.ServeMux, cfg FeedConfig) {
|
||||||
|
if cfg.Mailbox == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux.HandleFunc("/feed/publish", withSubmitTxGuards(feedPublish(cfg)))
|
||||||
|
mux.HandleFunc("/feed/post/", withReadLimit(feedPostRouter(cfg)))
|
||||||
|
mux.HandleFunc("/feed/author/", withReadLimit(feedAuthor(cfg)))
|
||||||
|
mux.HandleFunc("/feed/timeline", withReadLimit(feedTimeline(cfg)))
|
||||||
|
mux.HandleFunc("/feed/trending", withReadLimit(feedTrending(cfg)))
|
||||||
|
mux.HandleFunc("/feed/foryou", withReadLimit(feedForYou(cfg)))
|
||||||
|
mux.HandleFunc("/feed/hashtag/", withReadLimit(feedHashtag(cfg)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /feed/publish ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// feedPublishRequest — what the client sends. Signature is Ed25519 over
|
||||||
|
// canonical bytes: "publish:<post_id>:<content_sha256_hex>:<ts>".
|
||||||
|
// ts must be within ±5 minutes of server clock.
|
||||||
|
type feedPublishRequest struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
Author string `json:"author"` // hex Ed25519
|
||||||
|
Content string `json:"content"`
|
||||||
|
ContentType string `json:"content_type,omitempty"`
|
||||||
|
AttachmentB64 string `json:"attachment_b64,omitempty"`
|
||||||
|
AttachmentMIME string `json:"attachment_mime,omitempty"`
|
||||||
|
ReplyTo string `json:"reply_to,omitempty"`
|
||||||
|
QuoteOf string `json:"quote_of,omitempty"`
|
||||||
|
Sig string `json:"sig"` // base64 Ed25519 sig
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type feedPublishResponse struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
HostingRelay string `json:"hosting_relay"`
|
||||||
|
ContentHash string `json:"content_hash"` // hex sha256
|
||||||
|
Size uint64 `json:"size"`
|
||||||
|
Hashtags []string `json:"hashtags"`
|
||||||
|
EstimatedFeeUT uint64 `json:"estimated_fee_ut"` // base + size*byte_fee
|
||||||
|
}
|
||||||
|
|
||||||
|
func feedPublish(cfg FeedConfig) http.HandlerFunc {
|
||||||
|
const publishSkewSecs = 300
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req feedPublishRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PostID == "" || req.Author == "" || req.Sig == "" || req.Ts == 0 {
|
||||||
|
jsonErr(w, fmt.Errorf("post_id, author, sig, ts are required"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Content == "" && req.AttachmentB64 == "" {
|
||||||
|
jsonErr(w, fmt.Errorf("post must have content or attachment"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if req.Ts < now-publishSkewSecs || req.Ts > now+publishSkewSecs {
|
||||||
|
jsonErr(w, fmt.Errorf("ts out of range (±%ds)", publishSkewSecs), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ReplyTo != "" && req.QuoteOf != "" {
|
||||||
|
jsonErr(w, fmt.Errorf("reply_to and quote_of are mutually exclusive"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode attachment (raw upload — before scrub).
|
||||||
|
var rawAttachment []byte
|
||||||
|
var attachmentMIME string
|
||||||
|
if req.AttachmentB64 != "" {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(req.AttachmentB64)
|
||||||
|
if err != nil {
|
||||||
|
if b, err = base64.RawURLEncoding.DecodeString(req.AttachmentB64); err != nil {
|
||||||
|
jsonErr(w, fmt.Errorf("attachment_b64: invalid base64"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rawAttachment = b
|
||||||
|
attachmentMIME = req.AttachmentMIME
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: verify signature over the RAW-upload hash ──────────
|
||||||
|
// The client signs what it sent. The server recomputes hash over
|
||||||
|
// the as-received bytes and verifies — this proves the upload
|
||||||
|
// came from the claimed author and wasn't tampered with in transit.
|
||||||
|
rawHasher := sha256.New()
|
||||||
|
rawHasher.Write([]byte(req.Content))
|
||||||
|
rawHasher.Write(rawAttachment)
|
||||||
|
rawContentHash := rawHasher.Sum(nil)
|
||||||
|
rawContentHashHex := hex.EncodeToString(rawContentHash)
|
||||||
|
|
||||||
|
msg := []byte(fmt.Sprintf("publish:%s:%s:%d", req.PostID, rawContentHashHex, req.Ts))
|
||||||
|
sigBytes, err := base64.StdEncoding.DecodeString(req.Sig)
|
||||||
|
if err != nil {
|
||||||
|
if sigBytes, err = base64.RawURLEncoding.DecodeString(req.Sig); err != nil {
|
||||||
|
jsonErr(w, fmt.Errorf("sig: invalid base64"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(req.Author); err != nil {
|
||||||
|
jsonErr(w, fmt.Errorf("author: invalid hex"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok, err := identity.Verify(req.Author, msg, sigBytes)
|
||||||
|
if err != nil || !ok {
|
||||||
|
jsonErr(w, fmt.Errorf("signature invalid"), 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: MANDATORY server-side metadata scrub ─────────────
|
||||||
|
// Runs AFTER signature verification so a fake client can't burn
|
||||||
|
// CPU by triggering expensive scrub work on unauthenticated inputs.
|
||||||
|
//
|
||||||
|
// Images: in-process stdlib re-encode → kills EXIF/GPS/ICC/XMP by
|
||||||
|
// construction. Videos/audio: forwarded to FFmpeg sidecar; without
|
||||||
|
// one, we reject unless operator opted in to unscrubbed video.
|
||||||
|
attachment := rawAttachment
|
||||||
|
if len(attachment) > 0 {
|
||||||
|
if cfg.Scrubber == nil {
|
||||||
|
jsonErr(w, fmt.Errorf("media scrubber not configured on this node"), 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
cleaned, newMIME, err := cfg.Scrubber.Scrub(ctx, attachment, attachmentMIME)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if err == media.ErrSidecarUnavailable && cfg.AllowUnscrubbedVideo {
|
||||||
|
log.Printf("[feed] WARNING: storing unscrubbed video — no sidecar configured (author=%s)", req.Author)
|
||||||
|
} else {
|
||||||
|
status := 400
|
||||||
|
if err == media.ErrSidecarUnavailable {
|
||||||
|
status = 503
|
||||||
|
}
|
||||||
|
jsonErr(w, fmt.Errorf("scrub attachment: %w", err), status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attachment = cleaned
|
||||||
|
attachmentMIME = newMIME
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: recompute content hash over the SCRUBBED bytes ────
|
||||||
|
// This is what goes into the response + on-chain CREATE_POST, so
|
||||||
|
// anyone fetching the body can verify integrity against the chain.
|
||||||
|
// The signature check already used the raw-upload hash above;
|
||||||
|
// this final hash binds the on-chain record to what readers will
|
||||||
|
// actually download.
|
||||||
|
finalHasher := sha256.New()
|
||||||
|
finalHasher.Write([]byte(req.Content))
|
||||||
|
finalHasher.Write(attachment)
|
||||||
|
contentHash := finalHasher.Sum(nil)
|
||||||
|
contentHashHex := hex.EncodeToString(contentHash)
|
||||||
|
|
||||||
|
post := &relay.FeedPost{
|
||||||
|
PostID: req.PostID,
|
||||||
|
Author: req.Author,
|
||||||
|
Content: req.Content,
|
||||||
|
ContentType: req.ContentType,
|
||||||
|
Attachment: attachment,
|
||||||
|
AttachmentMIME: attachmentMIME,
|
||||||
|
ReplyTo: req.ReplyTo,
|
||||||
|
QuoteOf: req.QuoteOf,
|
||||||
|
}
|
||||||
|
hashtags, err := cfg.Mailbox.Store(post, req.Ts)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, relay.ErrPostTooLarge) {
|
||||||
|
jsonErr(w, err, 413)
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report what the client should put into CREATE_POST.
|
||||||
|
size := uint64(len(req.Content)) + uint64(len(attachment)) + 128
|
||||||
|
fee := blockchain.BasePostFee + size*blockchain.PostByteFee
|
||||||
|
jsonOK(w, feedPublishResponse{
|
||||||
|
PostID: req.PostID,
|
||||||
|
HostingRelay: cfg.HostingRelayPub,
|
||||||
|
ContentHash: contentHashHex,
|
||||||
|
Size: size,
|
||||||
|
Hashtags: hashtags,
|
||||||
|
EstimatedFeeUT: fee,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /feed/post/{id} [+ /stats subroute, POST /view] ─────────────────
|
||||||
|
|
||||||
|
// feedPostRouter dispatches /feed/post/{id}, /feed/post/{id}/stats,
|
||||||
|
// /feed/post/{id}/view to the right handler.
|
||||||
|
func feedPostRouter(cfg FeedConfig) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rest := strings.TrimPrefix(r.URL.Path, "/feed/post/")
|
||||||
|
rest = strings.Trim(rest, "/")
|
||||||
|
if rest == "" {
|
||||||
|
jsonErr(w, fmt.Errorf("post id required"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.Split(rest, "/")
|
||||||
|
postID := parts[0]
|
||||||
|
if len(parts) == 1 {
|
||||||
|
feedGetPost(cfg)(w, r, postID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch parts[1] {
|
||||||
|
case "stats":
|
||||||
|
feedPostStats(cfg)(w, r, postID)
|
||||||
|
case "view":
|
||||||
|
feedPostView(cfg)(w, r, postID)
|
||||||
|
case "attachment":
|
||||||
|
feedPostAttachment(cfg)(w, r, postID)
|
||||||
|
default:
|
||||||
|
jsonErr(w, fmt.Errorf("unknown sub-route %q", parts[1]), 404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedPostAttachment handles GET /feed/post/{id}/attachment — returns the
|
||||||
|
// raw attachment bytes with the correct Content-Type so clients can use
|
||||||
|
// the URL directly as an <Image source={uri: ...}>.
|
||||||
|
//
|
||||||
|
// Why a dedicated endpoint? The /feed/post/{id} response wraps the body
|
||||||
|
// as base64 inside JSON; fetching that + decoding for N posts in a feed
|
||||||
|
// list would blow up memory. Native image loaders stream bytes straight
|
||||||
|
// to the GPU — this route lets them do that without intermediate JSON.
|
||||||
|
//
|
||||||
|
// Respects on-chain soft-delete: returns 410 when the post is tombstoned.
|
||||||
|
func feedPostAttachment(cfg FeedConfig) postHandler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, postID string) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.GetPost != nil {
|
||||||
|
if rec, _ := cfg.GetPost(postID); rec != nil && rec.Deleted {
|
||||||
|
jsonErr(w, fmt.Errorf("post %s deleted", postID), 410)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post, err := cfg.Mailbox.Get(postID)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if post == nil || len(post.Attachment) == 0 {
|
||||||
|
jsonErr(w, fmt.Errorf("no attachment for post %s", postID), 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mime := post.AttachmentMIME
|
||||||
|
if mime == "" {
|
||||||
|
mime = "application/octet-stream"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", mime)
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(post.Attachment)))
|
||||||
|
// Cache for 1 hour — attachments are immutable (tied to content_hash),
|
||||||
|
// so aggressive client-side caching is safe and saves bandwidth.
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600, immutable")
|
||||||
|
w.Header().Set("ETag", `"`+postID+`"`)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(post.Attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type postHandler func(w http.ResponseWriter, r *http.Request, postID string)
|
||||||
|
|
||||||
|
func feedGetPost(cfg FeedConfig) postHandler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, postID string) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
post, err := cfg.Mailbox.Get(postID)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if post == nil {
|
||||||
|
jsonErr(w, fmt.Errorf("post %s not found", postID), 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Respect on-chain soft-delete.
|
||||||
|
if cfg.GetPost != nil {
|
||||||
|
if rec, _ := cfg.GetPost(postID); rec != nil && rec.Deleted {
|
||||||
|
jsonErr(w, fmt.Errorf("post %s deleted", postID), 410)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonOK(w, post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type postStatsResponse struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
Views uint64 `json:"views"`
|
||||||
|
Likes uint64 `json:"likes"`
|
||||||
|
LikedByMe *bool `json:"liked_by_me,omitempty"` // set only when ?me=<pub> given
|
||||||
|
}
|
||||||
|
|
||||||
|
func feedPostStats(cfg FeedConfig) postHandler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, postID string) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
views, _ := cfg.Mailbox.ViewCount(postID)
|
||||||
|
var likes uint64
|
||||||
|
if cfg.LikeCount != nil {
|
||||||
|
likes, _ = cfg.LikeCount(postID)
|
||||||
|
}
|
||||||
|
resp := postStatsResponse{
|
||||||
|
PostID: postID,
|
||||||
|
Views: views,
|
||||||
|
Likes: likes,
|
||||||
|
}
|
||||||
|
if me := r.URL.Query().Get("me"); me != "" && cfg.HasLiked != nil {
|
||||||
|
if liked, err := cfg.HasLiked(postID, me); err == nil {
|
||||||
|
resp.LikedByMe = &liked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonOK(w, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func feedPostView(cfg FeedConfig) postHandler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, postID string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next, err := cfg.Mailbox.IncrementView(postID)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]any{
|
||||||
|
"post_id": postID,
|
||||||
|
"views": next,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /feed/author/{pub} ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func feedAuthor(cfg FeedConfig) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pub := strings.TrimPrefix(r.URL.Path, "/feed/author/")
|
||||||
|
pub = strings.Trim(pub, "/")
|
||||||
|
if pub == "" {
|
||||||
|
jsonErr(w, fmt.Errorf("author pub required"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := queryInt(r, "limit", 30)
|
||||||
|
beforeTs := queryInt64(r, "before", 0) // pagination cursor (unix seconds)
|
||||||
|
|
||||||
|
// Prefer chain-authoritative list (includes soft-deleted flag) so
|
||||||
|
// clients can't be fooled by a stale relay that has an already-
|
||||||
|
// deleted post. If chain isn't wired, fall back to relay index.
|
||||||
|
if cfg.PostsByAuthor != nil {
|
||||||
|
records, err := cfg.PostsByAuthor(pub, beforeTs, limit)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]feedAuthorItem, 0, len(records))
|
||||||
|
for _, rec := range records {
|
||||||
|
if rec == nil || rec.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, buildAuthorItem(cfg, rec))
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]any{"author": pub, "count": len(out), "posts": out})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fallback: relay index (no chain). Doesn't support `before` yet;
|
||||||
|
// the chain-authoritative path above is what production serves.
|
||||||
|
ids, err := cfg.Mailbox.PostsByAuthor(pub, limit)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := expandByID(cfg, ids)
|
||||||
|
jsonOK(w, map[string]any{"author": pub, "count": len(out), "posts": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedAuthorItem is a chain record enriched with the body and live stats.
|
||||||
|
type feedAuthorItem struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
ContentType string `json:"content_type,omitempty"`
|
||||||
|
Hashtags []string `json:"hashtags,omitempty"`
|
||||||
|
ReplyTo string `json:"reply_to,omitempty"`
|
||||||
|
QuoteOf string `json:"quote_of,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Size uint64 `json:"size"`
|
||||||
|
HostingRelay string `json:"hosting_relay"`
|
||||||
|
Views uint64 `json:"views"`
|
||||||
|
Likes uint64 `json:"likes"`
|
||||||
|
HasAttachment bool `json:"has_attachment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAuthorItem(cfg FeedConfig, rec *blockchain.PostRecord) feedAuthorItem {
|
||||||
|
item := feedAuthorItem{
|
||||||
|
PostID: rec.PostID,
|
||||||
|
Author: rec.Author,
|
||||||
|
ReplyTo: rec.ReplyTo,
|
||||||
|
QuoteOf: rec.QuoteOf,
|
||||||
|
CreatedAt: rec.CreatedAt,
|
||||||
|
Size: rec.Size,
|
||||||
|
HostingRelay: rec.HostingRelay,
|
||||||
|
}
|
||||||
|
if body, _ := cfg.Mailbox.Get(rec.PostID); body != nil {
|
||||||
|
item.Content = body.Content
|
||||||
|
item.ContentType = body.ContentType
|
||||||
|
item.Hashtags = body.Hashtags
|
||||||
|
item.HasAttachment = len(body.Attachment) > 0
|
||||||
|
}
|
||||||
|
if cfg.LikeCount != nil {
|
||||||
|
item.Likes, _ = cfg.LikeCount(rec.PostID)
|
||||||
|
}
|
||||||
|
item.Views, _ = cfg.Mailbox.ViewCount(rec.PostID)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandByID fetches bodies+stats for a list of post IDs (no chain record).
|
||||||
|
func expandByID(cfg FeedConfig, ids []string) []feedAuthorItem {
|
||||||
|
out := make([]feedAuthorItem, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
body, _ := cfg.Mailbox.Get(id)
|
||||||
|
if body == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := feedAuthorItem{
|
||||||
|
PostID: id,
|
||||||
|
Author: body.Author,
|
||||||
|
Content: body.Content,
|
||||||
|
ContentType: body.ContentType,
|
||||||
|
Hashtags: body.Hashtags,
|
||||||
|
ReplyTo: body.ReplyTo,
|
||||||
|
QuoteOf: body.QuoteOf,
|
||||||
|
CreatedAt: body.CreatedAt,
|
||||||
|
HasAttachment: len(body.Attachment) > 0,
|
||||||
|
}
|
||||||
|
if cfg.LikeCount != nil {
|
||||||
|
item.Likes, _ = cfg.LikeCount(id)
|
||||||
|
}
|
||||||
|
item.Views, _ = cfg.Mailbox.ViewCount(id)
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /feed/timeline ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func feedTimeline(cfg FeedConfig) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
follower := r.URL.Query().Get("follower")
|
||||||
|
if follower == "" {
|
||||||
|
jsonErr(w, fmt.Errorf("follower parameter required"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.Following == nil || cfg.PostsByAuthor == nil {
|
||||||
|
jsonErr(w, fmt.Errorf("timeline requires chain queries"), 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := queryInt(r, "limit", 30)
|
||||||
|
beforeTs := queryInt64(r, "before", 0) // pagination cursor
|
||||||
|
perAuthor := limit
|
||||||
|
if perAuthor > 30 {
|
||||||
|
perAuthor = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
following, err := cfg.Following(follower)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var merged []*blockchain.PostRecord
|
||||||
|
for _, target := range following {
|
||||||
|
posts, err := cfg.PostsByAuthor(target, beforeTs, perAuthor)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range posts {
|
||||||
|
if p != nil && !p.Deleted {
|
||||||
|
merged = append(merged, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort newest-first, take top N.
|
||||||
|
sort.Slice(merged, func(i, j int) bool { return merged[i].CreatedAt > merged[j].CreatedAt })
|
||||||
|
if len(merged) > limit {
|
||||||
|
merged = merged[:limit]
|
||||||
|
}
|
||||||
|
out := make([]feedAuthorItem, 0, len(merged))
|
||||||
|
for _, rec := range merged {
|
||||||
|
out = append(out, buildAuthorItem(cfg, rec))
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]any{"count": len(out), "posts": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /feed/trending ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func feedTrending(cfg FeedConfig) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := queryInt(r, "limit", 30)
|
||||||
|
// Window defaults to 24h; cap 7d so a viral post from a week ago
|
||||||
|
// doesn't permanently dominate.
|
||||||
|
windowHours := queryInt(r, "window", 24)
|
||||||
|
if windowHours > 24*7 {
|
||||||
|
windowHours = 24 * 7
|
||||||
|
}
|
||||||
|
if windowHours < 1 {
|
||||||
|
windowHours = 1
|
||||||
|
}
|
||||||
|
ids, err := cfg.Mailbox.RecentPostIDs(int64(windowHours)*3600, 500)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Score each = likes*3 + views, honoring soft-delete.
|
||||||
|
type scored struct {
|
||||||
|
id string
|
||||||
|
score uint64
|
||||||
|
}
|
||||||
|
scoredList := make([]scored, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if cfg.GetPost != nil {
|
||||||
|
if rec, _ := cfg.GetPost(id); rec != nil && rec.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
views, _ := cfg.Mailbox.ViewCount(id)
|
||||||
|
var likes uint64
|
||||||
|
if cfg.LikeCount != nil {
|
||||||
|
likes, _ = cfg.LikeCount(id)
|
||||||
|
}
|
||||||
|
scoredList = append(scoredList, scored{id: id, score: likes*3 + views})
|
||||||
|
}
|
||||||
|
sort.Slice(scoredList, func(i, j int) bool { return scoredList[i].score > scoredList[j].score })
|
||||||
|
if len(scoredList) > limit {
|
||||||
|
scoredList = scoredList[:limit]
|
||||||
|
}
|
||||||
|
pickedIDs := make([]string, len(scoredList))
|
||||||
|
for i, s := range scoredList {
|
||||||
|
pickedIDs[i] = s.id
|
||||||
|
}
|
||||||
|
out := expandByID(cfg, pickedIDs)
|
||||||
|
jsonOK(w, map[string]any{"count": len(out), "posts": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /feed/foryou ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Simple recommendations heuristic for v2.0.0:
|
||||||
|
// 1. Compute the set of authors the user already follows.
|
||||||
|
// 2. Fetch recent posts from the relay (last 48h).
|
||||||
|
// 3. Filter OUT posts from followed authors (those live in /timeline).
|
||||||
|
// 4. Filter OUT posts the user has already liked.
|
||||||
|
// 5. Rank remaining by (likes × 3 + views) and return top N.
|
||||||
|
//
|
||||||
|
// Future improvements (tracked as v2.2.0 "Feed algorithm"):
|
||||||
|
// - Weight by "followed-of-followed" signal (friends-of-friends boost).
|
||||||
|
// - Decay by age (exp half-life ~12h).
|
||||||
|
// - Penalise self-engagement (author liking own post).
|
||||||
|
// - Collaborative filtering on hashtag co-occurrence.
|
||||||
|
|
||||||
|
func feedForYou(cfg FeedConfig) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pub := r.URL.Query().Get("pub")
|
||||||
|
limit := queryInt(r, "limit", 30)
|
||||||
|
|
||||||
|
// Gather user's follows + likes to exclude from the candidate pool.
|
||||||
|
excludedAuthors := make(map[string]struct{})
|
||||||
|
if cfg.Following != nil && pub != "" {
|
||||||
|
if list, err := cfg.Following(pub); err == nil {
|
||||||
|
for _, a := range list {
|
||||||
|
excludedAuthors[a] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Post pool: last 48h on this relay.
|
||||||
|
ids, err := cfg.Mailbox.RecentPostIDs(48*3600, 500)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type scored struct {
|
||||||
|
id string
|
||||||
|
score uint64
|
||||||
|
}
|
||||||
|
scoredList := make([]scored, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
body, _ := cfg.Mailbox.Get(id)
|
||||||
|
if body == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, followed := excludedAuthors[body.Author]; followed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if body.Author == pub {
|
||||||
|
continue // don't recommend user's own posts
|
||||||
|
}
|
||||||
|
if cfg.GetPost != nil {
|
||||||
|
if rec, _ := cfg.GetPost(id); rec != nil && rec.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Skip already-liked.
|
||||||
|
if cfg.HasLiked != nil && pub != "" {
|
||||||
|
if liked, _ := cfg.HasLiked(id, pub); liked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
views, _ := cfg.Mailbox.ViewCount(id)
|
||||||
|
var likes uint64
|
||||||
|
if cfg.LikeCount != nil {
|
||||||
|
likes, _ = cfg.LikeCount(id)
|
||||||
|
}
|
||||||
|
// Small "seed" score so posts with no engagement still get shown
|
||||||
|
// sometimes (otherwise a silent but fresh post can't break in).
|
||||||
|
scoredList = append(scoredList, scored{id: id, score: likes*3 + views + 1})
|
||||||
|
}
|
||||||
|
sort.Slice(scoredList, func(i, j int) bool { return scoredList[i].score > scoredList[j].score })
|
||||||
|
if len(scoredList) > limit {
|
||||||
|
scoredList = scoredList[:limit]
|
||||||
|
}
|
||||||
|
pickedIDs := make([]string, len(scoredList))
|
||||||
|
for i, s := range scoredList {
|
||||||
|
pickedIDs[i] = s.id
|
||||||
|
}
|
||||||
|
out := expandByID(cfg, pickedIDs)
|
||||||
|
jsonOK(w, map[string]any{"count": len(out), "posts": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /feed/hashtag/{tag} ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func feedHashtag(cfg FeedConfig) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag := strings.TrimPrefix(r.URL.Path, "/feed/hashtag/")
|
||||||
|
tag = strings.Trim(tag, "/")
|
||||||
|
if tag == "" {
|
||||||
|
jsonErr(w, fmt.Errorf("tag required"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := queryInt(r, "limit", 50)
|
||||||
|
ids, err := cfg.Mailbox.PostsByHashtag(tag, limit)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := expandByID(cfg, ids)
|
||||||
|
jsonOK(w, map[string]any{"tag": strings.ToLower(tag), "count": len(out), "posts": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (queryInt helper is shared with the rest of the node HTTP surface;
|
||||||
|
// see api_common.go.)
|
||||||
@@ -2,6 +2,7 @@ package node
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-blockchain/blockchain"
|
"go-blockchain/blockchain"
|
||||||
|
"go-blockchain/identity"
|
||||||
"go-blockchain/relay"
|
"go-blockchain/relay"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +28,12 @@ type RelayConfig struct {
|
|||||||
|
|
||||||
// ContactRequests returns incoming contact records for the given Ed25519 pubkey.
|
// ContactRequests returns incoming contact records for the given Ed25519 pubkey.
|
||||||
ContactRequests func(pubKey string) ([]blockchain.ContactInfo, error)
|
ContactRequests func(pubKey string) ([]blockchain.ContactInfo, error)
|
||||||
|
|
||||||
|
// ResolveX25519 returns the X25519 hex published by the Ed25519 identity,
|
||||||
|
// or "" if the identity has not registered or does not exist. Used by
|
||||||
|
// authenticated mutating endpoints (e.g. DELETE /relay/inbox) to link a
|
||||||
|
// signing key back to its mailbox pubkey. nil disables those endpoints.
|
||||||
|
ResolveX25519 func(ed25519PubHex string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerRelayRoutes wires relay mailbox endpoints onto mux.
|
// registerRelayRoutes wires relay mailbox endpoints onto mux.
|
||||||
@@ -37,12 +45,19 @@ type RelayConfig struct {
|
|||||||
// DELETE /relay/inbox/{envID} ?pub=<x25519hex>
|
// DELETE /relay/inbox/{envID} ?pub=<x25519hex>
|
||||||
// GET /relay/contacts ?pub=<ed25519hex>
|
// GET /relay/contacts ?pub=<ed25519hex>
|
||||||
func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
|
func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
|
||||||
mux.HandleFunc("/relay/send", relaySend(rc))
|
// Writes go through withSubmitTxGuards: per-IP rate limit (10/s, burst 20)
|
||||||
mux.HandleFunc("/relay/broadcast", relayBroadcast(rc))
|
// + 256 KiB body cap. Without these, a single attacker could spam
|
||||||
mux.HandleFunc("/relay/inbox/count", relayInboxCount(rc))
|
// 500 envelopes per victim in a few seconds and evict every real message
|
||||||
mux.HandleFunc("/relay/inbox/", relayInboxDelete(rc))
|
// via the mailbox FIFO cap.
|
||||||
mux.HandleFunc("/relay/inbox", relayInboxList(rc))
|
mux.HandleFunc("/relay/send", withSubmitTxGuards(relaySend(rc)))
|
||||||
mux.HandleFunc("/relay/contacts", relayContacts(rc))
|
mux.HandleFunc("/relay/broadcast", withSubmitTxGuards(relayBroadcast(rc)))
|
||||||
|
|
||||||
|
// Reads go through withReadLimit: per-IP rate limit (20/s, burst 40).
|
||||||
|
// Protects against inbox-scraping floods from a single origin.
|
||||||
|
mux.HandleFunc("/relay/inbox/count", withReadLimit(relayInboxCount(rc)))
|
||||||
|
mux.HandleFunc("/relay/inbox/", withReadLimit(relayInboxDelete(rc)))
|
||||||
|
mux.HandleFunc("/relay/inbox", withReadLimit(relayInboxList(rc)))
|
||||||
|
mux.HandleFunc("/relay/contacts", withReadLimit(relayContacts(rc)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// relayInboxList handles GET /relay/inbox?pub=<hex>[&since=<ts>][&limit=N]
|
// relayInboxList handles GET /relay/inbox?pub=<hex>[&since=<ts>][&limit=N]
|
||||||
@@ -109,8 +124,24 @@ func relayInboxList(rc RelayConfig) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<hex>
|
// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<x25519hex>
|
||||||
|
//
|
||||||
|
// Auth model:
|
||||||
|
// Query: ?pub=<x25519hex>
|
||||||
|
// Body: {"ed25519_pub":"<hex>", "sig":"<base64>", "ts":<unix_seconds>}
|
||||||
|
// sig = Ed25519(privEd25519,
|
||||||
|
// "inbox-delete:" + envID + ":" + x25519Pub + ":" + ts)
|
||||||
|
// ts must be within ±5 minutes of server clock (anti-replay).
|
||||||
|
//
|
||||||
|
// Server then:
|
||||||
|
// 1. Verifies sig over the canonical bytes above.
|
||||||
|
// 2. Looks up identity(ed25519_pub).X25519Pub — must equal the ?pub= query.
|
||||||
|
//
|
||||||
|
// This links the signing key to the mailbox key without exposing the user's
|
||||||
|
// X25519 private material.
|
||||||
func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
|
func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
|
||||||
|
const inboxDeleteSkewSecs = 300 // ±5 minutes
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodDelete {
|
if r.Method != http.MethodDelete {
|
||||||
// Also serve GET /relay/inbox/{id} for convenience (fetch single envelope)
|
// Also serve GET /relay/inbox/{id} for convenience (fetch single envelope)
|
||||||
@@ -133,6 +164,61 @@ func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth. Unauthenticated DELETE historically let anyone wipe any
|
||||||
|
// mailbox by just knowing the pub — fixed in v1.0.2 via signed
|
||||||
|
// Ed25519 identity linked to the x25519 via identity registry.
|
||||||
|
if rc.ResolveX25519 == nil {
|
||||||
|
jsonErr(w, fmt.Errorf("mailbox delete not available on this node"), 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Ed25519Pub string `json:"ed25519_pub"`
|
||||||
|
Sig string `json:"sig"`
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
jsonErr(w, fmt.Errorf("invalid JSON body: %w", err), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Ed25519Pub == "" || body.Sig == "" || body.Ts == 0 {
|
||||||
|
jsonErr(w, fmt.Errorf("ed25519_pub, sig, ts are required"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if body.Ts < now-inboxDeleteSkewSecs || body.Ts > now+inboxDeleteSkewSecs {
|
||||||
|
jsonErr(w, fmt.Errorf("timestamp out of range (±%ds)", inboxDeleteSkewSecs), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sigBytes, err := base64.StdEncoding.DecodeString(body.Sig)
|
||||||
|
if err != nil {
|
||||||
|
// Also try URL-safe for defensive UX.
|
||||||
|
sigBytes, err = base64.RawURLEncoding.DecodeString(body.Sig)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, fmt.Errorf("sig: invalid base64"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(body.Ed25519Pub); err != nil {
|
||||||
|
jsonErr(w, fmt.Errorf("ed25519_pub: invalid hex"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := []byte(fmt.Sprintf("inbox-delete:%s:%s:%d", envID, pub, body.Ts))
|
||||||
|
ok, err := identity.Verify(body.Ed25519Pub, msg, sigBytes)
|
||||||
|
if err != nil || !ok {
|
||||||
|
jsonErr(w, fmt.Errorf("signature invalid"), 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Link ed25519 → x25519 via identity registry.
|
||||||
|
registeredX := rc.ResolveX25519(body.Ed25519Pub)
|
||||||
|
if registeredX == "" {
|
||||||
|
jsonErr(w, fmt.Errorf("identity has no registered X25519 key"), 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(registeredX, pub) {
|
||||||
|
jsonErr(w, fmt.Errorf("pub does not match identity's registered X25519"), 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := rc.Mailbox.Delete(pub, envID); err != nil {
|
if err := rc.Mailbox.Delete(pub, envID); err != nil {
|
||||||
jsonErr(w, err, 500)
|
jsonErr(w, err, 500)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -79,12 +79,6 @@ type ExplorerQuery struct {
|
|||||||
GetNFTs func() ([]blockchain.NFTRecord, error)
|
GetNFTs func() ([]blockchain.NFTRecord, error)
|
||||||
NFTsByOwner func(ownerPub string) ([]blockchain.NFTRecord, error)
|
NFTsByOwner func(ownerPub string) ([]blockchain.NFTRecord, error)
|
||||||
|
|
||||||
// Channel group-messaging lookups (R1). GetChannel returns metadata;
|
|
||||||
// GetChannelMembers returns the Ed25519 pubkey of every current member.
|
|
||||||
// Both may be nil on nodes that don't expose channel state (tests).
|
|
||||||
GetChannel func(channelID string) (*blockchain.CreateChannelPayload, error)
|
|
||||||
GetChannelMembers func(channelID string) ([]string, error)
|
|
||||||
|
|
||||||
// Events is the SSE hub for the live event stream. Optional — if nil the
|
// Events is the SSE hub for the live event stream. Optional — if nil the
|
||||||
// /api/events endpoint returns 501 Not Implemented.
|
// /api/events endpoint returns 501 Not Implemented.
|
||||||
Events *SSEHub
|
Events *SSEHub
|
||||||
@@ -127,7 +121,6 @@ func RegisterExplorerRoutes(mux *http.ServeMux, q ExplorerQuery, flags ...Explor
|
|||||||
registerUpdateCheckAPI(mux, q)
|
registerUpdateCheckAPI(mux, q)
|
||||||
registerOnboardingAPI(mux, q)
|
registerOnboardingAPI(mux, q)
|
||||||
registerTokenAPI(mux, q)
|
registerTokenAPI(mux, q)
|
||||||
registerChannelAPI(mux, q)
|
|
||||||
if !f.DisableSwagger {
|
if !f.DisableSwagger {
|
||||||
registerSwaggerRoutes(mux)
|
registerSwaggerRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// returned forever (even if the implementation moves around internally). The
|
// returned forever (even if the implementation moves around internally). The
|
||||||
// client uses them as "is this feature here or not?", not "what version is
|
// client uses them as "is this feature here or not?", not "what version is
|
||||||
// this feature at?". Versioning a feature is done by shipping a new tag
|
// this feature at?". Versioning a feature is done by shipping a new tag
|
||||||
// (e.g. "channels_v2" alongside "channels_v1" for a deprecation window).
|
// (e.g. "feed_v3" alongside "feed_v2" for a deprecation window).
|
||||||
//
|
//
|
||||||
// Response shape:
|
// Response shape:
|
||||||
//
|
//
|
||||||
@@ -55,15 +55,17 @@ const ProtocolVersion = 1
|
|||||||
// a breaking successor (e.g. `channels_v1`, not `channels`).
|
// a breaking successor (e.g. `channels_v1`, not `channels`).
|
||||||
var nodeFeatures = []string{
|
var nodeFeatures = []string{
|
||||||
"access_token", // DCHAIN_API_TOKEN gating on writes (+ optional reads)
|
"access_token", // DCHAIN_API_TOKEN gating on writes (+ optional reads)
|
||||||
"channels_v1", // /api/channels/:id + /members with X25519 enrichment
|
|
||||||
"chain_id", // /api/network-info returns chain_id
|
"chain_id", // /api/network-info returns chain_id
|
||||||
"contract_logs", // /api/contract/:id/logs endpoint
|
"contract_logs", // /api/contract/:id/logs endpoint
|
||||||
"fan_out", // client-side per-recipient envelope sealing
|
"fan_out", // client-side per-recipient envelope sealing
|
||||||
|
"feed_v2", // social feed: posts, likes, follows, timeline, trending, for-you
|
||||||
"identity_registry", // /api/identity/:pub returns X25519 pub + relay hints
|
"identity_registry", // /api/identity/:pub returns X25519 pub + relay hints
|
||||||
|
"media_scrub", // mandatory EXIF/GPS strip on /feed/publish
|
||||||
"native_username_registry", // native:username_registry contract
|
"native_username_registry", // native:username_registry contract
|
||||||
"onboarding_api", // /api/network-info for joiner bootstrap
|
"onboarding_api", // /api/network-info for joiner bootstrap
|
||||||
"payment_channels", // off-chain payment channel open/close
|
"payment_channels", // off-chain payment channel open/close
|
||||||
"relay_mailbox", // /relay/send + /relay/inbox
|
"relay_broadcast", // /relay/broadcast (E2E envelope publish)
|
||||||
|
"relay_mailbox", // /relay/inbox (read), /relay/send (legacy non-E2E)
|
||||||
"ws_submit_tx", // WebSocket submit_tx op
|
"ws_submit_tx", // WebSocket submit_tx op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user