Compare commits
10 Commits
29e95485fa
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a75cbcd224 | ||
|
|
e6f3d2bcf8 | ||
|
|
e62b72b5be | ||
|
|
f7a849ddcb | ||
|
|
060ac6c2c9 | ||
|
|
516940fa8e | ||
|
|
3e9ddc1a43 | ||
|
|
6ed4e7ca50 | ||
|
|
f726587ac6 | ||
|
|
1e7f4d8da4 |
@@ -1,5 +1,5 @@
|
||||
# ---- build stage ----
|
||||
FROM golang:1.24-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
155
README.md
155
README.md
@@ -22,6 +22,7 @@
|
||||
## Содержание
|
||||
|
||||
- [Быстрый старт](#быстрый-старт)
|
||||
- [Поднятие ноды — пошагово](#поднятие-ноды--пошагово)
|
||||
- [Продакшен деплой](#продакшен-деплой)
|
||||
- [Архитектура](#архитектура)
|
||||
- [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).
|
||||
|
||||
## Поднятие ноды — пошагово
|
||||
|
||||
Ниже — полный минимум для двух сценариев, которые покрывают 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).
|
||||
|
||||
## Продакшен деплой
|
||||
|
||||
Два варианта, по масштабу.
|
||||
|
||||
@@ -23,9 +23,9 @@ import { useWellKnownContracts } from '@/hooks/useWellKnownContracts';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useGlobalInbox } from '@/hooks/useGlobalInbox';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { useDevSeed } from '@/lib/devSeed';
|
||||
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);
|
||||
@@ -37,18 +37,36 @@ export default function AppLayout() {
|
||||
// - 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);
|
||||
/^\/feed\/.+/.test(pathname) ||
|
||||
/^\/tx\/.+/.test(pathname);
|
||||
|
||||
useBalance();
|
||||
useContacts();
|
||||
useWellKnownContracts();
|
||||
useDevSeed();
|
||||
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 });
|
||||
|
||||
@@ -26,7 +26,7 @@ import { encryptMessage } from '@/lib/crypto';
|
||||
import { sendEnvelope } from '@/lib/api';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||
import { randomId } from '@/lib/utils';
|
||||
import { randomId, safeBack } from '@/lib/utils';
|
||||
import type { Message } from '@/lib/types';
|
||||
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
@@ -63,6 +63,24 @@ export default function ChatScreen() {
|
||||
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);
|
||||
@@ -121,9 +139,9 @@ export default function ChatScreen() {
|
||||
// Восстановить сообщения из persistent-storage при первом заходе в чат.
|
||||
//
|
||||
// Важно: НЕ перезаписываем store пустым массивом — это стёрло бы
|
||||
// содержимое, которое уже лежит в zustand (например, из devSeed или
|
||||
// только что полученные по WS сообщения пока монтировались). Если
|
||||
// в кэше что-то есть — мержим: берём max(cached, in-store) по id.
|
||||
// содержимое, которое уже лежит в zustand (только что полученные по
|
||||
// WS сообщения пока монтировались). Если в кэше что-то есть — мержим:
|
||||
// берём max(cached, in-store) по id.
|
||||
useEffect(() => {
|
||||
if (!contactAddress) return;
|
||||
loadMessages(contactAddress).then(cached => {
|
||||
@@ -137,9 +155,11 @@ export default function ChatScreen() {
|
||||
});
|
||||
}, [contactAddress, setMsgs]);
|
||||
|
||||
const name = contact?.username
|
||||
? `@${contact.username}`
|
||||
: contact?.alias ?? shortAddr(contactAddress ?? '');
|
||||
const name = isSavedMessages
|
||||
? 'Saved Messages'
|
||||
: contact?.username
|
||||
? `@${contact.username}`
|
||||
: contact?.alias ?? shortAddr(contactAddress ?? '');
|
||||
|
||||
// ── Compose actions ────────────────────────────────────────────────────
|
||||
const cancelCompose = useCallback(() => {
|
||||
@@ -172,7 +192,7 @@ export default function ChatScreen() {
|
||||
const hasText = !!actualText.trim();
|
||||
const hasAttach = !!actualAttach;
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
@@ -188,7 +208,10 @@ export default function ChatScreen() {
|
||||
|
||||
setSending(true);
|
||||
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(
|
||||
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
|
||||
);
|
||||
@@ -224,7 +247,7 @@ export default function ChatScreen() {
|
||||
setSending(false);
|
||||
}
|
||||
}, [
|
||||
text, keyFile, contact, composeMode, chatMsgs,
|
||||
text, keyFile, contact, composeMode, chatMsgs, isSavedMessages,
|
||||
setMsgs, cancelCompose, appendMsg, pendingAttach,
|
||||
]);
|
||||
|
||||
@@ -404,14 +427,14 @@ export default function ChatScreen() {
|
||||
) : (
|
||||
<Header
|
||||
divider
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
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} />
|
||||
<Avatar name={name} address={contactAddress} size={28} saved={isSavedMessages} />
|
||||
<View style={{ minWidth: 0, flexShrink: 1 }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
@@ -429,7 +452,7 @@ export default function ChatScreen() {
|
||||
typing…
|
||||
</Text>
|
||||
)}
|
||||
{!peerTyping && !contact?.x25519Pub && (
|
||||
{!peerTyping && !isSavedMessages && !contact?.x25519Pub && (
|
||||
<Text style={{ color: '#f0b35a', fontSize: 11 }}>
|
||||
waiting for key
|
||||
</Text>
|
||||
@@ -447,37 +470,49 @@ export default function ChatScreen() {
|
||||
с "scroll position at bottom" без ручного scrollToEnd, и новые
|
||||
сообщения (добавляемые в начало reversed-массива) появляются
|
||||
внизу естественно. Никаких jerk'ов при открытии. */}
|
||||
<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
|
||||
ListEmptyComponent={() => (
|
||||
<View style={{
|
||||
flex: 1, alignItems: 'center', justifyContent: 'center',
|
||||
paddingHorizontal: 32, gap: 10,
|
||||
transform: [{ scaleY: -1 }], // inverted flips cells; un-flip empty state
|
||||
}}>
|
||||
<Avatar name={name} address={contactAddress} size={72} />
|
||||
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
|
||||
Say hi to {name}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||
Your messages are end-to-end encrypted.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
{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' }}>
|
||||
|
||||
@@ -28,6 +28,7 @@ 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'а на аватаре зависят от него.
|
||||
@@ -48,9 +49,14 @@ export default function ChatsScreen() {
|
||||
return msgs && msgs.length ? msgs[msgs.length - 1] : null;
|
||||
};
|
||||
|
||||
// Сортировка по последней активности.
|
||||
// Сортировка по последней активности. Saved Messages (self-chat) всегда
|
||||
// закреплён сверху — это "Избранное", бессмысленно конкурировать с ним
|
||||
// по recency'и обычным чатам.
|
||||
const selfAddr = keyFile?.pub_key;
|
||||
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) }))
|
||||
.sort((a, b) => {
|
||||
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
|
||||
@@ -58,7 +64,8 @@ export default function ChatsScreen() {
|
||||
return kb - ka;
|
||||
})
|
||||
.map(x => x.c);
|
||||
}, [contacts, messages]);
|
||||
return saved ? [saved, ...rest] : rest;
|
||||
}, [contacts, messages, selfAddr]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
@@ -72,6 +79,7 @@ export default function ChatsScreen() {
|
||||
<ChatTile
|
||||
contact={item}
|
||||
lastMessage={lastOf(item)}
|
||||
saved={item.address === selfAddr}
|
||||
onPress={() => router.push(`/(app)/chats/${item.address}` as never)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -37,11 +37,23 @@ 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;
|
||||
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 {
|
||||
uri: string;
|
||||
@@ -98,11 +110,11 @@ export default function ComposeScreen() {
|
||||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert(
|
||||
'Нужен доступ к фото',
|
||||
'Откройте настройки и разрешите доступ к галерее.',
|
||||
'Photo access required',
|
||||
'Please enable photo library access in Settings.',
|
||||
[
|
||||
{ text: 'Отмена' },
|
||||
{ text: 'Настройки', onPress: () => Linking.openSettings() },
|
||||
{ text: 'Cancel' },
|
||||
{ text: 'Settings', onPress: () => Linking.openSettings() },
|
||||
],
|
||||
);
|
||||
return;
|
||||
@@ -130,10 +142,10 @@ export default function ComposeScreen() {
|
||||
});
|
||||
const bytes = base64ToBytes(b64);
|
||||
|
||||
if (bytes.length > MAX_POST_BYTES - 512) {
|
||||
if (bytes.length > IMAGE_BUDGET_BYTES) {
|
||||
Alert.alert(
|
||||
'Слишком большое',
|
||||
`Картинка ${Math.round(bytes.length / 1024)} KB — лимит ${MAX_POST_BYTES / 1024} KB. Попробуйте выбрать поменьше.`,
|
||||
'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;
|
||||
}
|
||||
@@ -147,7 +159,7 @@ export default function ComposeScreen() {
|
||||
height: manipulated.height,
|
||||
});
|
||||
} catch (e: any) {
|
||||
Alert.alert('Не удалось', String(e?.message ?? e));
|
||||
Alert.alert('Failed', String(e?.message ?? e));
|
||||
} finally {
|
||||
setPicking(false);
|
||||
}
|
||||
@@ -159,19 +171,19 @@ export default function ComposeScreen() {
|
||||
// Balance guard.
|
||||
if (balance !== null && balance < estimatedFee) {
|
||||
Alert.alert(
|
||||
'Недостаточно средств',
|
||||
`Нужно ${formatFee(estimatedFee)}, на балансе ${formatFee(balance)}.`,
|
||||
'Insufficient balance',
|
||||
`Need ${formatFee(estimatedFee)}, have ${formatFee(balance)}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Опубликовать пост?',
|
||||
`Цена: ${formatFee(estimatedFee)}\nРазмер: ${Math.round(totalBytes / 1024 * 10) / 10} KB`,
|
||||
'Publish post?',
|
||||
`Cost: ${formatFee(estimatedFee)}\nSize: ${Math.round(totalBytes / 1024 * 10) / 10} KB`,
|
||||
[
|
||||
{ text: 'Отмена', style: 'cancel' },
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Опубликовать',
|
||||
text: 'Publish',
|
||||
onPress: async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -185,7 +197,7 @@ export default function ComposeScreen() {
|
||||
// Close composer and open the new post.
|
||||
router.replace(`/(app)/feed/${postID}` as never);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Не удалось опубликовать', humanizeTxError(e));
|
||||
Alert.alert('Failed to publish', humanizeTxError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -212,7 +224,7 @@ export default function ComposeScreen() {
|
||||
borderBottomColor: '#141414',
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={() => router.back()} hitSlop={8}>
|
||||
<Pressable onPress={() => safeBack()} hitSlop={8}>
|
||||
<Ionicons name="close" size={26} color="#ffffff" />
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }} />
|
||||
@@ -235,7 +247,7 @@ export default function ComposeScreen() {
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Опубликовать
|
||||
Publish
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -251,7 +263,7 @@ export default function ComposeScreen() {
|
||||
<TextInput
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Что происходит?"
|
||||
placeholder="What's happening?"
|
||||
placeholderTextColor="#5a5a5a"
|
||||
multiline
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
@@ -328,7 +340,7 @@ export default function ComposeScreen() {
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6 }}>
|
||||
{Math.round(attach.size / 1024)} KB · метаданные удалят на сервере
|
||||
{Math.round(attach.size / 1024)} KB · metadata stripped on server
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ 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();
|
||||
@@ -71,15 +72,15 @@ export default function PostDetailScreen() {
|
||||
|
||||
const onDeleted = useCallback(() => {
|
||||
// Go back to feed — the post is gone.
|
||||
router.back();
|
||||
safeBack();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
divider
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
title="Пост"
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||
title="Post"
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
@@ -94,7 +95,7 @@ export default function PostDetailScreen() {
|
||||
<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>
|
||||
) : (
|
||||
@@ -131,18 +132,18 @@ export default function PostDetailScreen() {
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
Информация о посте
|
||||
Post details
|
||||
</Text>
|
||||
|
||||
<DetailRow label="Просмотров" value={formatCount(stats?.views ?? post.views)} />
|
||||
<DetailRow label="Лайков" value={formatCount(stats?.likes ?? post.likes)} />
|
||||
<DetailRow label="Размер" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} />
|
||||
<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="Стоимость публикации"
|
||||
label="Paid to publish"
|
||||
value={formatFee(1000 + post.size)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Хостинг"
|
||||
label="Hosted on"
|
||||
value={shortAddr(post.hosting_relay)}
|
||||
mono
|
||||
/>
|
||||
@@ -151,7 +152,7 @@ export default function PostDetailScreen() {
|
||||
<>
|
||||
<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 => (
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -27,14 +27,13 @@ import {
|
||||
fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView,
|
||||
type FeedPostItem,
|
||||
} from '@/lib/feed';
|
||||
import { getDevSeedFeed } from '@/lib/devSeedFeed';
|
||||
|
||||
type TabKey = 'following' | 'foryou' | 'trending';
|
||||
|
||||
const TAB_LABELS: Record<TabKey, string> = {
|
||||
following: 'Подписки',
|
||||
foryou: 'Для вас',
|
||||
trending: 'В тренде',
|
||||
following: 'Following',
|
||||
foryou: 'For you',
|
||||
trending: 'Trending',
|
||||
};
|
||||
|
||||
export default function FeedScreen() {
|
||||
@@ -78,12 +77,6 @@ export default function FeedScreen() {
|
||||
}
|
||||
if (seq !== requestRef.current) return; // stale response
|
||||
|
||||
// Dev-only fallback: if the node has no real posts yet, surface
|
||||
// synthetic ones so we can scroll + tap. Stripped from production.
|
||||
if (items.length === 0) {
|
||||
items = getDevSeedFeed();
|
||||
}
|
||||
|
||||
setPosts(items);
|
||||
// If the server returned fewer than PAGE_SIZE, we already have
|
||||
// everything — disable further paginated fetches.
|
||||
@@ -105,11 +98,11 @@ export default function FeedScreen() {
|
||||
} catch (e: any) {
|
||||
if (seq !== requestRef.current) return;
|
||||
const msg = String(e?.message ?? e);
|
||||
// Network / 404 is benign — node just unreachable or empty. In __DEV__
|
||||
// fall back to synthetic seed posts so the scroll / tap UI stays
|
||||
// testable; in production this path shows the empty state.
|
||||
// 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(getDevSeedFeed());
|
||||
setPosts([]);
|
||||
setExhausted(true);
|
||||
} else {
|
||||
setError(msg);
|
||||
@@ -208,15 +201,15 @@ export default function FeedScreen() {
|
||||
|
||||
const emptyHint = useMemo(() => {
|
||||
switch (tab) {
|
||||
case 'following': return 'Подпишитесь на кого-нибудь, чтобы увидеть их посты здесь.';
|
||||
case 'foryou': return 'Пока нет рекомендаций — возвращайтесь позже.';
|
||||
case 'trending': return 'В этой ленте пока тихо.';
|
||||
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="Лента" />
|
||||
<TabHeader title="Feed" />
|
||||
|
||||
{/* Tab strip — три таба, равномерно распределены по ширине
|
||||
(justifyContent: space-between). Каждый Pressable hug'ает
|
||||
@@ -312,14 +305,14 @@ export default function FeedScreen() {
|
||||
) : error ? (
|
||||
<EmptyState
|
||||
icon="alert-circle-outline"
|
||||
title="Не удалось загрузить ленту"
|
||||
title="Couldn't load feed"
|
||||
subtitle={error}
|
||||
onRetry={() => loadPosts(false)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="newspaper-outline"
|
||||
title="Здесь пока пусто"
|
||||
title="Nothing to show yet"
|
||||
subtitle={emptyHint}
|
||||
/>
|
||||
)
|
||||
@@ -416,7 +409,7 @@ function EmptyState({
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
|
||||
Попробовать снова
|
||||
Try again
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ 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();
|
||||
@@ -80,7 +81,7 @@ export default function HashtagScreen() {
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
divider
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||
title={`#${tag}`}
|
||||
/>
|
||||
|
||||
@@ -119,10 +120,10 @@ export default function HashtagScreen() {
|
||||
}}>
|
||||
<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 }}>
|
||||
Будьте первым — напишите пост с #{tag}
|
||||
Be the first — write a post with #{tag}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ 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 } from '@/lib/utils';
|
||||
import { formatAmount, safeBack } from '@/lib/utils';
|
||||
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Header } from '@/components/Header';
|
||||
@@ -64,9 +64,21 @@ export default function NewContactScreen() {
|
||||
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: identity?.pub_key ?? address,
|
||||
address: resolvedAddr,
|
||||
nickname: identity?.nickname || undefined,
|
||||
x25519: identity?.x25519_pub || undefined,
|
||||
});
|
||||
@@ -79,8 +91,12 @@ export default function NewContactScreen() {
|
||||
|
||||
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)} (fee + network).`);
|
||||
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (request fee + network).`);
|
||||
return;
|
||||
}
|
||||
setSending(true); setError(null);
|
||||
@@ -96,7 +112,7 @@ export default function NewContactScreen() {
|
||||
Alert.alert(
|
||||
'Request sent',
|
||||
`A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
|
||||
[{ text: 'OK', onPress: () => router.back() }],
|
||||
[{ text: 'OK', onPress: () => safeBack() }],
|
||||
);
|
||||
} catch (e: any) {
|
||||
setError(humanizeTxError(e));
|
||||
@@ -112,42 +128,65 @@ export default function NewContactScreen() {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="New chat"
|
||||
title="Search"
|
||||
divider={false}
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||
/>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 14, paddingBottom: 120 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 14 }}>
|
||||
Enter a <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text>, a
|
||||
hex pubkey or a <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC…</Text> address.
|
||||
</Text>
|
||||
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="@alice or hex / DC address"
|
||||
placeholder="@alice, hex pubkey or DC address"
|
||||
onSubmitEditing={search}
|
||||
autoFocus
|
||||
onClear={() => { setResolved(null); setError(null); }}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
onPress={search}
|
||||
disabled={searching || !query.trim()}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 11, borderRadius: 999, marginTop: 12,
|
||||
backgroundColor: !query.trim() || searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
})}
|
||||
>
|
||||
{searching ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
{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={{
|
||||
@@ -195,7 +234,7 @@ export default function NewContactScreen() {
|
||||
color: resolved.x25519 ? '#3ba55d' : '#f0b35a',
|
||||
fontSize: 11, fontWeight: '500',
|
||||
}}>
|
||||
{resolved.x25519 ? 'E2E-ready' : 'Key not published yet'}
|
||||
{resolved.x25519 ? 'E2E ready' : 'Key not published yet'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -227,8 +266,16 @@ export default function NewContactScreen() {
|
||||
|
||||
{/* Fee tier */}
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 14, marginBottom: 6 }}>
|
||||
Anti-spam fee (goes to recipient)
|
||||
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;
|
||||
@@ -238,25 +285,32 @@ export default function NewContactScreen() {
|
||||
onPress={() => setFee(t.value)}
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: active ? '#ffffff' : pressed ? '#1a1a1a' : '#111111',
|
||||
borderWidth: 1, borderColor: active ? '#ffffff' : '#1f1f1f',
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* push stack, so tapping Back returns the user to whatever screen
|
||||
* pushed them here (feed card tap, chat header tap, etc.).
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, Pressable, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
@@ -27,7 +27,11 @@ import { Avatar } from '@/components/Avatar';
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { followUser, unfollowUser } from '@/lib/feed';
|
||||
import { humanizeTxError } from '@/lib/api';
|
||||
import {
|
||||
humanizeTxError, getBalance, getIdentity, getRelayFor,
|
||||
type IdentityInfo, type RegisteredRelayInfo,
|
||||
} from '@/lib/api';
|
||||
import { safeBack, formatAmount } from '@/lib/utils';
|
||||
|
||||
function shortAddr(a: string, n = 10): string {
|
||||
if (!a) return '—';
|
||||
@@ -45,10 +49,35 @@ export default function ProfileScreen() {
|
||||
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;
|
||||
const displayName = contact?.username
|
||||
? `@${contact.username}`
|
||||
: contact?.alias ?? (isMe ? 'Вы' : shortAddr(address ?? '', 6));
|
||||
|
||||
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;
|
||||
@@ -85,15 +114,15 @@ export default function ProfileScreen() {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="Профиль"
|
||||
title="Profile"
|
||||
divider
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||
/>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>
|
||||
{/* ── Hero: avatar + Follow button ──────────────────────────── */}
|
||||
<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 }} />
|
||||
{!isMe ? (
|
||||
<Pressable
|
||||
@@ -124,22 +153,24 @@ export default function ProfileScreen() {
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{following ? 'Вы подписаны' : 'Подписаться'}
|
||||
{following ? 'Following' : 'Follow'}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => router.push('/(app)/settings' as never)}
|
||||
onPress={() => keyFile && router.push(`/(app)/chats/${keyFile.pub_key}` as never)}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 18, paddingVertical: 9,
|
||||
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>
|
||||
)}
|
||||
@@ -158,13 +189,14 @@ export default function ProfileScreen() {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Open chat — single CTA, full width, icon inline with text.
|
||||
Only when we know this is a contact (direct chat exists). */}
|
||||
{!isMe && contact && (
|
||||
{/* 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={openChat}
|
||||
onPress={() => address && router.push(`/(app)/feed/author/${address}` as never)}
|
||||
style={({ pressed }) => ({
|
||||
marginTop: 14,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -175,12 +207,34 @@ export default function ProfileScreen() {
|
||||
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 }}>
|
||||
Открыть чат
|
||||
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
|
||||
@@ -203,7 +257,7 @@ export default function ProfileScreen() {
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>
|
||||
Адрес
|
||||
Address
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
@@ -214,7 +268,7 @@ export default function ProfileScreen() {
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{copied ? 'Скопировано' : shortAddr(address ?? '')}
|
||||
{copied ? 'Copied' : shortAddr(address ?? '')}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={copied ? 'checkmark' : 'copy-outline'}
|
||||
@@ -224,22 +278,79 @@ export default function ProfileScreen() {
|
||||
/>
|
||||
</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="Шифрование"
|
||||
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="Добавлен"
|
||||
label="Added"
|
||||
value={new Date(contact.addedAt).toLocaleDateString()}
|
||||
icon="calendar-outline"
|
||||
/>
|
||||
@@ -251,7 +362,7 @@ export default function ProfileScreen() {
|
||||
<>
|
||||
<Divider />
|
||||
<InfoRow
|
||||
label="Участников"
|
||||
label="Members"
|
||||
value="—"
|
||||
icon="people-outline"
|
||||
/>
|
||||
@@ -270,7 +381,7 @@ export default function ProfileScreen() {
|
||||
paddingHorizontal: 24,
|
||||
lineHeight: 17,
|
||||
}}>
|
||||
Этот пользователь пока не в ваших контактах. Нажмите «Подписаться», чтобы видеть его посты в ленте, или добавьте в чаты через @username.
|
||||
This user isn't in your contacts yet. Tap "Follow" to see their posts in your feed, or add them as a chat contact via @username.
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function RequestsScreen() {
|
||||
{name}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
|
||||
wants to message you · {relativeTime(req.timestamp)}
|
||||
wants to add you as a contact · {relativeTime(req.timestamp)}
|
||||
</Text>
|
||||
{req.intro ? (
|
||||
<Text
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
humanizeTxError,
|
||||
} from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { formatAmount } from '@/lib/utils';
|
||||
import { formatAmount, safeBack } from '@/lib/utils';
|
||||
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Header } from '@/components/Header';
|
||||
@@ -335,7 +335,7 @@ export default function SettingsScreen() {
|
||||
<Header
|
||||
title="Settings"
|
||||
divider={false}
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||
/>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: 120 }}
|
||||
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ 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';
|
||||
|
||||
@@ -346,7 +347,7 @@ function TxTile({
|
||||
const color = toneColor(m.tone);
|
||||
|
||||
return (
|
||||
<Pressable>
|
||||
<Pressable onPress={() => router.push(`/(app)/tx/${tx.hash}` as never)}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -37,7 +38,7 @@ export default function CreateAccountScreen() {
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="Create account"
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
|
||||
/>
|
||||
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700', marginBottom: 4 }}>
|
||||
|
||||
@@ -15,6 +15,7 @@ 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';
|
||||
@@ -96,7 +97,7 @@ export default function ImportKeyScreen() {
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="Import key"
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
|
||||
/>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 14, paddingBottom: 40 }}
|
||||
|
||||
@@ -187,17 +187,17 @@ export default function WelcomeScreen() {
|
||||
<FeatureRow
|
||||
icon="lock-closed"
|
||||
title="End-to-end encryption"
|
||||
text="X25519 + NaCl на каждом сообщении. Даже релей-нода не может прочитать переписку."
|
||||
text="X25519 + NaCl on every message. Not even the relay node can read your conversations."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon="key"
|
||||
title="Твои ключи — твой аккаунт"
|
||||
text="Без телефона, email и серверных паролей. Ключи никогда не покидают устройство."
|
||||
title="Your keys, your account"
|
||||
text="No phone, email, or server passwords. Keys never leave your device."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon="git-network"
|
||||
title="Decentralised"
|
||||
text="Любой может поднять свою ноду. Нет единой точки отказа и цензуры."
|
||||
text="Anyone can run a node. No single point of failure or censorship."
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
@@ -206,7 +206,7 @@ export default function WelcomeScreen() {
|
||||
flexDirection: 'row', justifyContent: 'flex-end',
|
||||
paddingHorizontal: 24, paddingBottom: 8,
|
||||
}}>
|
||||
<CTAPrimary label="Продолжить" onPress={() => goToPage(1)} />
|
||||
<CTAPrimary label="Continue" onPress={() => goToPage(1)} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -223,22 +223,22 @@ export default function WelcomeScreen() {
|
||||
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="Публичная нода"
|
||||
text="Удобно и быстро — нода хостится комьюнити, небольшая комиссия за каждое отправленное сообщение."
|
||||
title="Public node"
|
||||
text="Quick and easy — community-hosted relay, small fee per delivered message."
|
||||
/>
|
||||
<OptionCard
|
||||
icon="hardware-chip"
|
||||
title="Своя нода"
|
||||
text="Максимальный контроль. Исходники открыты — подними на своём сервере за 5 минут."
|
||||
title="Self-hosted"
|
||||
text="Maximum control. Source is open — spin up your own in five minutes."
|
||||
/>
|
||||
|
||||
<Text style={{
|
||||
@@ -302,11 +302,11 @@ export default function WelcomeScreen() {
|
||||
paddingHorizontal: 24, paddingBottom: 8,
|
||||
}}>
|
||||
<CTASecondary
|
||||
label="Исходники"
|
||||
label="Source"
|
||||
icon="logo-github"
|
||||
onPress={() => Linking.openURL(GITEA_URL).catch(() => {})}
|
||||
/>
|
||||
<CTAPrimary label="Продолжить" onPress={() => goToPage(2)} />
|
||||
<CTAPrimary label="Continue" onPress={() => goToPage(2)} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -334,11 +334,11 @@ export default function WelcomeScreen() {
|
||||
<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>
|
||||
@@ -349,11 +349,11 @@ export default function WelcomeScreen() {
|
||||
paddingHorizontal: 24, paddingBottom: 8,
|
||||
}}>
|
||||
<CTASecondary
|
||||
label="Импорт"
|
||||
label="Import"
|
||||
onPress={() => router.push('/(auth)/import' as never)}
|
||||
/>
|
||||
<CTAPrimary
|
||||
label="Создать аккаунт"
|
||||
label="Create account"
|
||||
onPress={() => router.push('/(auth)/create' as never)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
export interface AvatarProps {
|
||||
/** Имя / @username — берём первый символ для placeholder. */
|
||||
@@ -18,6 +19,11 @@ export interface AvatarProps {
|
||||
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 оттенков серого для разнообразия. */
|
||||
@@ -28,10 +34,10 @@ function pickBg(seed: string): string {
|
||||
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 initial = seed.charAt(0).toUpperCase() || '?';
|
||||
const bg = pickBg(seed);
|
||||
const bg = saved ? '#1d9bf0' : pickBg(seed);
|
||||
|
||||
return (
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: '#d0d0d0',
|
||||
fontSize: size * 0.4,
|
||||
fontWeight: '600',
|
||||
includeFontPadding: false,
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</Text>
|
||||
{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
|
||||
|
||||
@@ -57,10 +57,12 @@ 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 }: ChatTileProps) {
|
||||
const name = displayName(c);
|
||||
export function ChatTile({ contact: c, lastMessage, onPress, saved }: ChatTileProps) {
|
||||
const name = saved ? 'Saved Messages' : displayName(c);
|
||||
const last = lastMessage;
|
||||
|
||||
// Визуальный маркер типа чата.
|
||||
@@ -92,7 +94,8 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
|
||||
name={name}
|
||||
address={c.address}
|
||||
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 }}>
|
||||
@@ -143,9 +146,11 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
|
||||
>
|
||||
{last
|
||||
? lastPreview(last)
|
||||
: c.x25519Pub
|
||||
? 'Tap to start encrypted chat'
|
||||
: 'Waiting for identity…'}
|
||||
: saved
|
||||
? 'Your personal notes & files'
|
||||
: c.x25519Pub
|
||||
? 'Tap to start encrypted chat'
|
||||
: 'Waiting for identity…'}
|
||||
</Text>
|
||||
|
||||
{unread !== null && (
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* SearchBar — серый блок, в состоянии idle текст с иконкой 🔍 отцентрированы.
|
||||
*
|
||||
* Когда пользователь тапает/фокусирует — поле становится input-friendly, но
|
||||
* визуально рестайл не нужен: при наличии текста placeholder скрыт и
|
||||
* пользовательский ввод выравнивается влево автоматически (multiline off).
|
||||
* SearchBar — single-TextInput pill. Icon + input в одном ряду, без
|
||||
* idle/focused двойного состояния (раньше был хак с невидимым
|
||||
* TextInput поверх отцентрированного Text — ломал focus и выравнивание
|
||||
* на Android).
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { View, TextInput, Text } from 'react-native';
|
||||
import React from 'react';
|
||||
import { View, TextInput, Pressable } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
export interface SearchBarProps {
|
||||
@@ -15,73 +14,55 @@ export interface SearchBarProps {
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
onSubmitEditing?: () => void;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing,
|
||||
value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing, onClear,
|
||||
}: SearchBarProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
// Placeholder центрируется пока нет фокуса И нет значения.
|
||||
// Как только юзер фокусируется или начинает печатать — иконка+текст
|
||||
// прыгают к левому краю, чтобы не мешать вводу.
|
||||
const centered = !focused && !value;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#111111',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 9,
|
||||
minHeight: 36,
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{centered ? (
|
||||
// ── Idle state — только текст+icon, отцентрированы.
|
||||
// Невидимый TextInput поверх ловит tap, чтобы не дергать focus вручную.
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 6 }} />
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 14 }}>{placeholder}</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
autoFocus={autoFocus}
|
||||
onFocus={() => setFocused(true)}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
returnKeyType="search"
|
||||
style={{
|
||||
position: 'absolute', left: 0, right: 0, top: 0, bottom: 0,
|
||||
color: 'transparent',
|
||||
// Скрываем cursor в idle-режиме; при focus компонент перерисуется.
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 8 }} />
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#8b8b8b"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
returnKeyType="search"
|
||||
style={{
|
||||
flex: 1,
|
||||
color: '#ffffff',
|
||||
fontSize: 14,
|
||||
padding: 0,
|
||||
includeFontPadding: false,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -90,7 +90,7 @@ export function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefC
|
||||
letterSpacing: 1.2,
|
||||
}}
|
||||
>
|
||||
ПОСТ
|
||||
POST
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -132,7 +132,7 @@ export function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefC
|
||||
>
|
||||
<Ionicons name="image-outline" size={11} color={subColor} />
|
||||
<Text style={{ color: subColor, fontSize: 11 }}>
|
||||
с фото
|
||||
photo
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
// 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;
|
||||
if (mine) return 'You';
|
||||
return shortAddr(post.author);
|
||||
}, [contacts, post.author, mine]);
|
||||
|
||||
@@ -109,7 +114,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
|
||||
// Roll back optimistic update.
|
||||
setLocalLiked(wasLiked);
|
||||
setLocalLikeCount(c => c + (wasLiked ? 1 : -1));
|
||||
Alert.alert('Не удалось', String(e?.message ?? e));
|
||||
Alert.alert('Failed', String(e?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -128,13 +133,13 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
|
||||
const options: Array<{ label: string; destructive?: boolean; onPress: () => void }> = [];
|
||||
if (mine) {
|
||||
options.push({
|
||||
label: 'Удалить пост',
|
||||
label: 'Delete post',
|
||||
destructive: true,
|
||||
onPress: () => {
|
||||
Alert.alert('Удалить пост?', 'Это действие нельзя отменить.', [
|
||||
{ text: 'Отмена', style: 'cancel' },
|
||||
Alert.alert('Delete post?', 'This action cannot be undone.', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Удалить',
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
@@ -145,7 +150,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
|
||||
});
|
||||
onDeleted?.(post.post_id);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Ошибка', String(e?.message ?? e));
|
||||
Alert.alert('Error', String(e?.message ?? e));
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -160,9 +165,9 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
|
||||
style: (o.destructive ? 'destructive' : 'default') as 'default' | 'destructive',
|
||||
onPress: o.onPress,
|
||||
})),
|
||||
{ text: 'Отмена', style: 'cancel' as const },
|
||||
{ text: 'Cancel', style: 'cancel' as const },
|
||||
];
|
||||
Alert.alert('Действия', '', buttons);
|
||||
Alert.alert('Actions', '', buttons);
|
||||
}, [keyFile, mine, post.post_id, onDeleted]);
|
||||
|
||||
// Attachment preview URL — native Image can stream straight from the
|
||||
|
||||
@@ -74,14 +74,14 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
|
||||
post, contacts: targets, keyFile,
|
||||
});
|
||||
if (failed > 0) {
|
||||
Alert.alert('Готово', `Отправлено в ${ok} из ${ok + failed} чат${plural(ok + failed)}.`);
|
||||
Alert.alert('Done', `Sent to ${ok} of ${ok + failed} ${plural(ok + failed)}.`);
|
||||
}
|
||||
// Close + reset regardless — done is done.
|
||||
setPicked(new Set());
|
||||
setQuery('');
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Не удалось', String(e?.message ?? e));
|
||||
Alert.alert('Failed', String(e?.message ?? e));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
|
||||
paddingHorizontal: 16, marginBottom: 10,
|
||||
}}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}>
|
||||
Поделиться постом
|
||||
Share post
|
||||
</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Pressable onPress={closeAndReset} hitSlop={8}>
|
||||
@@ -158,7 +158,7 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Поиск по контактам"
|
||||
placeholder="Search contacts"
|
||||
placeholderTextColor="#5a5a5a"
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -196,8 +196,8 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
|
||||
<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>
|
||||
}
|
||||
@@ -227,8 +227,8 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
|
||||
fontSize: 14,
|
||||
}}>
|
||||
{picked.size === 0
|
||||
? 'Выберите контакты'
|
||||
: `Отправить (${picked.size})`}
|
||||
? 'Select contacts'
|
||||
: `Send (${picked.size})`}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -298,10 +298,5 @@ function shortAddr(a: string, n = 6): string {
|
||||
}
|
||||
|
||||
function plural(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
const mod10 = n % 10;
|
||||
if (mod100 >= 11 && mod100 <= 19) return 'ов';
|
||||
if (mod10 === 1) return '';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'а';
|
||||
return 'ов';
|
||||
return n === 1 ? 'chat' : 'chats';
|
||||
}
|
||||
|
||||
@@ -79,23 +79,23 @@ async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
export function humanizeTxError(e: unknown): string {
|
||||
const raw = e instanceof Error ? e.message : String(e);
|
||||
if (raw.startsWith('429')) {
|
||||
return 'Слишком много запросов к ноде. Подождите пару секунд и попробуйте снова.';
|
||||
return 'Too many requests to the node. Wait a couple of seconds and try again.';
|
||||
}
|
||||
if (raw.startsWith('400') && raw.includes('timestamp')) {
|
||||
return 'Часы устройства не синхронизированы с нодой. Проверьте время на телефоне (±1 час).';
|
||||
return 'Device clock is out of sync with the node. Check the time on your phone (±1 hour).';
|
||||
}
|
||||
if (raw.startsWith('400') && raw.includes('signature')) {
|
||||
return 'Подпись транзакции невалидна. Попробуйте ещё раз; если не помогает — вероятна несовместимость версий клиента и ноды.';
|
||||
return 'Transaction signature is invalid. Try again; if this persists the client and node versions may be incompatible.';
|
||||
}
|
||||
if (raw.startsWith('400')) {
|
||||
return `Нода отклонила транзакцию: ${raw.replace(/^400:\s*/, '')}`;
|
||||
return `Node rejected transaction: ${raw.replace(/^400:\s*/, '')}`;
|
||||
}
|
||||
if (raw.startsWith('5')) {
|
||||
return `Ошибка ноды (${raw}). Попробуйте позже.`;
|
||||
return `Node error (${raw}). Please try again later.`;
|
||||
}
|
||||
// Network-level
|
||||
if (raw.toLowerCase().includes('network request failed')) {
|
||||
return 'Нет связи с нодой. Проверьте URL в настройках и доступность сервера.';
|
||||
return 'Cannot reach the node. Check the URL in settings and that the server is online.';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -191,6 +191,43 @@ export async function submitTx(tx: RawTx): Promise<{ id: string; status: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full transaction detail as returned by GET /api/tx/{id}. Matches the
|
||||
* explorer's txDetail wire format. Payload is JSON-decoded when the
|
||||
* node recognises the tx type, otherwise payload_hex is set.
|
||||
*/
|
||||
export interface TxDetail {
|
||||
id: string;
|
||||
type: string;
|
||||
memo?: string;
|
||||
from: string;
|
||||
from_addr?: string;
|
||||
to?: string;
|
||||
to_addr?: string;
|
||||
amount_ut: number;
|
||||
amount: string;
|
||||
fee_ut: number;
|
||||
fee: string;
|
||||
time: string; // ISO-8601 UTC
|
||||
block_index: number;
|
||||
block_hash: string;
|
||||
block_time: string; // ISO-8601 UTC
|
||||
gas_used?: number;
|
||||
payload?: unknown;
|
||||
payload_hex?: string;
|
||||
signature_hex?: string;
|
||||
}
|
||||
|
||||
/** Fetch full tx detail by hash/id. Returns null on 404. */
|
||||
export async function getTxDetail(txID: string): Promise<TxDetail | null> {
|
||||
try {
|
||||
return await get<TxDetail>(`/api/tx/${txID}`);
|
||||
} catch (e: any) {
|
||||
if (/→\s*404\b/.test(String(e?.message))) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord[]> {
|
||||
const data = await get<AddrResponse>(`/api/address/${pubkey}?limit=${limit}`);
|
||||
return (data.transactions ?? []).map(tx => ({
|
||||
@@ -302,7 +339,7 @@ export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
|
||||
// ─── Contact requests (on-chain) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps blockchain.ContactInfo returned by GET /api/relay/contacts?pub=...
|
||||
* Maps blockchain.ContactInfo returned by GET /relay/contacts?pub=...
|
||||
* The response shape is { pub, count, contacts: ContactInfo[] }.
|
||||
*/
|
||||
export interface ContactRequestRaw {
|
||||
@@ -316,7 +353,7 @@ export interface ContactRequestRaw {
|
||||
}
|
||||
|
||||
export async function fetchContactRequests(edPubHex: string): Promise<ContactRequestRaw[]> {
|
||||
const data = await get<{ contacts: ContactRequestRaw[] }>(`/api/relay/contacts?pub=${edPubHex}`);
|
||||
const data = await get<{ contacts: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPubHex}`);
|
||||
return data.contacts ?? [];
|
||||
}
|
||||
|
||||
@@ -330,6 +367,39 @@ export interface IdentityInfo {
|
||||
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 {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* который тоже в секундах). Для ms-таймштампов делаем нормализацию внутри.
|
||||
*/
|
||||
|
||||
// ─── Русские месяцы (genitive для "17 июня 2025") ────────────────────────────
|
||||
const RU_MONTHS_GEN = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
|
||||
// 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 {
|
||||
@@ -20,8 +20,8 @@ function sameDay(a: Date, b: Date): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-bucket label для сепараторов внутри чата.
|
||||
* "Сегодня" / "Вчера" / "17 июня 2025"
|
||||
* Day-bucket label for chat separators.
|
||||
* "Today" / "Yesterday" / "Jun 17, 2025"
|
||||
*
|
||||
* @param ts unix-seconds
|
||||
*/
|
||||
@@ -29,9 +29,9 @@ 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 'Сегодня';
|
||||
if (sameDay(d, yday)) return 'Вчера';
|
||||
return `${d.getDate()} ${RU_MONTHS_GEN[d.getMonth()]} ${d.getFullYear()}`;
|
||||
if (sameDay(d, now)) return 'Today';
|
||||
if (sameDay(d, yday)) return 'Yesterday';
|
||||
return `${MONTHS_SHORT[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
/**
|
||||
* Dev seed — заполняет store фейковыми контактами и сообщениями для UI-теста.
|
||||
*
|
||||
* Запускается один раз при монтировании layout'а если store пустой
|
||||
* (useDevSeed). Реальные контакты через WS/HTTP приходят позже —
|
||||
* `upsertContact` перезаписывает mock'и по address'у.
|
||||
*
|
||||
* Цели seed'а:
|
||||
* 1. Показать все три типа чатов (direct / group / channel) с разным
|
||||
* поведением sender-meta.
|
||||
* 2. Наполнить список чатов до скролла (15+ контактов).
|
||||
* 3. В каждом чате — ≥15 сообщений для скролла в chat view.
|
||||
* 4. Продемонстрировать "staircase" (run'ы одного отправителя
|
||||
* внутри 1h-окна) и переключения между отправителями.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useStore } from './store';
|
||||
import type { Contact, Message } from './types';
|
||||
|
||||
// ─── Детерминированные «pubkey» (64 hex символа) ───────────────────
|
||||
function fakeHex(seed: number): string {
|
||||
let h = '';
|
||||
let x = seed;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
x = (x * 1103515245 + 12345) & 0xffffffff;
|
||||
h += (x & 0xff).toString(16).padStart(2, '0');
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const MINE = fakeHex(9999);
|
||||
|
||||
// ─── Контакты ──────────────────────────────────────────────────────
|
||||
// 16 штук: 5 DM + 6 групп + 5 каналов. Поле `addedAt` задаёт порядок в
|
||||
// списке когда нет messages — ordering-fallback.
|
||||
|
||||
const mockContacts: Contact[] = [
|
||||
// ── DM ──────────────────────────────────────────────────────────
|
||||
{ address: fakeHex(1001), x25519Pub: fakeHex(2001),
|
||||
username: 'jordan', addedAt: Date.now() - 60 * 60 * 1_000, kind: 'direct' },
|
||||
{ address: fakeHex(1002), x25519Pub: fakeHex(2002),
|
||||
alias: 'Myles Wagner', addedAt: Date.now() - 2 * 60 * 60 * 1_000, kind: 'direct' },
|
||||
{ address: fakeHex(1010), x25519Pub: fakeHex(2010),
|
||||
username: 'sarah_k', addedAt: Date.now() - 3 * 60 * 60 * 1_000, kind: 'direct',
|
||||
unread: 2 },
|
||||
{ address: fakeHex(1011), x25519Pub: fakeHex(2011),
|
||||
alias: 'Mom', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'direct' },
|
||||
{ address: fakeHex(1012), x25519Pub: fakeHex(2012),
|
||||
username: 'alex_dev', addedAt: Date.now() - 6 * 60 * 60 * 1_000, kind: 'direct' },
|
||||
|
||||
// ── Groups ─────────────────────────────────────────────────────
|
||||
{ address: fakeHex(1003), x25519Pub: fakeHex(2003),
|
||||
alias: 'Tahoe weekend 🌲', addedAt: Date.now() - 4 * 60 * 60 * 1_000, kind: 'group' },
|
||||
{ address: fakeHex(1004), x25519Pub: fakeHex(2004),
|
||||
alias: 'Knicks tickets', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'group',
|
||||
unread: 3 },
|
||||
{ address: fakeHex(1020), x25519Pub: fakeHex(2020),
|
||||
alias: 'Family', addedAt: Date.now() - 8 * 60 * 60 * 1_000, kind: 'group' },
|
||||
{ address: fakeHex(1021), x25519Pub: fakeHex(2021),
|
||||
alias: 'Work eng', addedAt: Date.now() - 12 * 60 * 60 * 1_000, kind: 'group',
|
||||
unread: 7 },
|
||||
{ address: fakeHex(1022), x25519Pub: fakeHex(2022),
|
||||
alias: 'Book club', addedAt: Date.now() - 24 * 60 * 60 * 1_000, kind: 'group' },
|
||||
{ address: fakeHex(1023), x25519Pub: fakeHex(2023),
|
||||
alias: 'Tuesday D&D 🎲', addedAt: Date.now() - 30 * 60 * 60 * 1_000, kind: 'group' },
|
||||
|
||||
// (Channel seeds removed in v2.0.0 — channels replaced by the social feed.)
|
||||
];
|
||||
|
||||
// ─── Генератор сообщений ───────────────────────────────────────────
|
||||
|
||||
// Альт-отправители для group-чатов — нужны только как идентификатор `from`.
|
||||
const P_TYRA = fakeHex(3001);
|
||||
const P_MYLES = fakeHex(3002);
|
||||
const P_NATE = fakeHex(3003);
|
||||
const P_TYLER = fakeHex(3004);
|
||||
const P_MOM = fakeHex(3005);
|
||||
const P_DAD = fakeHex(3006);
|
||||
const P_SIS = fakeHex(3007);
|
||||
const P_LEAD = fakeHex(3008);
|
||||
const P_PM = fakeHex(3009);
|
||||
const P_QA = fakeHex(3010);
|
||||
const P_DESIGN= fakeHex(3011);
|
||||
const P_ANNA = fakeHex(3012);
|
||||
const P_DM_PEER = fakeHex(3013);
|
||||
|
||||
type Msg = Omit<Message, 'id'>;
|
||||
|
||||
function list(prefix: string, list: Msg[]): Message[] {
|
||||
return list.map((m, i) => ({ ...m, id: `${prefix}_${i}` }));
|
||||
}
|
||||
|
||||
function mockMessagesFor(contact: Contact): Message[] {
|
||||
const peer = contact.x25519Pub;
|
||||
|
||||
// ── DM: @jordan ────────────────────────────────────────────────
|
||||
if (contact.username === 'jordan') {
|
||||
// Важно: id'ы сообщений используются в replyTo.id, поэтому
|
||||
// указываем их явно где нужно сшить thread.
|
||||
const msgs: Message[] = list('jordan', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 22, text: 'Hey, have a sec later today?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 21, read: true, text: 'yep around 4pm' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'cool, coffee at the corner spot?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 19, read: true, text: 'works' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'just parked 🚗' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'see you in 5' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 3, read: true, text: "that was a great catchup" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: "totally — thanks for the book rec" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 40, text: 'Hey Jordan - Got tickets to the Knicks game tomorrow, let me know if you want to come!' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 39, text: "we've got floor seats 🔥" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 38, text: "starts at 7, pregame at the bar across the street" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: 'Ah sadly I already have plans' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 13, read: true, text: 'maybe next time?' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 5, text: "no worries — enjoy whatever you're up to" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 2, text: "wish you could make it tho 🏀" },
|
||||
]);
|
||||
// Пришьём reply: MINE-сообщение "Ah sadly…" отвечает на "Hey Jordan - Got tickets…"
|
||||
const target = msgs.find(m => m.text.startsWith('Hey Jordan - Got tickets'));
|
||||
const mine = msgs.find(m => m.text === 'Ah sadly I already have plans');
|
||||
if (target && mine) {
|
||||
mine.replyTo = {
|
||||
id: target.id,
|
||||
author: '@jordan',
|
||||
text: target.text,
|
||||
};
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
// ── DM: Myles Wagner ───────────────────────────────────────────
|
||||
if (contact.alias === 'Myles Wagner') {
|
||||
return list('myles', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'saw the draft, left a bunch of comments' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 29, read: true, text: 'thx, going through them now' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 29, text: 'no rush — tomorrow is fine' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'lunch today?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: "can't, stuck in reviews" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: 'tomorrow?' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: '✅ tomorrow' },
|
||||
{
|
||||
from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: '',
|
||||
attachment: {
|
||||
kind: 'voice',
|
||||
uri: 'voice-demo://myles-1',
|
||||
duration: 17,
|
||||
},
|
||||
},
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'the dchain repo finally built for me' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'docker weirdness was the issue' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 21, read: true, text: "nice, told you the WSL path would do it" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 20, text: 'So good!' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── DM: @sarah_k (с unread=2) ──────────────────────────────────
|
||||
if (contact.username === 'sarah_k') {
|
||||
return list('sarah', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: "hey! been a while" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 28, read: true, text: 'yeah, finally surfaced after the launch crunch' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 27, text: 'how did it go?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 27, read: true, text: "pretty well actually 🙏" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'btw drinks on friday?' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'that new wine bar' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'around 7 if you can make it' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── DM: Mom ────────────────────────────────────────────────────
|
||||
if (contact.alias === 'Mom') {
|
||||
return list('mom', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Did you see the photos from the trip?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 47, read: true, text: 'not yet, send them again?' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 47, text: 'ok' },
|
||||
{
|
||||
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
|
||||
attachment: {
|
||||
kind: 'image',
|
||||
uri: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800',
|
||||
width: 800, height: 533, mime: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
{
|
||||
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
|
||||
attachment: {
|
||||
kind: 'image',
|
||||
uri: 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=800',
|
||||
width: 800, height: 533, mime: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: 'wow, grandma looks great' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'she asked about you!' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'call later?' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── DM: @alex_dev ──────────────────────────────────────────────
|
||||
if (contact.username === 'alex_dev') {
|
||||
return list('alex', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'did you try the new WASM build?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 11, read: true, text: 'yeah, loader error on start' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 11, text: 'path encoding issue again?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 10, read: true, text: 'probably, checking now' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 8, read: true, text: 'yep, was the trailing slash' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 8, text: 'classic 😅' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'PR for that incoming tomorrow' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Group: Tahoe weekend 🌲 ────────────────────────────────────
|
||||
if (contact.alias === 'Tahoe weekend 🌲') {
|
||||
const msgs: Message[] = list('tahoe', [
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: "who's in for Tahoe this weekend?" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 49, text: "me!" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 48, read: true, text: "count me in" },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: "woohoo 🎉" },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 47, text: "planning friday night → sunday evening yeah?" },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 46, text: "yep, maybe leave friday after lunch" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "I made this itinerary with Grok, what do you think?" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 1: Eagle Falls hike" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 2: Emerald bay kayak" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 3: lazy breakfast then drive back" },
|
||||
{
|
||||
from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: '',
|
||||
attachment: {
|
||||
kind: 'file',
|
||||
uri: 'https://example.com/Lake_Tahoe_Itinerary.pdf',
|
||||
name: 'Lake_Tahoe_Itinerary.pdf',
|
||||
size: 97_280, // ~95 KB
|
||||
mime: 'application/pdf',
|
||||
},
|
||||
},
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 24, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "Eagle falls was stunning last year!" },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 31, text: "who's excited for Tahoe this weekend?" },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 30, text: "I've been checking the forecast — sun all weekend 🌞" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 22, text: "I made this itinerary with Grok, what do you think?" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 21, text: "Day 1 we can hit Eagle Falls" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
|
||||
{
|
||||
from: P_TYRA, mine: false, timestamp: now() - 60 * 3, text: 'pic from my last trip 😍',
|
||||
attachment: {
|
||||
kind: 'image',
|
||||
uri: 'https://images.unsplash.com/photo-1505245208761-ba872912fac0?w=800',
|
||||
width: 800,
|
||||
height: 1000,
|
||||
mime: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
]);
|
||||
// Thread: mine "Love it — Eagle falls looks insane" — ответ на
|
||||
// Myles'овский itinerary-PDF. Берём ПЕРВЫЙ match "Day 1 we can hit
|
||||
// Eagle Falls" и пришиваем его к первому mine-bubble'у.
|
||||
const target = msgs.find(m => m.text === 'Day 1 we can hit Eagle Falls');
|
||||
const reply = msgs.find(m => m.text === 'Love it — Eagle falls looks insane' && m.mine);
|
||||
if (target && reply) {
|
||||
reply.replyTo = {
|
||||
id: target.id,
|
||||
author: 'Myles Wagner',
|
||||
text: target.text,
|
||||
};
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
// ── Group: Knicks tickets ──────────────────────────────────────
|
||||
if (contact.alias === 'Knicks tickets') {
|
||||
return list('knicks', [
|
||||
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 20, text: "quick group update — got 5 tickets for thursday" },
|
||||
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'wow nice' },
|
||||
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'where are we seated?' },
|
||||
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 19, text: 'section 102, row 12' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 18, read: true, text: 'thats a great spot' },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 18, text: "can someone venmo nate 🙏" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 17, read: true, text: 'sending now' },
|
||||
{ from: P_NATE, mine: false, timestamp: now() - 60 * 32, text: "Ok who's in for tomorrow's game?" },
|
||||
{ from: P_NATE, mine: false, timestamp: now() - 60 * 31, text: 'Got 2 extra tickets, first-come-first-served' },
|
||||
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 27, text: "I'm in!" },
|
||||
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 26, text: 'What time does it start?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 20, read: true, text: "Let's meet at the bar around 6?" },
|
||||
{ from: P_NATE, mine: false, timestamp: now() - 60 * 15, text: 'Sounds good' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Group: Family ──────────────────────────────────────────────
|
||||
if (contact.alias === 'Family') {
|
||||
return list('family', [
|
||||
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 36, text: 'remember grandma birthday on sunday' },
|
||||
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 36, text: 'noted 🎂' },
|
||||
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 35, text: 'who is bringing the cake?' },
|
||||
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 35, text: "I'll get it from the bakery" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 34, read: true, text: 'I can pick up flowers' },
|
||||
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 34, text: 'perfect' },
|
||||
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 8, text: 'forecast is rain sunday — backup plan?' },
|
||||
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 8, text: "we'll move indoors, the living room works" },
|
||||
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 7, text: 'works!' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Group: Work eng (unread=7) ─────────────────────────────────
|
||||
if (contact.alias === 'Work eng') {
|
||||
return list('work', [
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 16, text: 'standup at 10 moved to 11 today btw' },
|
||||
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 16, text: 'thanks!' },
|
||||
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 15, text: "the staging deploy broke again 🙃" },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 15, text: "ugh, looking" },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 14, text: 'fixed — migration was stuck' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 13, read: true, text: 'Worked for me now 👍' },
|
||||
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 5, text: 'reminder: demo tomorrow, slides by eod' },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Ill handle the technical half' },
|
||||
{ from: P_DESIGN,mine: false,timestamp: now() - 60 * 60 * 4, text: 'just posted the v2 mocks in figma' },
|
||||
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 3, text: 'chatting with sales — 3 new trials this week' },
|
||||
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 2, text: 'flaky test on CI — investigating' },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 30, text: 'okay seems like CI is green now' },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 28, text: 'retry passed' },
|
||||
{ from: P_PM, mine: false, timestamp: now() - 60 * 20, text: "we're good for release" },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Group: Book club ───────────────────────────────────────────
|
||||
if (contact.alias === 'Book club') {
|
||||
return list('book', [
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 96, text: 'next month: "Project Hail Mary"?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 95, read: true, text: '👍' },
|
||||
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 94, text: 'yes please' },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'halfway through — so good' },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'love the linguistics angle' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: "rocky is my favourite character in years" },
|
||||
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 28, text: 'agreed' },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "let's meet sunday 4pm?" },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Group: Tuesday D&D 🎲 ──────────────────────────────────────
|
||||
if (contact.alias === 'Tuesday D&D 🎲') {
|
||||
return list('dnd', [
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 72, text: 'Session 14 recap up on the wiki' },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 72, text: '🙏' },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: 'can we start 30min late next tuesday? commute issue' },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 50, text: 'sure' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 49, read: true, text: 'works for me' },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 32, text: 'we pick up where we left — in the dragons cave' },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 32, text: 'excited 🐉' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Channel: dchain_updates ────────────────────────────────────
|
||||
if (contact.username === 'dchain_updates') {
|
||||
return list('dchain_updates', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 96, text: '🔨 v0.0.1-alpha tagged on Gitea' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 72, text: 'PBFT equivocation-detection тесты зелёные' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'New: /api/peers теперь включает peer-version info' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: '📘 Docs overhaul merged: docs/README.md' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 36, text: 'Schema migration scaffold landed (no-op для текущей версии)' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: '🚀 v0.0.1 released' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 10, text: 'Includes: auto-update from Gitea, peer-version gossip, schema migrations' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 20, text: 'Check /api/well-known-version for the full feature list' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'Thanks to all testers — feedback drives the roadmap 🙏' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'v0.0.2 roadmap published: https://git.vsecoder.vodka/vsecoder/dchain' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 30, text: 'quick heads-up: nightly builds switching to new docker-slim base' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Channel: Relay broadcasts ──────────────────────────────────
|
||||
if (contact.alias === '⚡ Relay broadcasts') {
|
||||
return list('relay_bc', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Relay fleet snapshot: 12 active, 3 inactive' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Relay #3 came online in US-east' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Validator set updated: 3→4' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'PBFT view-change детектирован и отработан на блоке 184120' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 15, text: 'Mailbox eviction ran — 42 stale envelopes' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'Relay #8 slashed for equivocation — evidence at block 184202' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'Relay #12 came online in EU-west, registering now…' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Channel: Tech news ────────────────────────────────────────
|
||||
if (contact.alias === '📰 Tech news') {
|
||||
return list('tech_news', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Rust 1.78 released — new lints for raw pointers' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Go 1.23 ships range-over-func officially' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Expo SDK 54 drops — new-architecture default' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'CVE-2026-1337 patched in libsodium (update your keys)' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Matrix protocol adds post-quantum handshakes' },
|
||||
{
|
||||
from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Data-center aerial view — new hyperscaler in Iceland',
|
||||
attachment: {
|
||||
kind: 'image',
|
||||
uri: 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800',
|
||||
width: 800, height: 533, mime: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'IETF draft: "DNS-over-blockchain"' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 6, text: 'GitHub tightens 2FA defaults for orgs' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Channel: Design inspo (unread=12) ──────────────────────────
|
||||
if (contact.alias === '🎨 Design inspo') {
|
||||
return list('design_inspo', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Weekly pick: Linear UI v3 breakdown' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 140, text: 'Figma file of the week: "Command bar patterns"' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Motion study: Stripe checkout shake-error animation' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: "10 great empty-state illustrations (blogpost)" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Tool: Hatch — colour-palette extractor from photos' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: '🔮 Trend watch: glassmorphism is back (again)' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Twitter thread: why rounded buttons are the default' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'Framer templates — black friday sale' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'New typeface: "Grotesk Pro" — free for personal use' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Channel: NBA scores ────────────────────────────────────────
|
||||
if (contact.alias === '🏀 NBA scores') {
|
||||
return list('nba', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Lakers 112 — Warriors 108 (OT)' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 130, text: 'Celtics 128 — Heat 115' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Nuggets 119 — Thunder 102' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 70, text: "Knicks 101 — Bulls 98" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Mavericks 130 — Kings 127' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 24, text: 'Bucks 114 — Sixers 110' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Live: Lakers leading 78-72 at half' },
|
||||
]);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// ─── Hook ──────────────────────────────────────────────────────────
|
||||
|
||||
export function useDevSeed() {
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const setContacts = useStore(s => s.setContacts);
|
||||
const setMessages = useStore(s => s.setMessages);
|
||||
|
||||
useEffect(() => {
|
||||
if (contacts.length > 0) return;
|
||||
setContacts(mockContacts);
|
||||
for (const c of mockContacts) {
|
||||
const msgs = mockMessagesFor(c);
|
||||
if (msgs.length > 0) setMessages(c.address, msgs);
|
||||
}
|
||||
}, [contacts.length, setContacts, setMessages]);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* Dev-only mock posts for the feed.
|
||||
*
|
||||
* Why: in __DEV__ before any real posts exist on the node, the timeline/
|
||||
* for-you/trending tabs come back empty. Empty state is fine visually but
|
||||
* doesn't let you test scrolling, like animations, view-counter bumps,
|
||||
* navigation to post detail, etc. This module injects a small set of
|
||||
* synthetic posts so the UI has something to chew on.
|
||||
*
|
||||
* Gating:
|
||||
* - Only active when __DEV__ === true (stripped from production builds).
|
||||
* - Only surfaces when the REAL API returns an empty array. If the node
|
||||
* is returning actual posts, we trust those and skip the mocks.
|
||||
*
|
||||
* These posts have made-up post_ids — tapping on them to open detail
|
||||
* WILL 404 against the real backend. That's intentional — the mock is
|
||||
* purely for scroll / tap-feedback testing.
|
||||
*/
|
||||
import type { FeedPostItem } from './feed';
|
||||
|
||||
// Fake hex-like pubkeys so Avatar's colour hash still looks varied.
|
||||
function fakeAddr(seed: number): string {
|
||||
const h = (seed * 2654435761).toString(16).padStart(8, '0');
|
||||
return (h + h + h + h).slice(0, 64);
|
||||
}
|
||||
|
||||
function fakePostID(n: number): string {
|
||||
return `dev${String(n).padStart(29, '0')}`;
|
||||
}
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Small curated pool of posts covering the render surface we care about:
|
||||
// plain text, hashtag variety, different lengths, likes / views spread,
|
||||
// reply/quote references, one with an attachment marker.
|
||||
const SEED_POSTS: FeedPostItem[] = [
|
||||
{
|
||||
post_id: fakePostID(1),
|
||||
author: fakeAddr(1),
|
||||
content: 'Добро пожаловать в ленту DChain. Это #DEV-посты — они видны только пока реальная лента пустая.',
|
||||
created_at: NOW - 60,
|
||||
size: 200,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 127, likes: 42,
|
||||
has_attachment: false,
|
||||
hashtags: ['dev'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(2),
|
||||
author: fakeAddr(2),
|
||||
content: 'Пробую новую ленту #twitter-style. Лайки, просмотры, подписки — всё on-chain, тела постов — off-chain в mailbox релея.',
|
||||
created_at: NOW - 540,
|
||||
size: 310,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 89, likes: 23,
|
||||
has_attachment: false,
|
||||
hashtags: ['twitter'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(3),
|
||||
author: fakeAddr(3),
|
||||
content: 'Сжатие изображений — максимальное на клиенте (WebP Q=50 @1080p), плюс серверный EXIF-скраб через stdlib re-encode. GPS-координаты из EXIF больше никогда не утекают. #privacy',
|
||||
created_at: NOW - 1200,
|
||||
size: 420,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 312, likes: 78,
|
||||
has_attachment: true,
|
||||
hashtags: ['privacy'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(4),
|
||||
author: fakeAddr(4),
|
||||
content: 'Короткий пост.',
|
||||
created_at: NOW - 3600,
|
||||
size: 128,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 12, likes: 3,
|
||||
has_attachment: false,
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(5),
|
||||
author: fakeAddr(1),
|
||||
content: 'Отвечаю сам себе — фича threads пока через reply_to только, без UI thread-виджета.',
|
||||
created_at: NOW - 7200,
|
||||
size: 220,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 45, likes: 11,
|
||||
has_attachment: false,
|
||||
reply_to: fakePostID(1),
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(6),
|
||||
author: fakeAddr(5),
|
||||
content: '#golang + #badgerdb + #libp2p = DChain бэкенд. Пять package в test suite, все зелёные.',
|
||||
created_at: NOW - 10800,
|
||||
size: 180,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 201, likes: 66,
|
||||
has_attachment: false,
|
||||
hashtags: ['golang', 'badgerdb', 'libp2p'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(7),
|
||||
author: fakeAddr(6),
|
||||
content: 'Feed-mailbox хранит тела постов до 30 дней (настраиваемо через DCHAIN_FEED_TTL_DAYS). Потом BadgerDB выселяет автоматически — chain-метаданные остаются навсегда.',
|
||||
created_at: NOW - 14400,
|
||||
size: 380,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 156, likes: 48,
|
||||
has_attachment: false,
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(8),
|
||||
author: fakeAddr(7),
|
||||
content: 'Pricing: BasePostFee = 1000 µT (0.001 T) + 1 µT за каждый байт. Уходит владельцу релея, принявшего пост.',
|
||||
created_at: NOW - 21600,
|
||||
size: 250,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 78, likes: 22,
|
||||
has_attachment: false,
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(9),
|
||||
author: fakeAddr(8),
|
||||
content: 'Twitter-like, но без миллиардов долларов на инфраструктуру — каждый оператор ноды платит за свой кусок хостинга и зарабатывает на публикациях. #decentralised #messaging',
|
||||
created_at: NOW - 43200,
|
||||
size: 340,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 412, likes: 103,
|
||||
has_attachment: false,
|
||||
hashtags: ['decentralised', 'messaging'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(10),
|
||||
author: fakeAddr(9),
|
||||
content: 'Короче. Лайк = on-chain tx с fee 1000 µT. Дорого для спама, дёшево для реального лайка. Пока без батчинга, но в плане. #design',
|
||||
created_at: NOW - 64800,
|
||||
size: 200,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 92, likes: 29,
|
||||
has_attachment: false,
|
||||
hashtags: ['design'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(11),
|
||||
author: fakeAddr(2),
|
||||
content: 'Follow граф на chain: двусторонний индекс (forward + inbound), так что Followers() и Following() — оба O(M).',
|
||||
created_at: NOW - 86400 - 1000,
|
||||
size: 230,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 61, likes: 14,
|
||||
has_attachment: false,
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(12),
|
||||
author: fakeAddr(10),
|
||||
content: 'Рекомендации (For You): берём последние 48ч постов, фильтруем подписки + уже лайкнутые + свои, ранжируем по likes × 3 + views. Версия 1 — будет умнее. #recsys',
|
||||
created_at: NOW - 129600,
|
||||
size: 290,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 189, likes: 58,
|
||||
has_attachment: false,
|
||||
hashtags: ['recsys'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the dev-seed post list. Only returns actual items in dev
|
||||
* builds; release bundles return an empty array so fake posts never
|
||||
* appear in production.
|
||||
*
|
||||
* We use the runtime `globalThis.__DEV__` lookup rather than the typed
|
||||
* `__DEV__` global because some builds can have the TS typing
|
||||
* out-of-sync with the actual injected value.
|
||||
*/
|
||||
export function getDevSeedFeed(): FeedPostItem[] {
|
||||
const g = globalThis as unknown as { __DEV__?: boolean };
|
||||
if (g.__DEV__ !== true) return [];
|
||||
return SEED_POSTS;
|
||||
}
|
||||
@@ -1,5 +1,23 @@
|
||||
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));
|
||||
|
||||
147
cmd/node/main.go
147
cmd/node/main.go
@@ -29,6 +29,8 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -114,6 +116,16 @@ func main() {
|
||||
// only for intentional migrations (e.g. importing data from another chain
|
||||
// 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.")
|
||||
// ── 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")
|
||||
flag.Parse()
|
||||
|
||||
@@ -128,6 +140,10 @@ func main() {
|
||||
// so subsequent logs inherit the format.
|
||||
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
|
||||
// --api-private also gates reads. Logged up-front so the operator
|
||||
// sees what mode they're in.
|
||||
@@ -641,12 +657,24 @@ func main() {
|
||||
|
||||
// --- Feed mailbox (social-feed post bodies, v2.0.0) ---
|
||||
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 {
|
||||
log.Fatalf("[NODE] feed mailbox: %v", err)
|
||||
}
|
||||
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
|
||||
// mailbox. Clients subscribed to `inbox:<my_x25519>` (via WS) get the
|
||||
@@ -1291,23 +1319,53 @@ type keyJSON struct {
|
||||
}
|
||||
|
||||
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
|
||||
if err := json.Unmarshal(data, &kj); err == nil {
|
||||
if id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv); err == nil {
|
||||
// If the file is missing X25519 keys, backfill and re-save.
|
||||
if kj.X25519Pub == "" {
|
||||
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
|
||||
if err := json.Unmarshal(data, &kj); err != nil {
|
||||
log.Fatalf("[NODE] key file %s is not valid JSON: %v", keyFile, err)
|
||||
}
|
||||
id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv)
|
||||
if err != nil {
|
||||
log.Fatalf("[NODE] key file %s is valid JSON but identity decode failed: %v",
|
||||
keyFile, err)
|
||||
}
|
||||
// If the file is missing X25519 keys, backfill and re-save (best-effort,
|
||||
// ignore write failure on read-only mounts).
|
||||
if kj.X25519Pub == "" {
|
||||
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()
|
||||
if err != nil {
|
||||
log.Fatalf("generate identity: %v", err)
|
||||
@@ -1320,7 +1378,9 @@ func loadOrCreateIdentity(keyFile string) *identity.Identity {
|
||||
}
|
||||
data, _ := json.MarshalIndent(kj, "", " ")
|
||||
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 {
|
||||
log.Printf("[NODE] new identity saved to %s", keyFile)
|
||||
}
|
||||
@@ -1440,6 +1500,61 @@ func shortKeys(keys []string) []string {
|
||||
// "text" (default) is handler-default human-readable format, same as bare
|
||||
// log.Printf. "json" emits one JSON object per line with `time/level/msg`
|
||||
// + 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) {
|
||||
var handler slog.Handler
|
||||
switch strings.ToLower(format) {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# testnet validator.
|
||||
|
||||
# ---- build stage ----
|
||||
FROM golang:1.24-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
# 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/client /usr/local/bin/client
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# Compose: see docker-compose.yml; node points DCHAIN_MEDIA_SIDECAR_URL at it.
|
||||
#
|
||||
# Stage 1 — build a tiny static Go binary.
|
||||
FROM golang:1.22-alpine AS build
|
||||
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).
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -249,10 +250,16 @@ func feedPublish(cfg FeedConfig) http.HandlerFunc {
|
||||
}
|
||||
hashtags, err := cfg.Mailbox.Store(post, req.Ts)
|
||||
if err != nil {
|
||||
if err == relay.ErrPostTooLarge {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func newFeedHarness(t *testing.T) *feedHarness {
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("OpenFeedMailbox: %v", err)
|
||||
}
|
||||
|
||||
@@ -81,11 +81,11 @@ func newTwoNodeHarness(t *testing.T) *twoNodeHarness {
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("feed B: %v", err)
|
||||
}
|
||||
|
||||
@@ -97,24 +97,35 @@ type FeedPost struct {
|
||||
// ErrPostTooLarge is returned by Store when the post body exceeds MaxPostBodySize.
|
||||
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.
|
||||
type FeedMailbox struct {
|
||||
db *badger.DB
|
||||
ttl time.Duration
|
||||
db *badger.DB
|
||||
ttl time.Duration
|
||||
quotaBytes int64 // 0 = unlimited
|
||||
}
|
||||
|
||||
// NewFeedMailbox wraps an already-open Badger DB. TTL controls how long
|
||||
// post bodies live before auto-eviction (on-chain metadata persists
|
||||
// forever independently).
|
||||
func NewFeedMailbox(db *badger.DB, ttl time.Duration) *FeedMailbox {
|
||||
// forever independently). quotaBytes caps the on-disk footprint; 0 or
|
||||
// negative means unlimited.
|
||||
func NewFeedMailbox(db *badger.DB, ttl time.Duration, quotaBytes int64) *FeedMailbox {
|
||||
if ttl <= 0 {
|
||||
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.
|
||||
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).
|
||||
WithLogger(nil).
|
||||
WithValueLogFileSize(128 << 20).
|
||||
@@ -124,9 +135,19 @@ func OpenFeedMailbox(dbPath string, ttl time.Duration) (*FeedMailbox, error) {
|
||||
if err != nil {
|
||||
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.
|
||||
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) {
|
||||
size := estimatePostSize(post)
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -12,7 +13,7 @@ func newTestFeedMailbox(t *testing.T) *FeedMailbox {
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp: %v", err)
|
||||
}
|
||||
fm, err := OpenFeedMailbox(dir, 24*time.Hour)
|
||||
fm, err := OpenFeedMailbox(dir, 24*time.Hour, 0)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(dir)
|
||||
t.Fatalf("OpenFeedMailbox: %v", err)
|
||||
@@ -75,7 +76,7 @@ func TestFeedMailboxTooLarge(t *testing.T) {
|
||||
Author: "a",
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user