feat: resource caps, Saved Messages, author walls, docs for node bring-up
Node flags (cmd/node/main.go):
--max-cpu / --max-ram-mb — Go runtime caps (GOMAXPROCS / GOMEMLIMIT)
--feed-disk-limit-mb — hard 507 refusal for new post bodies over quota
--chain-disk-limit-mb — advisory watcher (can't reject blocks without
breaking consensus; logs WARN every minute)
Client — Saved Messages (self-chat):
- Auto-created on sign-in, pinned top of chat list, blue bookmark avatar
- Send short-circuits the relay (no encrypt, no fee, no mailbox hop)
- Empty state rendered outside inverted FlatList — fixes the mirrored
"say hi…" on Android RTL-aware layout builds
- PostCard shows "You" for own posts instead of the self-contact alias
Client — user walls:
- New route /(app)/feed/author/[pub] with infinite-scroll via
`created_at` cursor and pull-to-refresh
- Profile screen gains "View posts" button (universal) next to
"Open chat" (contact-only)
Feed pipeline:
- Bump client JPEG quality 0.5 → 0.75 to match server scrubber (Q=75),
so a 60 KiB compose doesn't balloon past 256 KiB after server re-encode
- ErrPostTooLarge now wraps with the actual size vs cap, errors.Is
preserved in the HTTP layer
- FeedMailbox quota + DiskUsage surface — supports new CLI flag
README:
- Step-by-step "first node / joiner" section on the landing page,
full flag tables incl. the new resource-cap group, minimal
checklists for open/private/low-end deployments
This commit is contained in:
155
README.md
155
README.md
@@ -22,6 +22,7 @@
|
|||||||
## Содержание
|
## Содержание
|
||||||
|
|
||||||
- [Быстрый старт](#быстрый-старт)
|
- [Быстрый старт](#быстрый-старт)
|
||||||
|
- [Поднятие ноды — пошагово](#поднятие-ноды--пошагово)
|
||||||
- [Продакшен деплой](#продакшен-деплой)
|
- [Продакшен деплой](#продакшен-деплой)
|
||||||
- [Архитектура](#архитектура)
|
- [Архитектура](#архитектура)
|
||||||
- [REST / WebSocket API](#rest--websocket-api)
|
- [REST / WebSocket API](#rest--websocket-api)
|
||||||
@@ -66,6 +67,160 @@ curl -s http://localhost:8080/api/well-known-version | jq .
|
|||||||
|
|
||||||
3-node dev-кластер (для тестов PBFT кворума, slashing, federation): `docker compose up --build -d` — см. [`docs/quickstart.md`](docs/quickstart.md).
|
3-node dev-кластер (для тестов PBFT кворума, slashing, federation): `docker compose up --build -d` — см. [`docs/quickstart.md`](docs/quickstart.md).
|
||||||
|
|
||||||
|
## Поднятие ноды — пошагово
|
||||||
|
|
||||||
|
Ниже — полный минимум для двух сценариев, которые покрывают 99% случаев:
|
||||||
|
**первая нода сети** (genesis) и **присоединение к существующей сети**.
|
||||||
|
Все флаги читаются также из соответствующего `DCHAIN_*` env-var (CLI > env > default).
|
||||||
|
|
||||||
|
### Шаг 1. Ключи
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ключ identity ноды (Ed25519 — подпись блоков + tx)
|
||||||
|
./client keygen --out keys/node.json
|
||||||
|
# relay-ключ (X25519 — E2E-mailbox) создаётся нодой сам при первом старте,
|
||||||
|
# но можно задать путь заранее через --relay-key.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 2a. Первая нода (genesis)
|
||||||
|
|
||||||
|
Поднимает новую сеть с одним валидатором. `--genesis=true` **только** для самой первой ноды и **только один раз** — если блок 0 уже есть в `--db`, флаг игнорируется.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./node \
|
||||||
|
--genesis=true \
|
||||||
|
--key=keys/node.json \
|
||||||
|
--db=./chaindata \
|
||||||
|
--mailbox-db=./mailboxdata \
|
||||||
|
--feed-db=./feeddata \
|
||||||
|
--listen=/ip4/0.0.0.0/tcp/4001 \
|
||||||
|
--announce=/ip4/<ВАШ-ПУБЛИЧНЫЙ-IP>/tcp/4001 \
|
||||||
|
--stats-addr=:8080 \
|
||||||
|
--register-relay=true \
|
||||||
|
--relay-fee=1000
|
||||||
|
```
|
||||||
|
|
||||||
|
`--announce` **обязателен** для любой ноды смотрящей в интернет (VPS / внешний IP / Docker с проброшенным портом). Без него libp2p пытается UPnP/NAT-PMP и чаще всего промахивается.
|
||||||
|
|
||||||
|
### Шаг 2b. Вторая и последующие ноды
|
||||||
|
|
||||||
|
Нужен **один** из двух способов узнать первую ноду. Второй удобнее.
|
||||||
|
|
||||||
|
**Через HTTP URL живой ноды** (рекомендуется — нода сама заберёт multiaddr через `/api/network-info`, проверит genesis_hash и синхронизирует цепь):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./node \
|
||||||
|
--join=https://first-node.example.com \
|
||||||
|
--key=keys/node.json \
|
||||||
|
--db=./chaindata \
|
||||||
|
--mailbox-db=./mailboxdata \
|
||||||
|
--feed-db=./feeddata \
|
||||||
|
--listen=/ip4/0.0.0.0/tcp/4001 \
|
||||||
|
--announce=/ip4/<ВАШ-ПУБЛИЧНЫЙ-IP>/tcp/4001 \
|
||||||
|
--stats-addr=:8080 \
|
||||||
|
--register-relay=true \
|
||||||
|
--relay-fee=1000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Через libp2p multiaddr** (если есть прямой мульти-адрес):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./node \
|
||||||
|
--peers=/ip4/1.2.3.4/tcp/4001/p2p/12D3KooW... \
|
||||||
|
# остальные флаги как выше
|
||||||
|
```
|
||||||
|
|
||||||
|
**Автоприсоединение к validator set** происходит не само: после того как нода синхронизируется, действующий validator должен вызвать `client add-validator --target <your-pub> --cosigs ...` (multi-sig admit). До этого новая нода живёт как **observer** — читает и гоняет tx, но не голосует. Запустить ноду **явно** как observer (никогда не проситься в validator set): `--observer=true`.
|
||||||
|
|
||||||
|
### Все флаги `node`
|
||||||
|
|
||||||
|
CLI / env / default. Группы:
|
||||||
|
|
||||||
|
**Storage**
|
||||||
|
| Флаг | Env | Default | Назначение |
|
||||||
|
|------|-----|---------|-----------|
|
||||||
|
| `--db` | `DCHAIN_DB` | `./chaindata` | BadgerDB блокчейна |
|
||||||
|
| `--mailbox-db` | `DCHAIN_MAILBOX_DB` | `./mailboxdata` | E2E-конверты 1:1 чатов |
|
||||||
|
| `--feed-db` | `DCHAIN_FEED_DB` | `./feeddata` | Тела постов ленты (off-chain) |
|
||||||
|
| `--feed-ttl-days` | `DCHAIN_FEED_TTL_DAYS` | `30` | Через сколько дней тела постов auto-evict'ятся (метаданные on-chain остаются вечно) |
|
||||||
|
|
||||||
|
**Identity**
|
||||||
|
| Флаг | Env | Default | Назначение |
|
||||||
|
|------|-----|---------|-----------|
|
||||||
|
| `--key` | `DCHAIN_KEY` | `./node.json` | Ed25519 ключ ноды |
|
||||||
|
| `--relay-key` | `DCHAIN_RELAY_KEY` | `./relay.json` | X25519 ключ для relay-mailbox (создастся сам) |
|
||||||
|
| `--wallet` | `DCHAIN_WALLET` | — | Отдельный payout-кошелёк (опционально) |
|
||||||
|
| `--wallet-pass` | `DCHAIN_WALLET_PASS` | — | Парольная фраза для wallet-файла |
|
||||||
|
|
||||||
|
**Network**
|
||||||
|
| Флаг | Env | Default | Назначение |
|
||||||
|
|------|-----|---------|-----------|
|
||||||
|
| `--listen` | `DCHAIN_LISTEN` | `/ip4/0.0.0.0/tcp/4001` | libp2p listen multiaddr |
|
||||||
|
| `--announce` | `DCHAIN_ANNOUNCE` | — | Multiaddr который нода рассказывает пирам (обязателен на VPS/внешнем IP) |
|
||||||
|
| `--peers` | `DCHAIN_PEERS` | — | Bootstrap multiaddrs, comma-separated |
|
||||||
|
| `--join` | `DCHAIN_JOIN` | — | HTTP URL живой ноды для авто-дискавери — получает peers и genesis_hash |
|
||||||
|
| `--allow-genesis-mismatch` | — | `false` | Отключить защиту, падающую при расхождении локального и seed'ового genesis (только для явной миграции) |
|
||||||
|
|
||||||
|
**Consensus & role**
|
||||||
|
| Флаг | Env | Default | Назначение |
|
||||||
|
|------|-----|---------|-----------|
|
||||||
|
| `--genesis` | `DCHAIN_GENESIS` | `false` | Создать блок 0 (только для самой первой ноды сети) |
|
||||||
|
| `--validators` | `DCHAIN_VALIDATORS` | — | Исходный validator set (CSV pub-keys) — применяется только при genesis |
|
||||||
|
| `--observer` | `DCHAIN_OBSERVER` | `false` | Observer-режим: синхронизируется и отдаёт API, но не голосует и не предлагает блоки |
|
||||||
|
| `--heartbeat` | `DCHAIN_HEARTBEAT` | `true` | Периодический HEARTBEAT-tx (нужен для liveness-детекции валидаторов) |
|
||||||
|
|
||||||
|
**Relay / mailbox**
|
||||||
|
| Флаг | Env | Default | Назначение |
|
||||||
|
|------|-----|---------|-----------|
|
||||||
|
| `--register-relay` | `DCHAIN_REGISTER_RELAY` | `false` | Отправить `REGISTER_RELAY` tx на старте (объявить ноду публичным relay'ем) |
|
||||||
|
| `--relay-fee` | `DCHAIN_RELAY_FEE` | `1000` | Плата за доставку одного сообщения в µT (1000 = 0.001 T). `0` = бесплатный relay |
|
||||||
|
|
||||||
|
**Media scrubber (feed)**
|
||||||
|
| Флаг | Env | Default | Назначение |
|
||||||
|
|------|-----|---------|-----------|
|
||||||
|
| `--media-sidecar-url` | `DCHAIN_MEDIA_SIDECAR_URL` | — | URL FFmpeg-сайдкара для видео-скраба. Пустой = только картинки |
|
||||||
|
| `--allow-unscrubbed-video` | `DCHAIN_ALLOW_UNSCRUBBED_VIDEO` | `false` | Принимать видео **без** серверного скраба (опасно — EXIF/GPS/автор-теги останутся) |
|
||||||
|
|
||||||
|
**HTTP API**
|
||||||
|
| Флаг | Env | Default | Назначение |
|
||||||
|
|------|-----|---------|-----------|
|
||||||
|
| `--stats-addr` | `DCHAIN_STATS_ADDR` | `:8080` | Адрес HTTP/WS сервера |
|
||||||
|
| `--api-token` | `DCHAIN_API_TOKEN` | — | Bearer-токен для submit tx. Пустой = публичная нода |
|
||||||
|
| `--api-private` | `DCHAIN_API_PRIVATE` | `false` | Требовать токен также на чтение |
|
||||||
|
| `--disable-ui` | `DCHAIN_DISABLE_UI` | `false` | Отключить HTML-explorer (JSON API остаётся) |
|
||||||
|
| `--disable-swagger` | `DCHAIN_DISABLE_SWAGGER` | `false` | Отключить `/swagger*` |
|
||||||
|
|
||||||
|
**Resource caps** (новое в v2.1.0)
|
||||||
|
| Флаг | Env | Default | Назначение |
|
||||||
|
|------|-----|---------|-----------|
|
||||||
|
| `--max-cpu` | `DCHAIN_MAX_CPU` | `0` | Сколько CPU-ядер Go-runtime'у (`GOMAXPROCS`). `0` = все |
|
||||||
|
| `--max-ram-mb` | `DCHAIN_MAX_RAM_MB` | `0` | Soft-лимит Go-хипа в MiB (`GOMEMLIMIT`). `0` = без лимита. *Не OOM-kill'ит — усиливает GC при приближении* |
|
||||||
|
| `--feed-disk-limit-mb` | `DCHAIN_FEED_DISK_LIMIT_MB` | `0` | Жёсткая квота на feed-БД. При превышении `/feed/publish` отвечает 507. Существующие посты продолжают отдаваться |
|
||||||
|
| `--chain-disk-limit-mb` | `DCHAIN_CHAIN_DISK_LIMIT_MB` | `0` | Advisory-квота на блокчейн-БД. Превышение → `WARN` в лог раз в минуту (жёстко не отказываем — сломали бы консенсус) |
|
||||||
|
|
||||||
|
Для реального sandboxing (hard-kill при OOM, hard CPU throttling) используйте `docker run --cpus --memory` или systemd `CPUQuota` / `MemoryMax` поверх этих флагов.
|
||||||
|
|
||||||
|
**Update / versioning**
|
||||||
|
| Флаг | Env | Default | Назначение |
|
||||||
|
|------|-----|---------|-----------|
|
||||||
|
| `--update-source-url` | `DCHAIN_UPDATE_SOURCE_URL` | — | Gitea `/api/v1/repos/{owner}/{repo}/releases/latest` для `/api/update-check` |
|
||||||
|
| `--update-source-token` | `DCHAIN_UPDATE_SOURCE_TOKEN` | — | PAT для приватного репо |
|
||||||
|
| `--log-format` | `DCHAIN_LOG_FORMAT` | `text` | `text` (human) или `json` (Loki/ELK) |
|
||||||
|
| `--governance-contract` | `DCHAIN_GOVERNANCE_CONTRACT` | — | ID governance-контракта для динамических параметров |
|
||||||
|
| `--version` | — | — | Печатает версию и выходит |
|
||||||
|
|
||||||
|
### Минимальные чек-листы
|
||||||
|
|
||||||
|
**Первая нода (открытая):** `--genesis=true` + `--key` + `--announce` на внешний IP + `--stats-addr` + опционально `--register-relay=true --relay-fee=...` чтобы сразу монетизировать relay-трафик.
|
||||||
|
|
||||||
|
**Joiner:** `--join=<url-любой-живой-ноды>` + `--key` + `--announce` + `--stats-addr`. После синка попросите действующего валидатора поднять `add-validator` (иначе остаётесь observer'ом до принятия — это нормально и безопасно).
|
||||||
|
|
||||||
|
**Приватная/домашняя нода** без публичного эксплорера: добавьте `--api-token=<random>`, `--api-private=true`, `--disable-ui=true`, `--disable-swagger=true`. Clients передают `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
**Слабое железо:** `--max-cpu=2 --max-ram-mb=1024 --feed-disk-limit-mb=2048 --chain-disk-limit-mb=10240`.
|
||||||
|
|
||||||
|
Docker-обёртка с теми же флагами — в [`deploy/single/README.md`](deploy/single/README.md).
|
||||||
|
|
||||||
## Продакшен деплой
|
## Продакшен деплой
|
||||||
|
|
||||||
Два варианта, по масштабу.
|
Два варианта, по масштабу.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useGlobalInbox } from '@/hooks/useGlobalInbox';
|
|||||||
import { getWSClient } from '@/lib/ws';
|
import { getWSClient } from '@/lib/ws';
|
||||||
import { NavBar } from '@/components/NavBar';
|
import { NavBar } from '@/components/NavBar';
|
||||||
import { AnimatedSlot } from '@/components/AnimatedSlot';
|
import { AnimatedSlot } from '@/components/AnimatedSlot';
|
||||||
|
import { saveContact } from '@/lib/storage';
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
@@ -49,6 +50,23 @@ export default function AppLayout() {
|
|||||||
useNotifications(); // permission + tap-handler
|
useNotifications(); // permission + tap-handler
|
||||||
useGlobalInbox(); // global inbox listener → notifications on new peer msg
|
useGlobalInbox(); // global inbox listener → notifications on new peer msg
|
||||||
|
|
||||||
|
// Ensure the Saved Messages (self-chat) contact exists as soon as the user
|
||||||
|
// is signed in, so it shows up in the chat list without any prior action.
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const upsertContact = useStore(s => s.upsertContact);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
if (contacts.some(c => c.address === keyFile.pub_key)) return;
|
||||||
|
const saved = {
|
||||||
|
address: keyFile.pub_key,
|
||||||
|
x25519Pub: keyFile.x25519_pub,
|
||||||
|
alias: 'Saved Messages',
|
||||||
|
addedAt: Date.now(),
|
||||||
|
};
|
||||||
|
upsertContact(saved);
|
||||||
|
saveContact(saved).catch(() => { /* best-effort — re-added next boot anyway */ });
|
||||||
|
}, [keyFile, contacts, upsertContact]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ws = getWSClient();
|
const ws = getWSClient();
|
||||||
if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });
|
if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ export default function ChatScreen() {
|
|||||||
clearContactNotifications(contactAddress);
|
clearContactNotifications(contactAddress);
|
||||||
}, [contactAddress, clearUnread]);
|
}, [contactAddress, clearUnread]);
|
||||||
|
|
||||||
|
const upsertContact = useStore(s => s.upsertContact);
|
||||||
|
const isSavedMessages = !!keyFile && contactAddress === keyFile.pub_key;
|
||||||
|
|
||||||
|
// Auto-materialise the Saved Messages contact the first time the user
|
||||||
|
// opens chat-with-self. The contact is stored locally only — no on-chain
|
||||||
|
// CONTACT_REQUEST needed, since both ends are the same key pair.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSavedMessages || !keyFile) return;
|
||||||
|
const existing = contacts.find(c => c.address === keyFile.pub_key);
|
||||||
|
if (existing) return;
|
||||||
|
upsertContact({
|
||||||
|
address: keyFile.pub_key,
|
||||||
|
x25519Pub: keyFile.x25519_pub,
|
||||||
|
alias: 'Saved Messages',
|
||||||
|
addedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}, [isSavedMessages, keyFile, contacts, upsertContact]);
|
||||||
|
|
||||||
const contact = contacts.find(c => c.address === contactAddress);
|
const contact = contacts.find(c => c.address === contactAddress);
|
||||||
const chatMsgs = messages[contactAddress ?? ''] ?? [];
|
const chatMsgs = messages[contactAddress ?? ''] ?? [];
|
||||||
const listRef = useRef<FlatList>(null);
|
const listRef = useRef<FlatList>(null);
|
||||||
@@ -137,9 +155,11 @@ export default function ChatScreen() {
|
|||||||
});
|
});
|
||||||
}, [contactAddress, setMsgs]);
|
}, [contactAddress, setMsgs]);
|
||||||
|
|
||||||
const name = contact?.username
|
const name = isSavedMessages
|
||||||
? `@${contact.username}`
|
? 'Saved Messages'
|
||||||
: contact?.alias ?? shortAddr(contactAddress ?? '');
|
: contact?.username
|
||||||
|
? `@${contact.username}`
|
||||||
|
: contact?.alias ?? shortAddr(contactAddress ?? '');
|
||||||
|
|
||||||
// ── Compose actions ────────────────────────────────────────────────────
|
// ── Compose actions ────────────────────────────────────────────────────
|
||||||
const cancelCompose = useCallback(() => {
|
const cancelCompose = useCallback(() => {
|
||||||
@@ -172,7 +192,7 @@ export default function ChatScreen() {
|
|||||||
const hasText = !!actualText.trim();
|
const hasText = !!actualText.trim();
|
||||||
const hasAttach = !!actualAttach;
|
const hasAttach = !!actualAttach;
|
||||||
if (!hasText && !hasAttach) return;
|
if (!hasText && !hasAttach) return;
|
||||||
if (!contact.x25519Pub) {
|
if (!isSavedMessages && !contact.x25519Pub) {
|
||||||
Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.');
|
Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -188,7 +208,10 @@ export default function ChatScreen() {
|
|||||||
|
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
if (hasText) {
|
// Saved Messages short-circuits the relay entirely — the message never
|
||||||
|
// leaves the device, so no encryption/fee/network round-trip is needed.
|
||||||
|
// Regular chats still go through the NaCl + relay pipeline below.
|
||||||
|
if (hasText && !isSavedMessages) {
|
||||||
const { nonce, ciphertext } = encryptMessage(
|
const { nonce, ciphertext } = encryptMessage(
|
||||||
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
|
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
|
||||||
);
|
);
|
||||||
@@ -224,7 +247,7 @@ export default function ChatScreen() {
|
|||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
text, keyFile, contact, composeMode, chatMsgs,
|
text, keyFile, contact, composeMode, chatMsgs, isSavedMessages,
|
||||||
setMsgs, cancelCompose, appendMsg, pendingAttach,
|
setMsgs, cancelCompose, appendMsg, pendingAttach,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -411,7 +434,7 @@ export default function ChatScreen() {
|
|||||||
hitSlop={4}
|
hitSlop={4}
|
||||||
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
|
||||||
>
|
>
|
||||||
<Avatar name={name} address={contactAddress} size={28} />
|
<Avatar name={name} address={contactAddress} size={28} saved={isSavedMessages} />
|
||||||
<View style={{ minWidth: 0, flexShrink: 1 }}>
|
<View style={{ minWidth: 0, flexShrink: 1 }}>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@@ -429,7 +452,7 @@ export default function ChatScreen() {
|
|||||||
typing…
|
typing…
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!peerTyping && !contact?.x25519Pub && (
|
{!peerTyping && !isSavedMessages && !contact?.x25519Pub && (
|
||||||
<Text style={{ color: '#f0b35a', fontSize: 11 }}>
|
<Text style={{ color: '#f0b35a', fontSize: 11 }}>
|
||||||
waiting for key
|
waiting for key
|
||||||
</Text>
|
</Text>
|
||||||
@@ -447,37 +470,49 @@ export default function ChatScreen() {
|
|||||||
с "scroll position at bottom" без ручного scrollToEnd, и новые
|
с "scroll position at bottom" без ручного scrollToEnd, и новые
|
||||||
сообщения (добавляемые в начало reversed-массива) появляются
|
сообщения (добавляемые в начало reversed-массива) появляются
|
||||||
внизу естественно. Никаких jerk'ов при открытии. */}
|
внизу естественно. Никаких jerk'ов при открытии. */}
|
||||||
<FlatList
|
{rows.length === 0 ? (
|
||||||
ref={listRef}
|
// Empty state is rendered as a plain View instead of
|
||||||
data={rows}
|
// ListEmptyComponent on an inverted FlatList — the previous
|
||||||
inverted
|
// `transform: [{ scaleY: -1 }]` un-flip trick was rendering
|
||||||
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
|
// text mirrored on some Android builds (RTL-aware layout),
|
||||||
renderItem={renderRow}
|
// giving us the "say hi…" backwards bug.
|
||||||
contentContainerStyle={{ paddingVertical: 10 }}
|
<View style={{
|
||||||
showsVerticalScrollIndicator={false}
|
flex: 1, alignItems: 'center', justifyContent: 'center',
|
||||||
// Lazy render: only mount ~1.5 screens of bubbles initially,
|
paddingHorizontal: 32, gap: 10,
|
||||||
// render further batches as the user scrolls older. Keeps
|
}}>
|
||||||
// initial paint fast on chats with thousands of messages.
|
<Avatar
|
||||||
initialNumToRender={25}
|
name={name}
|
||||||
maxToRenderPerBatch={12}
|
address={contactAddress}
|
||||||
windowSize={10}
|
size={72}
|
||||||
removeClippedSubviews
|
saved={isSavedMessages}
|
||||||
ListEmptyComponent={() => (
|
/>
|
||||||
<View style={{
|
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
|
||||||
flex: 1, alignItems: 'center', justifyContent: 'center',
|
{isSavedMessages ? 'Notes to self' : `Say hi to ${name}`}
|
||||||
paddingHorizontal: 32, gap: 10,
|
</Text>
|
||||||
transform: [{ scaleY: -1 }], // inverted flips cells; un-flip empty state
|
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||||
}}>
|
{isSavedMessages
|
||||||
<Avatar name={name} address={contactAddress} size={72} />
|
? 'Anything you send here stays on your device — use it as a scratchpad for links, drafts, or files.'
|
||||||
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
|
: 'Your messages are end-to-end encrypted.'}
|
||||||
Say hi to {name}
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
) : (
|
||||||
Your messages are end-to-end encrypted.
|
<FlatList
|
||||||
</Text>
|
ref={listRef}
|
||||||
</View>
|
data={rows}
|
||||||
)}
|
inverted
|
||||||
/>
|
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
|
||||||
|
renderItem={renderRow}
|
||||||
|
contentContainerStyle={{ paddingVertical: 10 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
// Lazy render: only mount ~1.5 screens of bubbles initially,
|
||||||
|
// render further batches as the user scrolls older. Keeps
|
||||||
|
// initial paint fast on chats with thousands of messages.
|
||||||
|
initialNumToRender={25}
|
||||||
|
maxToRenderPerBatch={12}
|
||||||
|
windowSize={10}
|
||||||
|
removeClippedSubviews
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Composer — floating, прибит к низу. */}
|
{/* Composer — floating, прибит к низу. */}
|
||||||
<View style={{ paddingBottom: Math.max(insets.bottom, 4) + 6, backgroundColor: '#000000' }}>
|
<View style={{ paddingBottom: Math.max(insets.bottom, 4) + 6, backgroundColor: '#000000' }}>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default function ChatsScreen() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const contacts = useStore(s => s.contacts);
|
const contacts = useStore(s => s.contacts);
|
||||||
const messages = useStore(s => s.messages);
|
const messages = useStore(s => s.messages);
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
// Статус подключения: online / connecting / offline.
|
// Статус подключения: online / connecting / offline.
|
||||||
// Название шапки и цвет pip'а на аватаре зависят от него.
|
// Название шапки и цвет pip'а на аватаре зависят от него.
|
||||||
@@ -48,9 +49,14 @@ export default function ChatsScreen() {
|
|||||||
return msgs && msgs.length ? msgs[msgs.length - 1] : null;
|
return msgs && msgs.length ? msgs[msgs.length - 1] : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Сортировка по последней активности.
|
// Сортировка по последней активности. Saved Messages (self-chat) всегда
|
||||||
|
// закреплён сверху — это "Избранное", бессмысленно конкурировать с ним
|
||||||
|
// по recency'и обычным чатам.
|
||||||
|
const selfAddr = keyFile?.pub_key;
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
return [...contacts]
|
const saved = selfAddr ? contacts.find(c => c.address === selfAddr) : undefined;
|
||||||
|
const rest = contacts
|
||||||
|
.filter(c => c.address !== selfAddr)
|
||||||
.map(c => ({ c, last: lastOf(c) }))
|
.map(c => ({ c, last: lastOf(c) }))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
|
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
|
||||||
@@ -58,7 +64,8 @@ export default function ChatsScreen() {
|
|||||||
return kb - ka;
|
return kb - ka;
|
||||||
})
|
})
|
||||||
.map(x => x.c);
|
.map(x => x.c);
|
||||||
}, [contacts, messages]);
|
return saved ? [saved, ...rest] : rest;
|
||||||
|
}, [contacts, messages, selfAddr]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
@@ -72,6 +79,7 @@ export default function ChatsScreen() {
|
|||||||
<ChatTile
|
<ChatTile
|
||||||
contact={item}
|
contact={item}
|
||||||
lastMessage={lastOf(item)}
|
lastMessage={lastOf(item)}
|
||||||
|
saved={item.address === selfAddr}
|
||||||
onPress={() => router.push(`/(app)/chats/${item.address}` as never)}
|
onPress={() => router.push(`/(app)/chats/${item.address}` as never)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,7 +42,18 @@ import { safeBack } from '@/lib/utils';
|
|||||||
const MAX_CONTENT_LENGTH = 4000;
|
const MAX_CONTENT_LENGTH = 4000;
|
||||||
const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize
|
const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize
|
||||||
const IMAGE_MAX_DIM = 1080;
|
const IMAGE_MAX_DIM = 1080;
|
||||||
const IMAGE_QUALITY = 0.5; // JPEG Q=50 — small, still readable
|
// Match the server scrubber's JPEG quality (media/scrub.go:ImageJPEGQuality
|
||||||
|
// = 75). If the client re-encodes at a LOWER quality the server re-encode
|
||||||
|
// at 75 inflates the bytes, often 2-3× — so a 60 KiB upload silently blows
|
||||||
|
// past MaxPostSize = 256 KiB mid-flight and `/feed/publish` rejects with
|
||||||
|
// "post body exceeds maximum allowed size". Using the same Q for both
|
||||||
|
// passes keeps the final footprint ~the same as what the user sees in
|
||||||
|
// the composer.
|
||||||
|
const IMAGE_QUALITY = 0.75;
|
||||||
|
// Safety margin on the pre-upload check: the server pass is near-idempotent
|
||||||
|
// at matching Q but not exactly — reserve ~8 KiB for JPEG header / metadata
|
||||||
|
// scaffolding differences so we don't flirt with the hard cap.
|
||||||
|
const IMAGE_BUDGET_BYTES = MAX_POST_BYTES - 8 * 1024;
|
||||||
|
|
||||||
interface Attachment {
|
interface Attachment {
|
||||||
uri: string;
|
uri: string;
|
||||||
@@ -131,10 +142,10 @@ export default function ComposeScreen() {
|
|||||||
});
|
});
|
||||||
const bytes = base64ToBytes(b64);
|
const bytes = base64ToBytes(b64);
|
||||||
|
|
||||||
if (bytes.length > MAX_POST_BYTES - 512) {
|
if (bytes.length > IMAGE_BUDGET_BYTES) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Image too large',
|
'Image too large',
|
||||||
`Image is ${Math.round(bytes.length / 1024)} KB but the limit is ${MAX_POST_BYTES / 1024} KB. Try picking a smaller one.`,
|
`Image is ${Math.round(bytes.length / 1024)} KB but the post limit is ${MAX_POST_BYTES / 1024} KB (after server re-encode). Try a smaller picture.`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,16 +64,17 @@ export default function NewContactScreen() {
|
|||||||
if (!addr) { setError(`@${name} is not registered on this chain`); return; }
|
if (!addr) { setError(`@${name} is not registered on this chain`); return; }
|
||||||
address = addr;
|
address = addr;
|
||||||
}
|
}
|
||||||
// Block self-lookup — can't message yourself, and the on-chain
|
// Self-lookup: skip the contact-request dance entirely and jump straight
|
||||||
// CONTACT_REQUEST tx would go through but serve no purpose.
|
// 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()) {
|
if (keyFile && address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
|
||||||
setError("That's you. You can't send a contact request to yourself.");
|
router.replace(`/(app)/chats/${keyFile.pub_key}` as never);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const identity = await getIdentity(address);
|
const identity = await getIdentity(address);
|
||||||
const resolvedAddr = identity?.pub_key ?? address;
|
const resolvedAddr = identity?.pub_key ?? address;
|
||||||
if (keyFile && resolvedAddr.toLowerCase() === keyFile.pub_key.toLowerCase()) {
|
if (keyFile && resolvedAddr.toLowerCase() === keyFile.pub_key.toLowerCase()) {
|
||||||
setError("That's you. You can't send a contact request to yourself.");
|
router.replace(`/(app)/chats/${keyFile.pub_key}` as never);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setResolved({
|
setResolved({
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* push stack, so tapping Back returns the user to whatever screen
|
* push stack, so tapping Back returns the user to whatever screen
|
||||||
* pushed them here (feed card tap, chat header tap, etc.).
|
* pushed them here (feed card tap, chat header tap, etc.).
|
||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, ScrollView, Pressable, ActivityIndicator,
|
View, Text, ScrollView, Pressable, ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
@@ -27,8 +27,11 @@ import { Avatar } from '@/components/Avatar';
|
|||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { IconButton } from '@/components/IconButton';
|
import { IconButton } from '@/components/IconButton';
|
||||||
import { followUser, unfollowUser } from '@/lib/feed';
|
import { followUser, unfollowUser } from '@/lib/feed';
|
||||||
import { humanizeTxError } from '@/lib/api';
|
import {
|
||||||
import { safeBack } from '@/lib/utils';
|
humanizeTxError, getBalance, getIdentity, getRelayFor,
|
||||||
|
type IdentityInfo, type RegisteredRelayInfo,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { safeBack, formatAmount } from '@/lib/utils';
|
||||||
|
|
||||||
function shortAddr(a: string, n = 10): string {
|
function shortAddr(a: string, n = 10): string {
|
||||||
if (!a) return '—';
|
if (!a) return '—';
|
||||||
@@ -46,10 +49,35 @@ export default function ProfileScreen() {
|
|||||||
const [followingBusy, setFollowingBusy] = useState(false);
|
const [followingBusy, setFollowingBusy] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// On-chain enrichment — fetched once per address mount.
|
||||||
|
const [balanceUT, setBalanceUT] = useState<number | null>(null);
|
||||||
|
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
|
||||||
|
const [relay, setRelay] = useState<RegisteredRelayInfo | null>(null);
|
||||||
|
const [loadingChain, setLoadingChain] = useState(true);
|
||||||
|
|
||||||
const isMe = !!keyFile && keyFile.pub_key === address;
|
const isMe = !!keyFile && keyFile.pub_key === address;
|
||||||
const displayName = contact?.username
|
|
||||||
? `@${contact.username}`
|
useEffect(() => {
|
||||||
: contact?.alias ?? (isMe ? 'You' : shortAddr(address ?? '', 6));
|
if (!address) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingChain(true);
|
||||||
|
Promise.all([
|
||||||
|
getBalance(address).catch(() => 0),
|
||||||
|
getIdentity(address).catch(() => null),
|
||||||
|
getRelayFor(address).catch(() => null),
|
||||||
|
]).then(([bal, id, rel]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setBalanceUT(bal);
|
||||||
|
setIdentity(id);
|
||||||
|
setRelay(rel);
|
||||||
|
}).finally(() => { if (!cancelled) setLoadingChain(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [address]);
|
||||||
|
const displayName = isMe
|
||||||
|
? 'Saved Messages'
|
||||||
|
: contact?.username
|
||||||
|
? `@${contact.username}`
|
||||||
|
: contact?.alias ?? shortAddr(address ?? '', 6);
|
||||||
|
|
||||||
const copyAddress = async () => {
|
const copyAddress = async () => {
|
||||||
if (!address) return;
|
if (!address) return;
|
||||||
@@ -94,7 +122,7 @@ export default function ProfileScreen() {
|
|||||||
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>
|
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>
|
||||||
{/* ── Hero: avatar + Follow button ──────────────────────────── */}
|
{/* ── Hero: avatar + Follow button ──────────────────────────── */}
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12 }}>
|
||||||
<Avatar name={displayName} address={address} size={72} />
|
<Avatar name={displayName} address={address} size={72} saved={isMe} />
|
||||||
<View style={{ flex: 1 }} />
|
<View style={{ flex: 1 }} />
|
||||||
{!isMe ? (
|
{!isMe ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -131,16 +159,18 @@ export default function ProfileScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.push('/(app)/settings' as never)}
|
onPress={() => keyFile && router.push(`/(app)/chats/${keyFile.pub_key}` as never)}
|
||||||
style={({ pressed }) => ({
|
style={({ pressed }) => ({
|
||||||
paddingHorizontal: 18, paddingVertical: 9,
|
paddingHorizontal: 16, paddingVertical: 9,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||||||
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||||
borderWidth: 1, borderColor: '#1f1f1f',
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
<Ionicons name="bookmark" size={13} color="#f0b35a" />
|
||||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
|
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
|
||||||
Edit
|
Saved Messages
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -159,13 +189,14 @@ export default function ProfileScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Open chat — single CTA, full width, icon inline with text.
|
{/* Action row — View posts is universal (anyone can have a wall,
|
||||||
Only when we know this is a contact (direct chat exists). */}
|
even non-contacts). Open chat appears alongside only when this
|
||||||
{!isMe && contact && (
|
address is already a direct-chat contact. */}
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 14 }}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={openChat}
|
onPress={() => address && router.push(`/(app)/feed/author/${address}` as never)}
|
||||||
style={({ pressed }) => ({
|
style={({ pressed }) => ({
|
||||||
marginTop: 14,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -176,12 +207,34 @@ export default function ProfileScreen() {
|
|||||||
borderWidth: 1, borderColor: '#1f1f1f',
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Ionicons name="chatbubble-outline" size={15} color="#ffffff" />
|
<Ionicons name="document-text-outline" size={15} color="#ffffff" />
|
||||||
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
|
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
|
||||||
Open chat
|
View posts
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
|
||||||
|
{!isMe && contact && (
|
||||||
|
<Pressable
|
||||||
|
onPress={openChat}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingVertical: 11,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="chatbubble-outline" size={15} color="#ffffff" />
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
|
||||||
|
Open chat
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* ── Info card ───────────────────────────────────────────────── */}
|
{/* ── Info card ───────────────────────────────────────────────── */}
|
||||||
<View
|
<View
|
||||||
@@ -225,6 +278,63 @@ export default function ProfileScreen() {
|
|||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Username — shown if the on-chain identity record has one.
|
||||||
|
Different from contact.username (which may be a local alias). */}
|
||||||
|
{identity?.nickname ? (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<InfoRow
|
||||||
|
label="Username"
|
||||||
|
value={`@${identity.nickname}`}
|
||||||
|
icon="at-outline"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* DC address — the human-readable form of the pub key. */}
|
||||||
|
{identity?.address ? (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<InfoRow
|
||||||
|
label="DC address"
|
||||||
|
value={identity.address}
|
||||||
|
icon="pricetag-outline"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Balance — always shown once fetched. */}
|
||||||
|
<Divider />
|
||||||
|
<InfoRow
|
||||||
|
label="Balance"
|
||||||
|
value={loadingChain
|
||||||
|
? '…'
|
||||||
|
: `${formatAmount(balanceUT ?? 0)} UT`}
|
||||||
|
icon="wallet-outline"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Relay node — shown only if this address is a registered relay. */}
|
||||||
|
{relay && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<InfoRow
|
||||||
|
label="Relay node"
|
||||||
|
value={`${formatAmount(relay.relay.fee_per_msg_ut)} UT / msg`}
|
||||||
|
icon="radio-outline"
|
||||||
|
/>
|
||||||
|
{relay.last_heartbeat ? (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<InfoRow
|
||||||
|
label="Last seen"
|
||||||
|
value={new Date(relay.last_heartbeat * 1000).toLocaleString()}
|
||||||
|
icon="pulse-outline"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Encryption status */}
|
{/* Encryption status */}
|
||||||
{contact && (
|
{contact && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text } from 'react-native';
|
import { View, Text } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
export interface AvatarProps {
|
export interface AvatarProps {
|
||||||
/** Имя / @username — берём первый символ для placeholder. */
|
/** Имя / @username — берём первый символ для placeholder. */
|
||||||
@@ -18,6 +19,11 @@ export interface AvatarProps {
|
|||||||
dotColor?: string;
|
dotColor?: string;
|
||||||
/** Класс для обёртки (position: relative кадр). */
|
/** Класс для обёртки (position: relative кадр). */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Saved Messages variant — blue circle with a bookmark glyph, Telegram-style.
|
||||||
|
* When set, `name`/`address` are ignored for the visual.
|
||||||
|
*/
|
||||||
|
saved?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Простое хэширование имени → один из 6 оттенков серого для разнообразия. */
|
/** Простое хэширование имени → один из 6 оттенков серого для разнообразия. */
|
||||||
@@ -28,10 +34,10 @@ function pickBg(seed: string): string {
|
|||||||
return shades[h % shades.length];
|
return shades[h % shades.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Avatar({ name, address, size = 48, dotColor, className }: AvatarProps) {
|
export function Avatar({ name, address, size = 48, dotColor, className, saved }: AvatarProps) {
|
||||||
const seed = (name ?? address ?? '?').replace(/^@/, '');
|
const seed = (name ?? address ?? '?').replace(/^@/, '');
|
||||||
const initial = seed.charAt(0).toUpperCase() || '?';
|
const initial = seed.charAt(0).toUpperCase() || '?';
|
||||||
const bg = pickBg(seed);
|
const bg = saved ? '#1d9bf0' : pickBg(seed);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={className} style={{ width: size, height: size, position: 'relative' }}>
|
<View className={className} style={{ width: size, height: size, position: 'relative' }}>
|
||||||
@@ -45,16 +51,20 @@ export function Avatar({ name, address, size = 48, dotColor, className }: Avatar
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
{saved ? (
|
||||||
style={{
|
<Ionicons name="bookmark" size={size * 0.5} color="#ffffff" />
|
||||||
color: '#d0d0d0',
|
) : (
|
||||||
fontSize: size * 0.4,
|
<Text
|
||||||
fontWeight: '600',
|
style={{
|
||||||
includeFontPadding: false,
|
color: '#d0d0d0',
|
||||||
}}
|
fontSize: size * 0.4,
|
||||||
>
|
fontWeight: '600',
|
||||||
{initial}
|
includeFontPadding: false,
|
||||||
</Text>
|
}}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{dotColor && (
|
{dotColor && (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -57,10 +57,12 @@ export interface ChatTileProps {
|
|||||||
contact: Contact;
|
contact: Contact;
|
||||||
lastMessage: Message | null;
|
lastMessage: Message | null;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
|
/** Render as the Saved Messages tile (blue bookmark avatar, fixed name). */
|
||||||
|
saved?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
|
export function ChatTile({ contact: c, lastMessage, onPress, saved }: ChatTileProps) {
|
||||||
const name = displayName(c);
|
const name = saved ? 'Saved Messages' : displayName(c);
|
||||||
const last = lastMessage;
|
const last = lastMessage;
|
||||||
|
|
||||||
// Визуальный маркер типа чата.
|
// Визуальный маркер типа чата.
|
||||||
@@ -92,7 +94,8 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
|
|||||||
name={name}
|
name={name}
|
||||||
address={c.address}
|
address={c.address}
|
||||||
size={44}
|
size={44}
|
||||||
dotColor={c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined}
|
saved={saved}
|
||||||
|
dotColor={!saved && c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
|
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
|
||||||
@@ -143,9 +146,11 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
|
|||||||
>
|
>
|
||||||
{last
|
{last
|
||||||
? lastPreview(last)
|
? lastPreview(last)
|
||||||
: c.x25519Pub
|
: saved
|
||||||
? 'Tap to start encrypted chat'
|
? 'Your personal notes & files'
|
||||||
: 'Waiting for identity…'}
|
: c.x25519Pub
|
||||||
|
? 'Tap to start encrypted chat'
|
||||||
|
: 'Waiting for identity…'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{unread !== null && (
|
{unread !== null && (
|
||||||
|
|||||||
@@ -80,11 +80,16 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
|
|||||||
|
|
||||||
// Find a display-friendly name for the author. If it's a known contact
|
// Find a display-friendly name for the author. If it's a known contact
|
||||||
// with @username, use that; otherwise short-addr.
|
// with @username, use that; otherwise short-addr.
|
||||||
|
//
|
||||||
|
// `mine` takes precedence over the contact lookup: our own pub key has
|
||||||
|
// a self-contact entry with alias "Saved Messages" (that's how the
|
||||||
|
// self-chat tile is rendered), but that label is wrong in the feed —
|
||||||
|
// posts there should read as "You", not as a messaging-app affordance.
|
||||||
const displayName = useMemo(() => {
|
const displayName = useMemo(() => {
|
||||||
|
if (mine) return 'You';
|
||||||
const c = contacts.find(x => x.address === post.author);
|
const c = contacts.find(x => x.address === post.author);
|
||||||
if (c?.username) return `@${c.username}`;
|
if (c?.username) return `@${c.username}`;
|
||||||
if (c?.alias) return c.alias;
|
if (c?.alias) return c.alias;
|
||||||
if (mine) return 'You';
|
|
||||||
return shortAddr(post.author);
|
return shortAddr(post.author);
|
||||||
}, [contacts, post.author, mine]);
|
}, [contacts, post.author, mine]);
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,39 @@ export interface IdentityInfo {
|
|||||||
registered: boolean;
|
registered: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay registration info for a node pub key, as returned by
|
||||||
|
* /api/relays (which comes back as an array of RegisteredRelayInfo).
|
||||||
|
* We don't wrap the individual lookup on the server — just filter the
|
||||||
|
* full list client-side. It's bounded (N nodes in the network) and
|
||||||
|
* cached heavily enough that this is cheaper than a new endpoint.
|
||||||
|
*/
|
||||||
|
export interface RegisteredRelayInfo {
|
||||||
|
pub_key: string;
|
||||||
|
address: string;
|
||||||
|
relay: {
|
||||||
|
x25519_pub_key: string;
|
||||||
|
fee_per_msg_ut: number;
|
||||||
|
multiaddr?: string;
|
||||||
|
};
|
||||||
|
last_heartbeat?: number; // unix seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/relays — all relay nodes registered on-chain. */
|
||||||
|
export async function getRelays(): Promise<RegisteredRelayInfo[]> {
|
||||||
|
try {
|
||||||
|
return await get<RegisteredRelayInfo[]>('/api/relays');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find relay entry for a specific pub key. null if the address isn't a relay. */
|
||||||
|
export async function getRelayFor(pubKey: string): Promise<RegisteredRelayInfo | null> {
|
||||||
|
const all = await getRelays();
|
||||||
|
return all.find(r => r.pub_key === pubKey) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch identity info for any pubkey or DC address. Returns null on 404. */
|
/** Fetch identity info for any pubkey or DC address. Returns null on 404. */
|
||||||
export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> {
|
export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -114,6 +116,16 @@ func main() {
|
|||||||
// only for intentional migrations (e.g. importing data from another chain
|
// only for intentional migrations (e.g. importing data from another chain
|
||||||
// into this network) — very dangerous.
|
// into this network) — very dangerous.
|
||||||
allowGenesisMismatch := flag.Bool("allow-genesis-mismatch", false, "skip the safety check that aborts when the local genesis hash differs from the seed's. Use only for explicit chain migration.")
|
allowGenesisMismatch := flag.Bool("allow-genesis-mismatch", false, "skip the safety check that aborts when the local genesis hash differs from the seed's. Use only for explicit chain migration.")
|
||||||
|
// ── Resource caps ───────────────────────────────────────────────────────
|
||||||
|
// All four accept 0 meaning "no limit". Enforcement model:
|
||||||
|
// * CPU — runtime.GOMAXPROCS(n): Go runtime won't use more than n OS threads for Go code.
|
||||||
|
// * RAM — debug.SetMemoryLimit: soft limit, the GC works harder as the heap approaches it.
|
||||||
|
// * Feed disk — hard refuse of new post bodies once the cap is crossed (existing posts keep serving).
|
||||||
|
// * Chain disk — warn-only periodic check; we can't hard-reject new blocks without breaking consensus.
|
||||||
|
maxCPU := flag.Int("max-cpu", int(envUint64Or("DCHAIN_MAX_CPU", 0)), "max CPU cores the node may use (GOMAXPROCS). 0 = all (env: DCHAIN_MAX_CPU)")
|
||||||
|
maxRAMMB := flag.Uint64("max-ram-mb", envUint64Or("DCHAIN_MAX_RAM_MB", 0), "soft Go heap limit in MiB (GOMEMLIMIT). 0 = unlimited (env: DCHAIN_MAX_RAM_MB)")
|
||||||
|
feedDiskMB := flag.Uint64("feed-disk-limit-mb", envUint64Or("DCHAIN_FEED_DISK_LIMIT_MB", 0), "disk quota for post bodies in MiB; new posts are refused with 507 once crossed. 0 = unlimited (env: DCHAIN_FEED_DISK_LIMIT_MB)")
|
||||||
|
chainDiskMB := flag.Uint64("chain-disk-limit-mb", envUint64Or("DCHAIN_CHAIN_DISK_LIMIT_MB", 0), "advisory disk cap for the chain DB dir in MiB; exceeding it logs a loud WARN every minute. 0 = unlimited (env: DCHAIN_CHAIN_DISK_LIMIT_MB)")
|
||||||
showVersion := flag.Bool("version", false, "print version info and exit")
|
showVersion := flag.Bool("version", false, "print version info and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -128,6 +140,10 @@ func main() {
|
|||||||
// so subsequent logs inherit the format.
|
// so subsequent logs inherit the format.
|
||||||
setupLogging(*logFormat)
|
setupLogging(*logFormat)
|
||||||
|
|
||||||
|
// Apply CPU / RAM caps before anything else spins up so the runtime
|
||||||
|
// picks them up at first goroutine/heap allocation.
|
||||||
|
applyResourceCaps(*maxCPU, *maxRAMMB)
|
||||||
|
|
||||||
// Wire API access-control. A non-empty token gates writes; adding
|
// Wire API access-control. A non-empty token gates writes; adding
|
||||||
// --api-private also gates reads. Logged up-front so the operator
|
// --api-private also gates reads. Logged up-front so the operator
|
||||||
// sees what mode they're in.
|
// sees what mode they're in.
|
||||||
@@ -641,12 +657,24 @@ func main() {
|
|||||||
|
|
||||||
// --- Feed mailbox (social-feed post bodies, v2.0.0) ---
|
// --- Feed mailbox (social-feed post bodies, v2.0.0) ---
|
||||||
feedTTL := time.Duration(*feedTTLDays) * 24 * time.Hour
|
feedTTL := time.Duration(*feedTTLDays) * 24 * time.Hour
|
||||||
feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL)
|
feedQuotaBytes := int64(*feedDiskMB) * 1024 * 1024
|
||||||
|
feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL, feedQuotaBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[NODE] feed mailbox: %v", err)
|
log.Fatalf("[NODE] feed mailbox: %v", err)
|
||||||
}
|
}
|
||||||
defer feedMailbox.Close()
|
defer feedMailbox.Close()
|
||||||
log.Printf("[NODE] feed mailbox: %s (TTL %d days)", *feedDB, *feedTTLDays)
|
if feedQuotaBytes > 0 {
|
||||||
|
log.Printf("[NODE] feed mailbox: %s (TTL %d days, disk quota %d MiB)", *feedDB, *feedTTLDays, *feedDiskMB)
|
||||||
|
} else {
|
||||||
|
log.Printf("[NODE] feed mailbox: %s (TTL %d days, no disk quota)", *feedDB, *feedTTLDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advisory chain-disk watcher. We can't refuse new blocks (consensus
|
||||||
|
// would stall), so instead we walk the chain DB dir every minute and
|
||||||
|
// log a loud WARN if the operator's budget is exceeded. Zero = disabled.
|
||||||
|
if *chainDiskMB > 0 {
|
||||||
|
go watchChainDisk(*dbPath, int64(*chainDiskMB)*1024*1024)
|
||||||
|
}
|
||||||
|
|
||||||
// Push-notify bus consumers whenever a fresh envelope lands in the
|
// Push-notify bus consumers whenever a fresh envelope lands in the
|
||||||
// mailbox. Clients subscribed to `inbox:<my_x25519>` (via WS) get the
|
// mailbox. Clients subscribed to `inbox:<my_x25519>` (via WS) get the
|
||||||
@@ -1472,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) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -249,10 +250,16 @@ func feedPublish(cfg FeedConfig) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
hashtags, err := cfg.Mailbox.Store(post, req.Ts)
|
hashtags, err := cfg.Mailbox.Store(post, req.Ts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == relay.ErrPostTooLarge {
|
if errors.Is(err, relay.ErrPostTooLarge) {
|
||||||
jsonErr(w, err, 413)
|
jsonErr(w, err, 413)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, relay.ErrFeedQuotaExceeded) {
|
||||||
|
// 507 Insufficient Storage — the client should try
|
||||||
|
// another relay (or wait for TTL-driven eviction here).
|
||||||
|
jsonErr(w, err, 507)
|
||||||
|
return
|
||||||
|
}
|
||||||
jsonErr(w, err, 500)
|
jsonErr(w, err, 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func newFeedHarness(t *testing.T) *feedHarness {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewChain: %v", err)
|
t.Fatalf("NewChain: %v", err)
|
||||||
}
|
}
|
||||||
fm, err := relay.OpenFeedMailbox(feedDir, 24*time.Hour)
|
fm, err := relay.OpenFeedMailbox(feedDir, 24*time.Hour, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenFeedMailbox: %v", err)
|
t.Fatalf("OpenFeedMailbox: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,11 +81,11 @@ func newTwoNodeHarness(t *testing.T) *twoNodeHarness {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("chain B: %v", err)
|
t.Fatalf("chain B: %v", err)
|
||||||
}
|
}
|
||||||
h.aMailbox, err = relay.OpenFeedMailbox(h.aFeedDir, 24*time.Hour)
|
h.aMailbox, err = relay.OpenFeedMailbox(h.aFeedDir, 24*time.Hour, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("feed A: %v", err)
|
t.Fatalf("feed A: %v", err)
|
||||||
}
|
}
|
||||||
h.bMailbox, err = relay.OpenFeedMailbox(h.bFeedDir, 24*time.Hour)
|
h.bMailbox, err = relay.OpenFeedMailbox(h.bFeedDir, 24*time.Hour, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("feed B: %v", err)
|
t.Fatalf("feed B: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,24 +97,35 @@ type FeedPost struct {
|
|||||||
// ErrPostTooLarge is returned by Store when the post body exceeds MaxPostBodySize.
|
// ErrPostTooLarge is returned by Store when the post body exceeds MaxPostBodySize.
|
||||||
var ErrPostTooLarge = errors.New("post body exceeds maximum allowed size")
|
var ErrPostTooLarge = errors.New("post body exceeds maximum allowed size")
|
||||||
|
|
||||||
|
// ErrFeedQuotaExceeded is returned by Store when the on-disk footprint
|
||||||
|
// (LSM + value log) plus the incoming post would exceed the operator-set
|
||||||
|
// disk quota. Ops set this via --feed-disk-limit-mb. Zero = unlimited.
|
||||||
|
var ErrFeedQuotaExceeded = errors.New("feed mailbox disk quota exceeded")
|
||||||
|
|
||||||
// FeedMailbox stores feed post bodies.
|
// FeedMailbox stores feed post bodies.
|
||||||
type FeedMailbox struct {
|
type FeedMailbox struct {
|
||||||
db *badger.DB
|
db *badger.DB
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
|
quotaBytes int64 // 0 = unlimited
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFeedMailbox wraps an already-open Badger DB. TTL controls how long
|
// NewFeedMailbox wraps an already-open Badger DB. TTL controls how long
|
||||||
// post bodies live before auto-eviction (on-chain metadata persists
|
// post bodies live before auto-eviction (on-chain metadata persists
|
||||||
// forever independently).
|
// forever independently). quotaBytes caps the on-disk footprint; 0 or
|
||||||
func NewFeedMailbox(db *badger.DB, ttl time.Duration) *FeedMailbox {
|
// negative means unlimited.
|
||||||
|
func NewFeedMailbox(db *badger.DB, ttl time.Duration, quotaBytes int64) *FeedMailbox {
|
||||||
if ttl <= 0 {
|
if ttl <= 0 {
|
||||||
ttl = time.Duration(FeedPostDefaultTTLDays) * 24 * time.Hour
|
ttl = time.Duration(FeedPostDefaultTTLDays) * 24 * time.Hour
|
||||||
}
|
}
|
||||||
return &FeedMailbox{db: db, ttl: ttl}
|
if quotaBytes < 0 {
|
||||||
|
quotaBytes = 0
|
||||||
|
}
|
||||||
|
return &FeedMailbox{db: db, ttl: ttl, quotaBytes: quotaBytes}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenFeedMailbox opens (or creates) a dedicated BadgerDB at dbPath.
|
// OpenFeedMailbox opens (or creates) a dedicated BadgerDB at dbPath.
|
||||||
func OpenFeedMailbox(dbPath string, ttl time.Duration) (*FeedMailbox, error) {
|
// quotaBytes caps the total on-disk footprint (LSM + vlog); 0 = unlimited.
|
||||||
|
func OpenFeedMailbox(dbPath string, ttl time.Duration, quotaBytes int64) (*FeedMailbox, error) {
|
||||||
opts := badger.DefaultOptions(dbPath).
|
opts := badger.DefaultOptions(dbPath).
|
||||||
WithLogger(nil).
|
WithLogger(nil).
|
||||||
WithValueLogFileSize(128 << 20).
|
WithValueLogFileSize(128 << 20).
|
||||||
@@ -124,9 +135,19 @@ func OpenFeedMailbox(dbPath string, ttl time.Duration) (*FeedMailbox, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open feed mailbox db: %w", err)
|
return nil, fmt.Errorf("open feed mailbox db: %w", err)
|
||||||
}
|
}
|
||||||
return NewFeedMailbox(db, ttl), nil
|
return NewFeedMailbox(db, ttl, quotaBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiskUsage returns the current on-disk footprint (LSM + value log) in
|
||||||
|
// bytes. Cheap — Badger tracks these counters internally.
|
||||||
|
func (fm *FeedMailbox) DiskUsage() int64 {
|
||||||
|
lsm, vlog := fm.db.Size()
|
||||||
|
return lsm + vlog
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quota returns the configured disk quota in bytes. 0 = unlimited.
|
||||||
|
func (fm *FeedMailbox) Quota() int64 { return fm.quotaBytes }
|
||||||
|
|
||||||
// Close releases the underlying Badger handle.
|
// Close releases the underlying Badger handle.
|
||||||
func (fm *FeedMailbox) Close() error { return fm.db.Close() }
|
func (fm *FeedMailbox) Close() error { return fm.db.Close() }
|
||||||
|
|
||||||
@@ -139,7 +160,23 @@ func (fm *FeedMailbox) Close() error { return fm.db.Close() }
|
|||||||
func (fm *FeedMailbox) Store(post *FeedPost, createdAt int64) ([]string, error) {
|
func (fm *FeedMailbox) Store(post *FeedPost, createdAt int64) ([]string, error) {
|
||||||
size := estimatePostSize(post)
|
size := estimatePostSize(post)
|
||||||
if size > MaxPostBodySize {
|
if size > MaxPostBodySize {
|
||||||
return nil, ErrPostTooLarge
|
// Wrap the sentinel so the HTTP layer can still errors.Is() on it
|
||||||
|
// while the operator / client sees the actual offending numbers.
|
||||||
|
// This catches the common case where the client's pre-scrub
|
||||||
|
// estimate is below the cap but the server re-encode (quality=75
|
||||||
|
// JPEG) inflates past it.
|
||||||
|
return nil, fmt.Errorf("%w: size %d > max %d (after server scrub)",
|
||||||
|
ErrPostTooLarge, size, MaxPostBodySize)
|
||||||
|
}
|
||||||
|
// Disk quota: refuse new bodies once we're already over the cap.
|
||||||
|
// `size` is a post-body estimate, not the exact BadgerDB write-amp
|
||||||
|
// cost; we accept that slack — the goal is a coarse guard-rail so
|
||||||
|
// an operator's disk doesn't blow up unnoticed. Exceeding nodes
|
||||||
|
// still serve existing posts; only new Store() calls are refused.
|
||||||
|
if fm.quotaBytes > 0 {
|
||||||
|
if fm.DiskUsage()+int64(size) > fm.quotaBytes {
|
||||||
|
return nil, ErrFeedQuotaExceeded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post.CreatedAt = createdAt
|
post.CreatedAt = createdAt
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package relay
|
package relay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,7 +13,7 @@ func newTestFeedMailbox(t *testing.T) *FeedMailbox {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("MkdirTemp: %v", err)
|
t.Fatalf("MkdirTemp: %v", err)
|
||||||
}
|
}
|
||||||
fm, err := OpenFeedMailbox(dir, 24*time.Hour)
|
fm, err := OpenFeedMailbox(dir, 24*time.Hour, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.RemoveAll(dir)
|
_ = os.RemoveAll(dir)
|
||||||
t.Fatalf("OpenFeedMailbox: %v", err)
|
t.Fatalf("OpenFeedMailbox: %v", err)
|
||||||
@@ -75,7 +76,7 @@ func TestFeedMailboxTooLarge(t *testing.T) {
|
|||||||
Author: "a",
|
Author: "a",
|
||||||
Attachment: big,
|
Attachment: big,
|
||||||
}
|
}
|
||||||
if _, err := fm.Store(post, 0); err != ErrPostTooLarge {
|
if _, err := fm.Store(post, 0); !errors.Is(err, ErrPostTooLarge) {
|
||||||
t.Fatalf("Store huge post: got %v, want ErrPostTooLarge", err)
|
t.Fatalf("Store huge post: got %v, want ErrPostTooLarge", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user