Compare commits
24 Commits
feature/fe
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b7cb1c5a9 | ||
|
|
96b347076e | ||
|
|
98ac700e0a | ||
|
|
ce11a13874 | ||
|
|
49ad09efe7 | ||
|
|
963fe062e3 | ||
|
|
3641cb113d | ||
|
|
b55486775e | ||
|
|
af7223b93c | ||
|
|
8940b97cc6 | ||
|
|
423d307125 | ||
|
|
1d9206494a | ||
|
|
217b374789 | ||
|
|
a75cbcd224 | ||
|
|
e6f3d2bcf8 | ||
|
|
e62b72b5be | ||
|
|
f7a849ddcb | ||
|
|
060ac6c2c9 | ||
|
|
516940fa8e | ||
|
|
3e9ddc1a43 | ||
|
|
6ed4e7ca50 | ||
|
|
f726587ac6 | ||
|
|
1e7f4d8da4 | ||
|
|
29e95485fa |
@@ -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).
|
||||
|
||||
## Продакшен деплой
|
||||
|
||||
Два варианта, по масштабу.
|
||||
|
||||
@@ -54,6 +54,9 @@ const (
|
||||
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
|
||||
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
|
||||
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
|
||||
// Multi-device registry (v2.2.0)
|
||||
prefixDevice = "device:" // device:<x25519_pub> → DeviceRecord JSON
|
||||
prefixDevicesByOwner = "devicesbyowner:" // devicesbyowner:<master_pub>:<x25519_pub> → "" (reverse index for O(k) listing)
|
||||
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
|
||||
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
|
||||
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
|
||||
@@ -771,6 +774,99 @@ func PayChanCloseSigPayload(channelID string, balanceA, balanceB, nonce uint64)
|
||||
return payChanCloseSigPayload(channelID, balanceA, balanceB, nonce)
|
||||
}
|
||||
|
||||
// DevicesOf returns the active (non-revoked) device records for a master
|
||||
// identity. Sender fan-out reads this to decide how many envelopes to
|
||||
// produce per outgoing message. Empty slice (not error) for identities
|
||||
// with no registry entries yet — caller should fall back to the legacy
|
||||
// single-X25519 path via IdentityInfo.
|
||||
func (c *Chain) DevicesOf(masterPub string) ([]DeviceRecord, error) {
|
||||
var out []DeviceRecord
|
||||
err := c.db.View(func(txn *badger.Txn) error {
|
||||
prefix := []byte(prefixDevicesByOwner + masterPub + ":")
|
||||
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix, PrefetchValues: false})
|
||||
defer it.Close()
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
key := it.Item().KeyCopy(nil)
|
||||
// key layout: devicesbyowner:<master>:<x25519> — take the suffix
|
||||
x25519Pub := string(key[len(prefix):])
|
||||
recItem, err := txn.Get([]byte(prefixDevice + x25519Pub))
|
||||
if err != nil {
|
||||
// Reverse index points to a missing record — stale entry,
|
||||
// skip silently (shouldn't happen unless the DB was tampered
|
||||
// with, but we don't want to fail the whole call).
|
||||
continue
|
||||
}
|
||||
var rec DeviceRecord
|
||||
if verr := recItem.Value(func(v []byte) error {
|
||||
return json.Unmarshal(v, &rec)
|
||||
}); verr != nil {
|
||||
continue
|
||||
}
|
||||
if rec.RevokedAt != 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// countActiveDevicesForOwner walks the reverse index (entries there are,
|
||||
// by construction, non-revoked — UNLINK_DEVICE deletes the index row).
|
||||
// O(k) where k = active device count, bounded by MaxDevicesPerOwner.
|
||||
func (c *Chain) countActiveDevicesForOwner(txn *badger.Txn, masterPub string) (int, error) {
|
||||
prefix := []byte(prefixDevicesByOwner + masterPub + ":")
|
||||
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix, PrefetchValues: false})
|
||||
defer it.Close()
|
||||
n := 0
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// validateDevicePubKey checks the wire-level format of a device X25519
|
||||
// public key: 64 hex characters (32 bytes). Does NOT check that the key
|
||||
// is a valid curve point — senders learn that at encrypt time, and an
|
||||
// invalid point there just yields garbage ciphertext which the device
|
||||
// can't decrypt (self-punishing).
|
||||
func validateDevicePubKey(hexPub string) error {
|
||||
if len(hexPub) != 64 {
|
||||
return fmt.Errorf("x25519_pub_key must be 64 hex chars (got %d)", len(hexPub))
|
||||
}
|
||||
// Enforce strict lowercase-hex to keep keys canonical across clients —
|
||||
// two clients hashing the same bytes with different letter cases would
|
||||
// produce different registry lookups, which breaks sender fan-out.
|
||||
for _, r := range hexPub {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r >= 'a' && r <= 'f':
|
||||
default:
|
||||
return fmt.Errorf("x25519_pub_key must be lowercase hex")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDeviceName enforces the UX constraint that device labels stay
|
||||
// short and printable — this field gets displayed in Settings → Devices
|
||||
// and has no business carrying newlines, control chars, or 2KB blobs.
|
||||
func validateDeviceName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("device_name is required")
|
||||
}
|
||||
if len(name) > MaxDeviceNameLen {
|
||||
return fmt.Errorf("device_name %d bytes > max %d", len(name), MaxDeviceNameLen)
|
||||
}
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return fmt.Errorf("device_name contains a control character")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reputation returns the reputation stats for a public key.
|
||||
func (c *Chain) Reputation(pubKeyHex string) (RepStats, error) {
|
||||
var r RepStats
|
||||
@@ -1261,6 +1357,127 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
case EventLinkDevice:
|
||||
// Master Ed25519 (= tx.From) publishes a per-device X25519 pub.
|
||||
// Validation:
|
||||
// 1. Payload well-formed, X25519 pub is hex(32 bytes).
|
||||
// 2. Device name is short + printable.
|
||||
// 3. X25519 pub isn't already registered to a DIFFERENT owner.
|
||||
// 4. Same owner re-linking the same pub is a no-op refresh
|
||||
// (updates device_name / added_at — useful for rename).
|
||||
// 5. Owner has < MaxDevicesPerOwner active (non-revoked) devices.
|
||||
//
|
||||
// Fee: standard tx.Fee is debited from master's balance — cheap
|
||||
// anti-spam. Block validation already enforced `tx.Fee >= MinFee`.
|
||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||
return 0, fmt.Errorf("LINK_DEVICE debit: %w", err)
|
||||
}
|
||||
var p LinkDevicePayload
|
||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||
return 0, fmt.Errorf("%w: LINK_DEVICE bad payload: %v", ErrTxFailed, err)
|
||||
}
|
||||
if err := validateDevicePubKey(p.X25519PubKey); err != nil {
|
||||
return 0, fmt.Errorf("%w: LINK_DEVICE: %v", ErrTxFailed, err)
|
||||
}
|
||||
if err := validateDeviceName(p.DeviceName); err != nil {
|
||||
return 0, fmt.Errorf("%w: LINK_DEVICE: %v", ErrTxFailed, err)
|
||||
}
|
||||
devKey := []byte(prefixDevice + p.X25519PubKey)
|
||||
if item, err := txn.Get(devKey); err == nil {
|
||||
var existing DeviceRecord
|
||||
if verr := item.Value(func(v []byte) error {
|
||||
return json.Unmarshal(v, &existing)
|
||||
}); verr == nil {
|
||||
if existing.Owner != tx.From {
|
||||
return 0, fmt.Errorf("%w: LINK_DEVICE: x25519 pub already linked to a different owner",
|
||||
ErrTxFailed)
|
||||
}
|
||||
// Same owner — rename/refresh path. Keep AddedAt.
|
||||
existing.DeviceName = p.DeviceName
|
||||
existing.RevokedAt = 0 // re-link cancels a previous revoke
|
||||
refreshed, _ := json.Marshal(existing)
|
||||
if err := txn.Set(devKey, refreshed); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Restore reverse index too — UNLINK_DEVICE deletes it, so
|
||||
// a re-link after revoke must recreate it or DevicesOf
|
||||
// will skip the record.
|
||||
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
|
||||
if err := txn.Set(idxKey, []byte{}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
// Count owner's active devices.
|
||||
active, err := c.countActiveDevicesForOwner(txn, tx.From)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("LINK_DEVICE count devices: %w", err)
|
||||
}
|
||||
if active >= MaxDevicesPerOwner {
|
||||
return 0, fmt.Errorf("%w: LINK_DEVICE: owner already has %d devices (max %d)",
|
||||
ErrTxFailed, active, MaxDevicesPerOwner)
|
||||
}
|
||||
rec := DeviceRecord{
|
||||
Owner: tx.From,
|
||||
X25519PubKey: p.X25519PubKey,
|
||||
DeviceName: p.DeviceName,
|
||||
AddedAt: tx.Timestamp.Unix(),
|
||||
}
|
||||
val, _ := json.Marshal(rec)
|
||||
if err := txn.Set(devKey, val); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
|
||||
if err := txn.Set(idxKey, []byte{}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
case EventUnlinkDevice:
|
||||
// Revoke (soft-delete) a device record. Mark RevokedAt and keep
|
||||
// the row — clients wake up, notice their own pub is revoked, and
|
||||
// wipe local state. A permanent delete would let a sender that
|
||||
// missed the revoke keep encrypting for the old pub silently.
|
||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||
return 0, fmt.Errorf("UNLINK_DEVICE debit: %w", err)
|
||||
}
|
||||
var p UnlinkDevicePayload
|
||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||
return 0, fmt.Errorf("%w: UNLINK_DEVICE bad payload: %v", ErrTxFailed, err)
|
||||
}
|
||||
if err := validateDevicePubKey(p.X25519PubKey); err != nil {
|
||||
return 0, fmt.Errorf("%w: UNLINK_DEVICE: %v", ErrTxFailed, err)
|
||||
}
|
||||
devKey := []byte(prefixDevice + p.X25519PubKey)
|
||||
item, err := txn.Get(devKey)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: UNLINK_DEVICE: device not found", ErrTxFailed)
|
||||
}
|
||||
var existing DeviceRecord
|
||||
if verr := item.Value(func(v []byte) error {
|
||||
return json.Unmarshal(v, &existing)
|
||||
}); verr != nil {
|
||||
return 0, fmt.Errorf("UNLINK_DEVICE decode: %w", verr)
|
||||
}
|
||||
if existing.Owner != tx.From {
|
||||
return 0, fmt.Errorf("%w: UNLINK_DEVICE: signer is not the owner of this device",
|
||||
ErrTxFailed)
|
||||
}
|
||||
if existing.RevokedAt != 0 {
|
||||
// Already revoked — idempotent success, don't error.
|
||||
break
|
||||
}
|
||||
existing.RevokedAt = tx.Timestamp.Unix()
|
||||
updated, _ := json.Marshal(existing)
|
||||
if err := txn.Set(devKey, updated); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Drop reverse index so countActiveDevicesForOwner is O(k_active).
|
||||
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
|
||||
if err := txn.Delete(idxKey); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
case EventContactRequest:
|
||||
var p ContactRequestPayload
|
||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||
|
||||
413
blockchain/devices_test.go
Normal file
413
blockchain/devices_test.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package blockchain_test
|
||||
|
||||
// Multi-device registry tests (v2.2.0). Cover happy-path link + list,
|
||||
// unlink idempotency, ownership validation, max-devices guard, and
|
||||
// backward-compat sender fall-back semantics (empty list for unknown
|
||||
// master). HTTP-layer tests live in the node package; these stay at
|
||||
// chain level — applyTx + state-shape only.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-blockchain/blockchain"
|
||||
"go-blockchain/identity"
|
||||
)
|
||||
|
||||
// uniqueTxID sidesteps the `chain_test.go:txID` race where two helper
|
||||
// calls in the same nanosecond produce identical tx IDs → chain.AddBlock
|
||||
// dedupes the second as "already committed". We add a strictly-monotonic
|
||||
// counter so every tx built in the same test gets a fresh ID even on
|
||||
// fast machines.
|
||||
var devicesTxIDCounter atomic.Uint64
|
||||
|
||||
func uniqueTxID() string {
|
||||
n := devicesTxIDCounter.Add(1)
|
||||
h := make([]byte, 16)
|
||||
_, _ = rand.Read(h)
|
||||
return hex.EncodeToString(h) + ":" + time.Now().Format("150405.000000000") + ":" + itoa(n)
|
||||
}
|
||||
|
||||
func itoa(n uint64) string {
|
||||
// tiny local conversion to avoid importing strconv just for tests.
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// linkDeviceRawTx / unlinkDeviceRawTx build txs with guaranteed-unique
|
||||
// IDs so we can stuff many of them into a single block during tests.
|
||||
func linkDeviceRawTx(master *identity.Identity, x25519Pub, name string) *blockchain.Transaction {
|
||||
payload := mustJSON(blockchain.LinkDevicePayload{
|
||||
X25519PubKey: x25519Pub,
|
||||
DeviceName: name,
|
||||
})
|
||||
return &blockchain.Transaction{
|
||||
ID: uniqueTxID(),
|
||||
Type: blockchain.EventLinkDevice,
|
||||
From: master.PubKeyHex(),
|
||||
Fee: blockchain.MinFee,
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func unlinkDeviceRawTx(master *identity.Identity, x25519Pub string) *blockchain.Transaction {
|
||||
payload := mustJSON(blockchain.UnlinkDevicePayload{X25519PubKey: x25519Pub})
|
||||
return &blockchain.Transaction{
|
||||
ID: uniqueTxID(),
|
||||
Type: blockchain.EventUnlinkDevice,
|
||||
From: master.PubKeyHex(),
|
||||
Fee: blockchain.MinFee,
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// randX25519Pub returns a throw-away 32-byte hex string suitable for the
|
||||
// x25519_pub_key field. We don't need the matching private key — the
|
||||
// chain only stores the pub and doesn't verify it's a valid curve point.
|
||||
func randX25519Pub(t *testing.T) string {
|
||||
t.Helper()
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// fundIdentity credits enough balance to cover `nFees` worth of MinFee
|
||||
// payments. Every test needs this because LINK_DEVICE and UNLINK_DEVICE
|
||||
// debit tx.Fee from the master's balance — without funding the first tx
|
||||
// errors with "insufficient funds" and no state changes.
|
||||
func fundIdentity(t *testing.T, c *blockchain.Chain, val, recipient *identity.Identity, nFees int) {
|
||||
t.Helper()
|
||||
amount := uint64(nFees+2) * blockchain.MinFee
|
||||
tx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), recipient.PubKeyHex(),
|
||||
amount, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
|
||||
}
|
||||
|
||||
// TestLinkDeviceHappyPath: after a single LINK_DEVICE, DevicesOf returns
|
||||
// exactly one record with the right owner/name/pub.
|
||||
func TestLinkDeviceHappyPath(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 1)
|
||||
dev1 := randX25519Pub(t)
|
||||
tx := linkDeviceRawTx(alice, dev1, "Alice's iPhone")
|
||||
b := buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx})
|
||||
mustAddBlock(t, c, b)
|
||||
|
||||
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||
if err != nil {
|
||||
t.Fatalf("DevicesOf: %v", err)
|
||||
}
|
||||
if len(devs) != 1 {
|
||||
t.Fatalf("expected 1 device, got %d", len(devs))
|
||||
}
|
||||
got := devs[0]
|
||||
if got.Owner != alice.PubKeyHex() {
|
||||
t.Errorf("owner: got %s, want %s", got.Owner, alice.PubKeyHex())
|
||||
}
|
||||
if got.X25519PubKey != dev1 {
|
||||
t.Errorf("x25519: got %s, want %s", got.X25519PubKey, dev1)
|
||||
}
|
||||
if got.DeviceName != "Alice's iPhone" {
|
||||
t.Errorf("name: got %q, want %q", got.DeviceName, "Alice's iPhone")
|
||||
}
|
||||
if got.RevokedAt != 0 {
|
||||
t.Errorf("freshly linked device should have RevokedAt=0, got %d", got.RevokedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkDeviceMultiple: multiple devices for the same owner all show up
|
||||
// in DevicesOf, and the count matches.
|
||||
func TestLinkDeviceMultiple(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 3)
|
||||
pubs := []string{randX25519Pub(t), randX25519Pub(t), randX25519Pub(t)}
|
||||
var txs []*blockchain.Transaction
|
||||
for i, p := range pubs {
|
||||
txs = append(txs, linkDeviceRawTx(alice, p, "device-"+string(rune('A'+i))))
|
||||
}
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, txs))
|
||||
|
||||
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||
if err != nil {
|
||||
t.Fatalf("DevicesOf: %v", err)
|
||||
}
|
||||
if len(devs) != 3 {
|
||||
t.Fatalf("expected 3 devices, got %d", len(devs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkDeviceRejectsForeignOwner: if Alice already linked a X25519 pub
|
||||
// and Bob tries to link the SAME pub to himself, the tx is rejected.
|
||||
// This protects against a malicious actor hijacking someone else's
|
||||
// device-pub and trying to claim it.
|
||||
func TestLinkDeviceRejectsForeignOwner(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
bob := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 1)
|
||||
fundIdentity(t, c, val, bob, 1)
|
||||
dev := randX25519Pub(t)
|
||||
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")}))
|
||||
|
||||
// Bob tries to claim the same pub. The chain silently drops invalid
|
||||
// txs (they're non-fatal for block commit), so we verify the *state*:
|
||||
// Alice still owns the pub, Bob has zero devices.
|
||||
bobTx := linkDeviceRawTx(bob, dev, "bob phone")
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{bobTx}))
|
||||
|
||||
aliceDevs, _ := c.DevicesOf(alice.PubKeyHex())
|
||||
if len(aliceDevs) != 1 || aliceDevs[0].DeviceName != "alice phone" {
|
||||
t.Fatalf("Alice should still own the device, got %+v", aliceDevs)
|
||||
}
|
||||
bobDevs, _ := c.DevicesOf(bob.PubKeyHex())
|
||||
if len(bobDevs) != 0 {
|
||||
t.Fatalf("Bob's hijack should have been rejected, got %+v", bobDevs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkDeviceSameOwnerRefresh: the same owner re-linking the same
|
||||
// x25519_pub is a rename/refresh (not an error), and the revoked flag
|
||||
// gets cleared if it was set.
|
||||
func TestLinkDeviceSameOwnerRefresh(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 3)
|
||||
dev := randX25519Pub(t)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "old name")}))
|
||||
|
||||
// Unlink then re-link — should restore the record.
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "new name")}))
|
||||
|
||||
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||
if err != nil {
|
||||
t.Fatalf("DevicesOf: %v", err)
|
||||
}
|
||||
if len(devs) != 1 {
|
||||
t.Fatalf("expected 1 active device after re-link, got %d", len(devs))
|
||||
}
|
||||
if devs[0].DeviceName != "new name" {
|
||||
t.Errorf("expected refreshed name 'new name', got %q", devs[0].DeviceName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnlinkDeviceRemovesFromActive: after UNLINK_DEVICE, DevicesOf no
|
||||
// longer returns that record — senders stop fanning out to it.
|
||||
func TestUnlinkDeviceRemovesFromActive(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 3)
|
||||
keep := randX25519Pub(t)
|
||||
revoke := randX25519Pub(t)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{
|
||||
linkDeviceRawTx(alice, keep, "kept"),
|
||||
linkDeviceRawTx(alice, revoke, "revoked"),
|
||||
}))
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, revoke)}))
|
||||
|
||||
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||
if err != nil {
|
||||
t.Fatalf("DevicesOf: %v", err)
|
||||
}
|
||||
if len(devs) != 1 || devs[0].X25519PubKey != keep {
|
||||
t.Fatalf("expected only the kept device, got %+v", devs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnlinkDeviceRejectsForeignSigner: Bob can't unlink Alice's device
|
||||
// even if he knows the X25519 pub — only the owner's signature (= tx.From)
|
||||
// authorises revoke.
|
||||
func TestUnlinkDeviceRejectsForeignSigner(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
bob := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 1)
|
||||
fundIdentity(t, c, val, bob, 1)
|
||||
dev := randX25519Pub(t)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")}))
|
||||
|
||||
// Bob tries to unlink Alice's device — tx is silently dropped, Alice
|
||||
// still has it in her active list.
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(bob, dev)}))
|
||||
|
||||
aliceDevs, _ := c.DevicesOf(alice.PubKeyHex())
|
||||
if len(aliceDevs) != 1 || aliceDevs[0].X25519PubKey != dev {
|
||||
t.Fatalf("Alice's device should still be active, got %+v", aliceDevs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnlinkDeviceIdempotent: unlinking a device that's already revoked
|
||||
// does NOT error — this is needed for recovery scenarios where a client
|
||||
// retries the tx after a spotty connection.
|
||||
func TestUnlinkDeviceIdempotent(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 3)
|
||||
dev := randX25519Pub(t)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "phone")}))
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
|
||||
// Second unlink of the same device must apply cleanly.
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
|
||||
}
|
||||
|
||||
// TestLinkDeviceRejectsMalformedPub: payload.X25519PubKey must be 64-char
|
||||
// lowercase hex. Garbage / short / uppercase values are rejected at
|
||||
// applyTx time before any state change.
|
||||
func TestLinkDeviceRejectsMalformedPub(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 5)
|
||||
|
||||
bad := []string{
|
||||
"", // empty
|
||||
"abc", // too short
|
||||
strings.Repeat("x", 64), // non-hex
|
||||
strings.Repeat("A", 64), // uppercase
|
||||
}
|
||||
for _, p := range bad {
|
||||
tx := linkDeviceRawTx(alice, p, "dev")
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
|
||||
}
|
||||
|
||||
devs, _ := c.DevicesOf(alice.PubKeyHex())
|
||||
if len(devs) != 0 {
|
||||
t.Fatalf("expected 0 devices after %d malformed link attempts, got %d",
|
||||
len(bad), len(devs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkDeviceRejectsBadDeviceName: empty, too long, or control-char
|
||||
// names are refused — this field is rendered verbatim in clients.
|
||||
func TestLinkDeviceRejectsBadDeviceName(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 5)
|
||||
cases := map[string]string{
|
||||
"empty": "",
|
||||
"too long": strings.Repeat("a", blockchain.MaxDeviceNameLen+1),
|
||||
"control char": "device\x01name",
|
||||
}
|
||||
for name, devName := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
before, _ := c.DevicesOf(alice.PubKeyHex())
|
||||
tx := linkDeviceRawTx(alice, randX25519Pub(t), devName)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
|
||||
after, _ := c.DevicesOf(alice.PubKeyHex())
|
||||
if len(after) != len(before) {
|
||||
t.Fatalf("bad %s name should not have been linked (before=%d, after=%d)",
|
||||
name, len(before), len(after))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkDeviceMaxDevices: after MaxDevicesPerOwner active devices, any
|
||||
// further LINK_DEVICE is rejected. Revoking one frees a slot.
|
||||
func TestLinkDeviceMaxDevices(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
// Enough for cap + one overflow attempt + one unlink + one replacement.
|
||||
fundIdentity(t, c, val, alice, blockchain.MaxDevicesPerOwner+4)
|
||||
|
||||
// Fill to the cap — each in its own block so timestamps differ.
|
||||
pubs := make([]string, blockchain.MaxDevicesPerOwner)
|
||||
for i := 0; i < blockchain.MaxDevicesPerOwner; i++ {
|
||||
pubs[i] = randX25519Pub(t)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, pubs[i], "d")}))
|
||||
}
|
||||
// One more — the tx is dropped, cap stays exact.
|
||||
over := linkDeviceRawTx(alice, randX25519Pub(t), "overflow")
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{over}))
|
||||
if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner {
|
||||
t.Fatalf("overflow link should have been rejected, count=%d", len(devs))
|
||||
}
|
||||
|
||||
// Revoke one, then a fresh link succeeds.
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, pubs[0])}))
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, randX25519Pub(t), "replacement")}))
|
||||
if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner {
|
||||
t.Fatalf("after unlink+relink, should be %d active, got %d",
|
||||
blockchain.MaxDevicesPerOwner, len(devs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevicesOfEmptyForUnknown: unknown master pubs return an empty list
|
||||
// (not an error) — this preserves the sender's ability to fall back to
|
||||
// IdentityInfo.X25519Pub for legacy identities.
|
||||
func TestDevicesOfEmptyForUnknown(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||
if err != nil {
|
||||
t.Fatalf("DevicesOf: %v", err)
|
||||
}
|
||||
if len(devs) != 0 {
|
||||
t.Fatalf("expected 0 devices for unknown master, got %d", len(devs))
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,14 @@ const (
|
||||
EventUnfollow EventType = "UNFOLLOW" // unfollow an author
|
||||
EventLikePost EventType = "LIKE_POST" // like a post
|
||||
EventUnlikePost EventType = "UNLIKE_POST" // remove a previous like
|
||||
|
||||
// Multi-device support (v2.2.0): each physical device of an identity gets
|
||||
// its own X25519 keypair for the messenger mailbox. LINK/UNLINK_DEVICE
|
||||
// publish/revoke those pubs on-chain so senders can fan-out envelopes
|
||||
// across the recipient's active devices. Signed by the identity's
|
||||
// master Ed25519 key.
|
||||
EventLinkDevice EventType = "LINK_DEVICE"
|
||||
EventUnlinkDevice EventType = "UNLINK_DEVICE"
|
||||
)
|
||||
|
||||
// Token amounts are stored in micro-tokens (µT).
|
||||
@@ -555,6 +563,70 @@ type IdentityInfo struct {
|
||||
X25519Pub string `json:"x25519_pub"` // hex Curve25519 key; empty if not published
|
||||
Nickname string `json:"nickname"`
|
||||
Registered bool `json:"registered"` // true if REGISTER_KEY tx was committed
|
||||
// DeviceCount is the number of currently-linked (non-revoked) devices in
|
||||
// this identity's multi-device registry. `0` for legacy identities that
|
||||
// only published a single X25519 via REGISTER_KEY — senders fall back
|
||||
// to IdentityInfo.X25519Pub in that case.
|
||||
DeviceCount int `json:"device_count"`
|
||||
}
|
||||
|
||||
// ── Multi-device registry (v2.2.0) ───────────────────────────────────────
|
||||
|
||||
// MaxDevicesPerOwner caps how many devices a single identity can have
|
||||
// linked concurrently. A sender must encrypt + relay one envelope per
|
||||
// device, so the multiplier bounds the per-message cost. Ten covers
|
||||
// typical users (phone + tablet + laptop + desktop + work phone + …)
|
||||
// without letting an abuse case blow up mailbox traffic. Revoked devices
|
||||
// don't count.
|
||||
const MaxDevicesPerOwner = 10
|
||||
|
||||
// MaxDeviceNameLen caps the length of a human-friendly device label
|
||||
// ("Alice's iPhone 14", "Work MacBook Pro"). Longer names get rejected
|
||||
// at validation. Kept short to discourage using this field as a free-form
|
||||
// comment/profile channel.
|
||||
const MaxDeviceNameLen = 64
|
||||
|
||||
// LinkDevicePayload is embedded in EventLinkDevice transactions. Master
|
||||
// Ed25519 (= tx.From) asserts that the given X25519 pub belongs to one
|
||||
// of its physical devices, publishing it so senders can fan out envelopes.
|
||||
type LinkDevicePayload struct {
|
||||
// X25519PubKey is the hex-encoded Curve25519 pub the device generated
|
||||
// locally. Must be unique in the device registry — senders index
|
||||
// envelopes by this key in the relay mailbox.
|
||||
X25519PubKey string `json:"x25519_pub_key"`
|
||||
// DeviceName is a short human label shown in Settings → Devices.
|
||||
// Purely informational; not used for routing.
|
||||
DeviceName string `json:"device_name"`
|
||||
}
|
||||
|
||||
// UnlinkDevicePayload is embedded in EventUnlinkDevice transactions. Signed
|
||||
// by master Ed25519; marks the referenced device as revoked so senders
|
||||
// stop shipping envelopes to its X25519 pub. The revoked device itself,
|
||||
// once it sees its pub in the revoked list, is expected to wipe its
|
||||
// local state (master Ed25519 priv + chat cache).
|
||||
type UnlinkDevicePayload struct {
|
||||
X25519PubKey string `json:"x25519_pub_key"`
|
||||
}
|
||||
|
||||
// DeviceRecord is the on-chain persisted state for one device link.
|
||||
// Stored at key `prefixDevice + x25519_pub`; the reverse index
|
||||
// `prefixDevicesByOwner + master_pub` keeps a slice of x25519 pubs per
|
||||
// owner for efficient listing.
|
||||
type DeviceRecord struct {
|
||||
Owner string `json:"owner"` // master Ed25519 pub hex
|
||||
X25519PubKey string `json:"x25519_pub_key"` // device X25519 pub hex
|
||||
DeviceName string `json:"device_name"`
|
||||
AddedAt int64 `json:"added_at"` // unix seconds, from tx timestamp
|
||||
RevokedAt int64 `json:"revoked_at,omitempty"` // 0 = active; >0 = revoked
|
||||
}
|
||||
|
||||
// DeviceInfo is the public view of a DeviceRecord served by
|
||||
// GET /api/devices/{master_pub}. Revoked records are not included
|
||||
// in the response (intentionally — sender only needs the active set).
|
||||
type DeviceInfo struct {
|
||||
X25519PubKey string `json:"x25519_pub_key"`
|
||||
DeviceName string `json:"device_name"`
|
||||
AddedAt int64 `json:"added_at"`
|
||||
}
|
||||
|
||||
// ConsensusMessage types used by the PBFT engine over the P2P layer.
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
"infoPlist": {
|
||||
"NSMicrophoneUsageDescription": "Allow DChain to record voice messages and video.",
|
||||
"NSCameraUsageDescription": "Allow DChain to record video messages and scan QR codes.",
|
||||
"NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library."
|
||||
"NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library.",
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "com.dchain.messenger",
|
||||
"softwareKeyboardLayoutMode": "pan",
|
||||
"usesCleartextTraffic": true,
|
||||
"permissions": [
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.CAMERA",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* один раз; переходы между tab'ами их не перезапускают.
|
||||
*/
|
||||
import React, { useEffect } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { View, Platform } from 'react-native';
|
||||
import { router, usePathname } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
@@ -23,9 +23,15 @@ 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,
|
||||
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
|
||||
} from '@/lib/storage';
|
||||
import {
|
||||
fetchDevices, buildLinkDeviceTx, submitTx,
|
||||
} from '@/lib/api';
|
||||
|
||||
export default function AppLayout() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
@@ -37,24 +43,115 @@ 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 });
|
||||
else ws.setAuthCreds(null);
|
||||
}, [keyFile]);
|
||||
|
||||
// Multi-device registry bootstrap + revoke-detection (v2.2.0).
|
||||
//
|
||||
// Three branches, by (chain list × local "was registered" flag):
|
||||
//
|
||||
// 1. Our pub is in the chain's active list →
|
||||
// mark us registered locally (idempotent), done.
|
||||
//
|
||||
// 2. Our pub is NOT in the active list, AND we've registered before →
|
||||
// another device issued UNLINK_DEVICE against us. Wipe ALL local
|
||||
// state (master priv, contacts, chats, marker) and redirect to
|
||||
// the auth screen. This is the security-critical path: without
|
||||
// the wipe, a stolen phone after revoke would still decrypt
|
||||
// historical messages.
|
||||
//
|
||||
// 3. Our pub is NOT in the active list, AND we've NEVER registered →
|
||||
// first boot on this chain; submit LINK_DEVICE so senders can
|
||||
// target us. Failures (fee, offline) are swallowed; next launch
|
||||
// retries.
|
||||
useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
let chainList;
|
||||
try {
|
||||
chainList = await fetchDevices(keyFile.pub_key);
|
||||
} catch {
|
||||
// Network unavailable — leave state unchanged; we'll resync on
|
||||
// the next launch. Do NOT wipe on network error.
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub);
|
||||
const previouslyRegistered = await isDeviceRegistered();
|
||||
if (cancelled) return;
|
||||
|
||||
if (inActive) {
|
||||
// Branch #1 — ensure the local marker is set.
|
||||
if (!previouslyRegistered) await markDeviceRegistered();
|
||||
return;
|
||||
}
|
||||
|
||||
if (previouslyRegistered) {
|
||||
// Branch #2 — REVOKED. Self-wipe.
|
||||
await wipeAllLocalState();
|
||||
useStore.getState().setKeyFile(null);
|
||||
// The redirect-on-null-keyFile effect below will push the user
|
||||
// back to the welcome screen automatically.
|
||||
return;
|
||||
}
|
||||
|
||||
// Branch #3 — first-boot link. Best-effort.
|
||||
try {
|
||||
const deviceName = Platform.select({
|
||||
ios: 'iPhone',
|
||||
android: 'Android phone',
|
||||
default: 'Device',
|
||||
}) ?? 'Device';
|
||||
const tx = buildLinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: keyFile.x25519_pub,
|
||||
deviceName,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
await markDeviceRegistered();
|
||||
} catch {
|
||||
/* next launch retries */
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [keyFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyFile === null) {
|
||||
const t = setTimeout(() => {
|
||||
|
||||
@@ -23,10 +23,10 @@ import * as Clipboard from 'expo-clipboard';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { useMessages } from '@/hooks/useMessages';
|
||||
import { encryptMessage } from '@/lib/crypto';
|
||||
import { sendEnvelope } from '@/lib/api';
|
||||
import { sendEnvelope, resolveRecipientKeys } 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);
|
||||
@@ -89,7 +107,7 @@ export default function ChatScreen() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const selectionMode = selectedIds.size > 0;
|
||||
|
||||
useMessages(contact?.x25519Pub ?? '');
|
||||
useMessages(contact?.x25519Pub ?? '', contact?.address);
|
||||
|
||||
// ── Typing indicator от peer'а ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -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,7 +155,9 @@ export default function ChatScreen() {
|
||||
});
|
||||
}, [contactAddress, setMsgs]);
|
||||
|
||||
const name = contact?.username
|
||||
const name = isSavedMessages
|
||||
? 'Saved Messages'
|
||||
: contact?.username
|
||||
? `@${contact.username}`
|
||||
: contact?.alias ?? shortAddr(contactAddress ?? '');
|
||||
|
||||
@@ -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,16 +208,38 @@ 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) {
|
||||
// Multi-device fan-out (v2.2.0): resolve the recipient's active
|
||||
// device X25519 pubs via /api/devices. Legacy identities (no
|
||||
// devices registered) fall back to their published identity
|
||||
// x25519 pub, preserving the pre-v2.2.0 single-device path.
|
||||
// `contact.x25519Pub` stays the floor — if both network calls
|
||||
// fail we still attempt delivery to the cached pub so a flaky
|
||||
// connection doesn't block outgoing messages.
|
||||
let recipientPubs = await resolveRecipientKeys(contact.address);
|
||||
if (recipientPubs.length === 0 && contact.x25519Pub) {
|
||||
recipientPubs = [contact.x25519Pub];
|
||||
}
|
||||
if (recipientPubs.length === 0) {
|
||||
throw new Error('recipient has no encryption key published');
|
||||
}
|
||||
// One sealed envelope per recipient device. Parallel — slow
|
||||
// relays don't block each other; any individual failure
|
||||
// rejects the whole send (user retries).
|
||||
await Promise.all(recipientPubs.map(async (rpub) => {
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
|
||||
actualText.trim(), keyFile.x25519_priv, rpub,
|
||||
);
|
||||
await sendEnvelope({
|
||||
senderPub: keyFile.x25519_pub,
|
||||
recipientPub: contact.x25519Pub,
|
||||
recipientPub: rpub,
|
||||
senderEd25519Pub: keyFile.pub_key,
|
||||
nonce, ciphertext,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
const msg: Message = {
|
||||
@@ -224,7 +266,7 @@ export default function ChatScreen() {
|
||||
setSending(false);
|
||||
}
|
||||
}, [
|
||||
text, keyFile, contact, composeMode, chatMsgs,
|
||||
text, keyFile, contact, composeMode, chatMsgs, isSavedMessages,
|
||||
setMsgs, cancelCompose, appendMsg, pendingAttach,
|
||||
]);
|
||||
|
||||
@@ -404,14 +446,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 +471,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,6 +489,32 @@ export default function ChatScreen() {
|
||||
с "scroll position at bottom" без ручного scrollToEnd, и новые
|
||||
сообщения (добавляемые в начало reversed-массива) появляются
|
||||
внизу естественно. Никаких jerk'ов при открытии. */}
|
||||
{rows.length === 0 ? (
|
||||
// Empty state is rendered as a plain View instead of
|
||||
// ListEmptyComponent on an inverted FlatList — the previous
|
||||
// `transform: [{ scaleY: -1 }]` un-flip trick was rendering
|
||||
// text mirrored on some Android builds (RTL-aware layout),
|
||||
// giving us the "say hi…" backwards bug.
|
||||
<View style={{
|
||||
flex: 1, alignItems: 'center', justifyContent: 'center',
|
||||
paddingHorizontal: 32, gap: 10,
|
||||
}}>
|
||||
<Avatar
|
||||
name={name}
|
||||
address={contactAddress}
|
||||
size={72}
|
||||
saved={isSavedMessages}
|
||||
/>
|
||||
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
|
||||
{isSavedMessages ? 'Notes to self' : `Say hi to ${name}`}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||
{isSavedMessages
|
||||
? 'Anything you send here stays on your device — use it as a scratchpad for links, drafts, or files.'
|
||||
: 'Your messages are end-to-end encrypted.'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
@@ -462,22 +530,8 @@ export default function ChatScreen() {
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
490
client-app/app/(app)/devices.tsx
Normal file
490
client-app/app/(app)/devices.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Devices screen — Settings → Linked devices.
|
||||
*
|
||||
* Multi-device registry (v2.2.0). Lists every X25519 device published
|
||||
* on-chain under this identity's master Ed25519 key. Operators can:
|
||||
* - see added-at timestamps
|
||||
* - rename this device (local alias for now; rename via LINK_DEVICE
|
||||
* with same pub + new name is a v2.3 polish)
|
||||
* - revoke a remote device via UNLINK_DEVICE (requires fee)
|
||||
* - pair a new device (Phase 3 — separate modal, stub for now)
|
||||
*
|
||||
* This device is NEVER listed with an Unlink button — revoking yourself
|
||||
* is a footgun (you'd wipe your own state on next launch). Export/import
|
||||
* your key first, then revoke from the new device.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, Pressable, ActivityIndicator, Alert, RefreshControl,
|
||||
TextInput, KeyboardAvoidingView, Platform, Modal,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
fetchDevices, buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx,
|
||||
sendEnvelope, humanizeTxError,
|
||||
type DeviceInfo,
|
||||
} from '@/lib/api';
|
||||
import { encryptMessage } from '@/lib/crypto';
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { safeBack } from '@/lib/utils';
|
||||
|
||||
function shortPub(p: string, n = 8): string {
|
||||
if (!p) return '—';
|
||||
return p.length <= n * 2 + 1 ? p : `${p.slice(0, n)}…${p.slice(-n)}`;
|
||||
}
|
||||
|
||||
function formatDate(unixSec: number): string {
|
||||
return new Date(unixSec * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export default function DevicesScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [unlinking, setUnlinking] = useState<string | null>(null); // pub being revoked
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (!keyFile) return;
|
||||
if (isRefresh) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
try {
|
||||
const list = await fetchDevices(keyFile.pub_key);
|
||||
setDevices(list);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [keyFile]);
|
||||
|
||||
useEffect(() => { load(false); }, [load]);
|
||||
|
||||
const onUnlink = useCallback((dev: DeviceInfo) => {
|
||||
if (!keyFile) return;
|
||||
Alert.alert(
|
||||
'Unlink device?',
|
||||
`"${dev.device_name}" will stop receiving messages sent to you. ` +
|
||||
`This costs a small network fee. The revoked device wipes its ` +
|
||||
`local state the next time it checks in.`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Unlink',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setUnlinking(dev.x25519_pub_key);
|
||||
try {
|
||||
const tx = buildUnlinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: dev.x25519_pub_key,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
// Optimistic — drop from local list immediately; next load
|
||||
// reconciles. Chain tx takes ~1 block to commit.
|
||||
setDevices(prev => prev.filter(d => d.x25519_pub_key !== dev.x25519_pub_key));
|
||||
} catch (e: any) {
|
||||
Alert.alert('Unlink failed', humanizeTxError(e));
|
||||
} finally {
|
||||
setUnlinking(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [keyFile]);
|
||||
|
||||
const meX25519 = keyFile?.x25519_pub ?? '';
|
||||
|
||||
// Pairing modal state — filled by the operator reading values off the
|
||||
// new device's /auth/pair screen.
|
||||
const [pairOpen, setPairOpen] = useState(false);
|
||||
const [pairCode, setPairCode] = useState('');
|
||||
const [pairKey, setPairKey] = useState('');
|
||||
const [pairName, setPairName] = useState('');
|
||||
const [pairBusy, setPairBusy] = useState(false);
|
||||
|
||||
const submitPair = useCallback(async () => {
|
||||
if (!keyFile) return;
|
||||
const code = pairCode.replace(/\s+/g, '').trim();
|
||||
const key = pairKey.replace(/\s+/g, '').trim().toLowerCase();
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
Alert.alert('Invalid code', 'The pairing code is 6 digits.');
|
||||
return;
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(key)) {
|
||||
Alert.alert('Invalid key', 'The device key must be 64 hex characters.');
|
||||
return;
|
||||
}
|
||||
const name = pairName.trim() || 'New device';
|
||||
setPairBusy(true);
|
||||
try {
|
||||
// 1. LINK_DEVICE on-chain so senders learn the new pub.
|
||||
const tx = buildLinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: key,
|
||||
deviceName: name,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
// 2. Ship the handshake payload to the new device. Encrypted for
|
||||
// its x25519 pub so only it can read — master priv in plaintext
|
||||
// would be catastrophic, E2E is the whole point.
|
||||
const payload = JSON.stringify({
|
||||
v: 1,
|
||||
type: 'pair-handshake',
|
||||
code,
|
||||
master_pub: keyFile.pub_key,
|
||||
master_priv: keyFile.priv_key,
|
||||
master_x25519_pub: keyFile.x25519_pub,
|
||||
});
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
payload, keyFile.x25519_priv, key,
|
||||
);
|
||||
await sendEnvelope({
|
||||
senderPub: keyFile.x25519_pub,
|
||||
recipientPub: key,
|
||||
senderEd25519Pub: keyFile.pub_key,
|
||||
nonce, ciphertext,
|
||||
});
|
||||
// 3. Optimistic local insert so the row shows up without waiting
|
||||
// for the next pull/refresh round-trip.
|
||||
setDevices(prev => {
|
||||
if (prev.some(d => d.x25519_pub_key === key)) return prev;
|
||||
return [...prev, {
|
||||
x25519_pub_key: key,
|
||||
device_name: name,
|
||||
added_at: Math.floor(Date.now() / 1000),
|
||||
}];
|
||||
});
|
||||
setPairOpen(false);
|
||||
setPairCode(''); setPairKey(''); setPairName('');
|
||||
Alert.alert(
|
||||
'Pairing sent',
|
||||
'The new device should finish pairing in a few seconds.',
|
||||
);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Pairing failed', humanizeTxError(e));
|
||||
} finally {
|
||||
setPairBusy(false);
|
||||
}
|
||||
}, [keyFile, pairCode, pairKey, pairName]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="Devices"
|
||||
divider
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => load(true)}
|
||||
tintColor="#1d9bf0"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 14 }}>
|
||||
Every linked device has its own encryption key. Messages sent to you
|
||||
are delivered to all active devices.
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<View style={{ paddingTop: 60, alignItems: 'center' }}>
|
||||
<ActivityIndicator color="#1d9bf0" />
|
||||
</View>
|
||||
) : devices.length === 0 ? (
|
||||
<View style={{
|
||||
paddingTop: 60, alignItems: 'center', paddingHorizontal: 24,
|
||||
}}>
|
||||
<Ionicons name="phone-portrait-outline" size={36} color="#3a3a3a" />
|
||||
<Text style={{
|
||||
color: '#ffffff', fontSize: 15, fontWeight: '700',
|
||||
marginTop: 10,
|
||||
}}>
|
||||
No devices registered yet
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: '#8b8b8b', fontSize: 13, textAlign: 'center',
|
||||
marginTop: 6, lineHeight: 19,
|
||||
}}>
|
||||
This device auto-registers when the next network-fee is available.
|
||||
Top up your balance and pull to refresh.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{devices.map((d, i) => {
|
||||
const isMe = d.x25519_pub_key === meX25519;
|
||||
const busy = unlinking === d.x25519_pub_key;
|
||||
return (
|
||||
<View key={d.x25519_pub_key}>
|
||||
{i > 0 && <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
paddingHorizontal: 14, paddingVertical: 14, gap: 12,
|
||||
}}>
|
||||
<Ionicons
|
||||
name={isMe ? 'phone-portrait' : 'phone-portrait-outline'}
|
||||
size={22}
|
||||
color={isMe ? '#1d9bf0' : '#d0d0d0'}
|
||||
/>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Text
|
||||
style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{d.device_name || 'Unnamed device'}
|
||||
</Text>
|
||||
{isMe && (
|
||||
<View style={{
|
||||
paddingHorizontal: 6, paddingVertical: 1,
|
||||
borderRadius: 6, backgroundColor: '#0d2540',
|
||||
}}>
|
||||
<Text style={{ color: '#1d9bf0', fontSize: 10, fontWeight: '700' }}>
|
||||
THIS DEVICE
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||
marginTop: 3,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{shortPub(d.x25519_pub_key)}
|
||||
</Text>
|
||||
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
|
||||
Linked {formatDate(d.added_at)}
|
||||
</Text>
|
||||
</View>
|
||||
{!isMe && (
|
||||
<Pressable
|
||||
onPress={() => onUnlink(d)}
|
||||
disabled={busy}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 12, paddingVertical: 7,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1, borderColor: '#3a2020',
|
||||
backgroundColor: pressed ? '#2a1414' : 'transparent',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color="#ff6b6b" />
|
||||
) : (
|
||||
<Text style={{ color: '#ff6b6b', fontSize: 12, fontWeight: '700' }}>
|
||||
Unlink
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Link new device — opens a modal with Code + DeviceKey inputs
|
||||
that the operator transcribes from the new device's
|
||||
/auth/pair screen. */}
|
||||
<View style={{ marginTop: 18 }}>
|
||||
<Pressable
|
||||
onPress={() => setPairOpen(true)}
|
||||
style={({ pressed }) => ({
|
||||
paddingVertical: 13, paddingHorizontal: 16,
|
||||
borderRadius: 14,
|
||||
backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="link-outline" size={18} color="#1d9bf0" />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
|
||||
Link new device
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
|
||||
Enter the 6-digit code from the new device
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#6a6a6a" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* ── Pair new device modal ───────────────────────────────────── */}
|
||||
<Modal
|
||||
visible={pairOpen}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={() => !pairBusy && setPairOpen(false)}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
width: '100%', maxWidth: 420,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 18,
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
padding: 20, gap: 12,
|
||||
}}>
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
justifyContent: 'space-between', marginBottom: 4,
|
||||
}}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 18, fontWeight: '800' }}>
|
||||
Link new device
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => !pairBusy && setPairOpen(false)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons name="close" size={22} color="#8b8b8b" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 18 }}>
|
||||
Open the new device, tap <Text style={{ color: '#ffffff' }}>Pair</Text> on
|
||||
the welcome screen, then transcribe the code + device key shown there.
|
||||
</Text>
|
||||
|
||||
<PairInput
|
||||
label="6-digit code"
|
||||
value={pairCode}
|
||||
onChangeText={setPairCode}
|
||||
placeholder="000000"
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
monospace
|
||||
/>
|
||||
<PairInput
|
||||
label="Device key"
|
||||
value={pairKey}
|
||||
onChangeText={setPairKey}
|
||||
placeholder="64 hex chars"
|
||||
autoCapitalize="none"
|
||||
maxLength={64}
|
||||
monospace
|
||||
/>
|
||||
<PairInput
|
||||
label="Name for this device (optional)"
|
||||
value={pairName}
|
||||
onChangeText={setPairName}
|
||||
placeholder="e.g. Alice's laptop"
|
||||
maxLength={64}
|
||||
/>
|
||||
|
||||
<View style={{
|
||||
flexDirection: 'row', justifyContent: 'flex-end',
|
||||
gap: 10, marginTop: 8,
|
||||
}}>
|
||||
<Pressable
|
||||
onPress={() => setPairOpen(false)}
|
||||
disabled={pairBusy}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 16, paddingVertical: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1a1a1a' : 'transparent',
|
||||
opacity: pairBusy ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 14, fontWeight: '700' }}>
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={submitPair}
|
||||
disabled={pairBusy}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 18, paddingVertical: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1580c8' : '#1d9bf0',
|
||||
opacity: pairBusy ? 0.7 : 1,
|
||||
minWidth: 90, alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{pairBusy ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
|
||||
Link
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PairInput({
|
||||
label, value, onChangeText, placeholder, keyboardType, autoCapitalize,
|
||||
maxLength, monospace,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
placeholder?: string;
|
||||
keyboardType?: 'default' | 'number-pad';
|
||||
autoCapitalize?: 'none' | 'sentences';
|
||||
maxLength?: number;
|
||||
monospace?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: '700',
|
||||
textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6,
|
||||
}}>
|
||||
{label}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#3a3a3a"
|
||||
keyboardType={keyboardType}
|
||||
autoCapitalize={autoCapitalize ?? 'sentences'}
|
||||
autoCorrect={false}
|
||||
maxLength={maxLength}
|
||||
style={{
|
||||
color: '#ffffff', fontSize: monospace ? 13 : 14,
|
||||
fontFamily: monospace ? 'monospace' : undefined,
|
||||
backgroundColor: '#000000',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12, paddingVertical: 10,
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
}}
|
||||
/>
|
||||
</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,34 +128,32 @@ 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); }}
|
||||
/>
|
||||
|
||||
{query.trim().length > 0 && (
|
||||
<Pressable
|
||||
onPress={search}
|
||||
disabled={searching || !query.trim()}
|
||||
disabled={searching}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 11, borderRadius: 999, marginTop: 12,
|
||||
backgroundColor: !query.trim() || searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
backgroundColor: searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
})}
|
||||
>
|
||||
{searching ? (
|
||||
@@ -148,6 +162,31 @@ export default function NewContactScreen() {
|
||||
<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,12 +285,18 @@ export default function NewContactScreen() {
|
||||
onPress={() => setFee(t.value)}
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: active ? '#ffffff' : pressed ? '#1a1a1a' : '#111111',
|
||||
borderWidth: 1, borderColor: active ? '#ffffff' : '#1f1f1f',
|
||||
})}
|
||||
backgroundColor: active ? '#ffffff' : '#111111',
|
||||
borderWidth: 1,
|
||||
borderColor: active ? '#ffffff' : '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: active ? '#000' : '#ffffff',
|
||||
@@ -257,6 +310,7 @@ export default function NewContactScreen() {
|
||||
}}>
|
||||
{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
|
||||
|
||||
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 ?? (isMe ? 'Вы' : shortAddr(address ?? '', 6));
|
||||
: 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,35 @@ 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). */}
|
||||
{/* Action row — View posts is universal (anyone can have a wall,
|
||||
even non-contacts). Open chat appears alongside only when this
|
||||
address is already a direct-chat contact. */}
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 14 }}>
|
||||
<Pressable
|
||||
onPress={() => address && router.push(`/(app)/feed/author/${address}` as never)}
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 11,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
})}
|
||||
>
|
||||
<Ionicons name="document-text-outline" size={15} color="#ffffff" />
|
||||
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
|
||||
View posts
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{!isMe && contact && (
|
||||
<Pressable
|
||||
onPress={openChat}
|
||||
style={({ pressed }) => ({
|
||||
marginTop: 14,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -177,10 +230,11 @@ export default function ProfileScreen() {
|
||||
>
|
||||
<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 }}
|
||||
@@ -492,6 +492,18 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ── Devices — multi-device registry (v2.2.0) ── */}
|
||||
<SectionLabel>Devices</SectionLabel>
|
||||
<Card>
|
||||
<Row
|
||||
icon="phone-portrait-outline"
|
||||
label="Linked devices"
|
||||
value="Manage the devices that receive your messages"
|
||||
onPress={() => router.push('/(app)/devices' as never)}
|
||||
first
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ── Account ── */}
|
||||
<SectionLabel>Account</SectionLabel>
|
||||
<Card>
|
||||
|
||||
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 }}
|
||||
|
||||
300
client-app/app/(auth)/pair.tsx
Normal file
300
client-app/app/(auth)/pair.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Pair — secondary-device onboarding.
|
||||
*
|
||||
* Flow (new device, this screen):
|
||||
* 1. Generate a fresh X25519 keypair locally + random 6-digit code.
|
||||
* 2. Display { code, device x25519 pub }. User enters both on their
|
||||
* primary device (Settings → Devices → Link new device).
|
||||
* 3. Primary device:
|
||||
* - submits LINK_DEVICE tx to publish our pub under its master,
|
||||
* - sends a relay envelope to our x25519 pub, encrypted with its
|
||||
* own x25519 priv, containing { code, master_pub, master_priv,
|
||||
* master_x25519_pub }.
|
||||
* 4. We poll /relay/inbox every few seconds; when a decryptable
|
||||
* envelope arrives whose payload.code matches our displayed code,
|
||||
* we treat it as the handshake, assemble a KeyFile, save it, and
|
||||
* redirect into (app).
|
||||
*
|
||||
* Security notes:
|
||||
* - Master Ed25519 priv travels only via this envelope, encrypted for
|
||||
* this device's X25519 pub (which only this device holds the priv
|
||||
* for). Exposure is limited to one successful decrypt; we DELETE
|
||||
* the envelope from the mailbox immediately.
|
||||
* - The 6-digit code defends against a confused primary device paired
|
||||
* with a different victim, or a race with an attacker who guesses
|
||||
* our X25519 pub. Envelope without matching code → ignored.
|
||||
* - Envelope is short-lived in the mailbox: we DELETE on first decrypt
|
||||
* and the relay node has its own TTL.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, Pressable, ActivityIndicator, ScrollView,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import nacl from 'tweetnacl';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { bytesToHex, decryptMessage } from '@/lib/crypto';
|
||||
import { fetchInbox } from '@/lib/api';
|
||||
import { saveKeyFile, markDeviceRegistered } from '@/lib/storage';
|
||||
import { safeBack } from '@/lib/utils';
|
||||
import type { KeyFile } from '@/lib/types';
|
||||
|
||||
// Protocol constant — bump if payload shape changes.
|
||||
const PAIR_ENVELOPE_VERSION = 1;
|
||||
|
||||
interface PairEnvelopePayload {
|
||||
v: number;
|
||||
type: 'pair-handshake';
|
||||
code: string;
|
||||
master_pub: string; // Ed25519 pub, hex
|
||||
master_priv: string; // Ed25519 priv, hex
|
||||
master_x25519_pub: string; // primary device's x25519 pub, hex (for nothing special — just FYI)
|
||||
}
|
||||
|
||||
function randomCode(): string {
|
||||
// Six decimal digits. Entropy ~20 bits. Good enough for a one-shot
|
||||
// rendezvous code gated by an out-of-band delivery channel (envelope
|
||||
// targeted at a freshly-generated X25519 pub that only this device
|
||||
// holds the priv for).
|
||||
const n = Math.floor(Math.random() * 1_000_000);
|
||||
return n.toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
export default function PairScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
|
||||
// One-shot keypair + code for this pairing session. Regenerate only on
|
||||
// manual Retry (unmount+remount).
|
||||
const session = useRef(genSession()).current;
|
||||
|
||||
const [status, setStatus] = useState<'waiting' | 'success' | 'error'>('waiting');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const copyCode = useCallback(async () => {
|
||||
await Clipboard.setStringAsync(session.code);
|
||||
}, [session.code]);
|
||||
|
||||
const copyPub = useCallback(async () => {
|
||||
await Clipboard.setStringAsync(session.x25519Pub);
|
||||
}, [session.x25519Pub]);
|
||||
|
||||
// Poll mailbox until a matching handshake envelope arrives, or user
|
||||
// backs out. Interval 2.5s — conservative on battery, fine for a
|
||||
// flow the user is staring at for a minute.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const envs = await fetchInbox(session.x25519Pub);
|
||||
for (const env of envs) {
|
||||
// Decrypt each envelope with our session priv. We don't know
|
||||
// the primary's x25519 pub up front — it's inside the envelope
|
||||
// metadata. decryptMessage needs both pubs, so we pass the
|
||||
// envelope's sender_pub directly.
|
||||
const plain = decryptMessage(
|
||||
env.ciphertext, env.nonce, env.sender_pub, session.x25519Priv,
|
||||
);
|
||||
if (!plain) continue;
|
||||
let payload: PairEnvelopePayload;
|
||||
try {
|
||||
payload = JSON.parse(plain);
|
||||
} catch { continue; }
|
||||
if (
|
||||
payload.v !== PAIR_ENVELOPE_VERSION ||
|
||||
payload.type !== 'pair-handshake' ||
|
||||
payload.code !== session.code ||
|
||||
!payload.master_pub || !payload.master_priv
|
||||
) continue;
|
||||
|
||||
// Success — materialise a KeyFile and redirect.
|
||||
const kf: KeyFile = {
|
||||
pub_key: payload.master_pub,
|
||||
priv_key: payload.master_priv,
|
||||
x25519_pub: session.x25519Pub,
|
||||
x25519_priv: session.x25519Priv,
|
||||
};
|
||||
await saveKeyFile(kf);
|
||||
// The root layout auto-links us on first boot if needed, but
|
||||
// the primary device already submitted LINK_DEVICE for our
|
||||
// pub as part of the pairing, so the registry is already
|
||||
// correct. Mark ourselves registered so the revoke-detection
|
||||
// branch doesn't spuriously wipe on next launch.
|
||||
await markDeviceRegistered();
|
||||
setKeyFile(kf);
|
||||
|
||||
// Envelope stays in mailbox until relay TTL eviction; the
|
||||
// single-shot handshake is idempotent (saveKeyFile overwrites)
|
||||
// and our session pub won't be polled again after redirect.
|
||||
setStatus('success');
|
||||
setTimeout(() => {
|
||||
if (!cancelled) router.replace('/(app)/chats' as never);
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* transient — retry */
|
||||
}
|
||||
if (!cancelled) timer = setTimeout(tick, 2_500);
|
||||
};
|
||||
|
||||
tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [session, setKeyFile]);
|
||||
|
||||
return (
|
||||
<View style={{
|
||||
flex: 1, backgroundColor: '#000000',
|
||||
paddingTop: insets.top + 12,
|
||||
paddingBottom: Math.max(insets.bottom, 20),
|
||||
}}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 24 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<Pressable
|
||||
onPress={() => safeBack('/')}
|
||||
hitSlop={8}
|
||||
style={{ alignSelf: 'flex-start', marginBottom: 20 }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={28} color="#ffffff" />
|
||||
</Pressable>
|
||||
|
||||
<View style={{ alignItems: 'center', marginBottom: 28 }}>
|
||||
<View style={{
|
||||
width: 80, height: 80, borderRadius: 22,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Ionicons name="link" size={40} color="#1d9bf0" />
|
||||
</View>
|
||||
<Text style={{
|
||||
color: '#ffffff', fontSize: 22, fontWeight: '800',
|
||||
marginTop: 14, textAlign: 'center',
|
||||
}}>
|
||||
Pair with your other device
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: '#8b8b8b', fontSize: 13, lineHeight: 19,
|
||||
marginTop: 8, textAlign: 'center', maxWidth: 300,
|
||||
}}>
|
||||
On a device where you're already signed in,
|
||||
open Settings → Devices → Link new device,
|
||||
and enter these values.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Code */}
|
||||
<View style={{
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
paddingVertical: 18, paddingHorizontal: 16,
|
||||
marginBottom: 14, alignItems: 'center',
|
||||
}}>
|
||||
<Text style={{
|
||||
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
|
||||
textTransform: 'uppercase', letterSpacing: 1.2,
|
||||
}}>
|
||||
1. Code
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: '#ffffff', fontSize: 38, fontWeight: '800',
|
||||
letterSpacing: 6, marginTop: 4, fontFamily: 'monospace',
|
||||
}}>
|
||||
{session.code.slice(0, 3)} {session.code.slice(3)}
|
||||
</Text>
|
||||
<Pressable onPress={copyCode} hitSlop={6} style={{ marginTop: 6 }}>
|
||||
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
|
||||
Copy code
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Device key */}
|
||||
<View style={{
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
paddingVertical: 16, paddingHorizontal: 16,
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<Text style={{
|
||||
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
|
||||
textTransform: 'uppercase', letterSpacing: 1.2,
|
||||
}}>
|
||||
2. Device key
|
||||
</Text>
|
||||
<Text
|
||||
selectable
|
||||
style={{
|
||||
color: '#ffffff', fontSize: 12, fontFamily: 'monospace',
|
||||
marginTop: 6, lineHeight: 17,
|
||||
}}
|
||||
>
|
||||
{session.x25519Pub}
|
||||
</Text>
|
||||
<Pressable onPress={copyPub} hitSlop={6} style={{ marginTop: 8 }}>
|
||||
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
|
||||
Copy key
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Status */}
|
||||
<View style={{ alignItems: 'center', minHeight: 56 }}>
|
||||
{status === 'waiting' && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<ActivityIndicator size="small" color="#1d9bf0" />
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13 }}>
|
||||
Waiting for your other device…
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#3ba55d" />
|
||||
<Text style={{ color: '#3ba55d', fontSize: 13, fontWeight: '700' }}>
|
||||
Paired. Opening your chats…
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<Text style={{ color: '#f4212e', fontSize: 13, textAlign: 'center' }}>
|
||||
{err ?? 'Something went wrong. Please retry.'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── session helper ──────────────────────────────────────────────────────
|
||||
|
||||
interface PairSession {
|
||||
x25519Pub: string; // hex
|
||||
x25519Priv: string; // hex
|
||||
code: string;
|
||||
}
|
||||
|
||||
function genSession(): PairSession {
|
||||
const kp = nacl.box.keyPair();
|
||||
return {
|
||||
x25519Pub: bytesToHex(kp.publicKey),
|
||||
x25519Priv: bytesToHex(kp.secretKey),
|
||||
code: randomCode(),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -346,14 +346,19 @@ export default function WelcomeScreen() {
|
||||
{/* CTA — прижата к правому нижнему краю. */}
|
||||
<View style={{
|
||||
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
|
||||
paddingHorizontal: 24, paddingBottom: 8,
|
||||
paddingHorizontal: 24, paddingBottom: 8, flexWrap: 'wrap',
|
||||
}}>
|
||||
<CTASecondary
|
||||
label="Импорт"
|
||||
label="Pair"
|
||||
icon="link"
|
||||
onPress={() => router.push('/(auth)/pair' as never)}
|
||||
/>
|
||||
<CTASecondary
|
||||
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,6 +51,9 @@ export function Avatar({ name, address, size = 48, dotColor, className }: Avatar
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{saved ? (
|
||||
<Ionicons name="bookmark" size={size * 0.5} color="#ffffff" />
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
color: '#d0d0d0',
|
||||
@@ -55,6 +64,7 @@ export function Avatar({ name, address, size = 48, dotColor, className }: Avatar
|
||||
>
|
||||
{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,6 +146,8 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
|
||||
>
|
||||
{last
|
||||
? lastPreview(last)
|
||||
: saved
|
||||
? 'Your personal notes & files'
|
||||
: c.x25519Pub
|
||||
? 'Tap to start encrypted chat'
|
||||
: 'Waiting for identity…'}
|
||||
|
||||
@@ -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 }} />
|
||||
<Ionicons name="search" size={16} color="#6a6a6a" />
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#8b8b8b"
|
||||
placeholderTextColor="#5a5a5a"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
autoFocus={autoFocus}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
returnKeyType="search"
|
||||
style={{
|
||||
flex: 1,
|
||||
color: '#ffffff',
|
||||
fontSize: 14,
|
||||
paddingVertical: 10,
|
||||
padding: 0,
|
||||
includeFontPadding: false,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{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';
|
||||
}
|
||||
|
||||
@@ -52,10 +52,13 @@ export function useGlobalInbox() {
|
||||
try {
|
||||
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||||
for (const env of envelopes) {
|
||||
// Найти контакт по sender_pub — если не знакомый, игнорим
|
||||
// (для MVP; в future можно показывать "unknown sender").
|
||||
const c = contactsRef.current.find(
|
||||
x => x.x25519Pub === env.sender_pub,
|
||||
// Attribution (v2.2.0+): prefer the envelope's master Ed25519
|
||||
// so messages from any of the sender's linked devices roll
|
||||
// into a single chat. Fall back to legacy X25519-based lookup
|
||||
// for pre-v2.2.0 senders that left the field empty.
|
||||
const c = contactsRef.current.find(x =>
|
||||
(env.sender_ed25519_pub && x.address === env.sender_ed25519_pub) ||
|
||||
x.x25519Pub === env.sender_pub,
|
||||
);
|
||||
if (!c) continue;
|
||||
|
||||
|
||||
@@ -24,10 +24,26 @@ import { tryParsePostRef } from '@/lib/forwardPost';
|
||||
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
|
||||
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
|
||||
|
||||
export function useMessages(contactX25519: string) {
|
||||
/**
|
||||
* useMessages — mounts per-chat inbox consumption. Accepts:
|
||||
* - contactX25519: the legacy/primary X25519 for the contact.
|
||||
* - contactMasterEd25519 (optional, v2.2.0+): the contact's master
|
||||
* identity so we can attribute envelopes from any of their
|
||||
* linked devices to this conversation.
|
||||
*
|
||||
* Matching rule: an envelope belongs to this chat when
|
||||
* env.sender_ed25519_pub === contactMasterEd25519 (v2.2.0 path)
|
||||
* OR env.sender_pub === contactX25519 (legacy path)
|
||||
*/
|
||||
export function useMessages(contactX25519: string, contactMasterEd25519?: string) {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const appendMsg = useStore(s => s.appendMessage);
|
||||
|
||||
const matchesChat = useCallback((env: { sender_pub: string; sender_ed25519_pub: string }): boolean => {
|
||||
if (contactMasterEd25519 && env.sender_ed25519_pub === contactMasterEd25519) return true;
|
||||
return env.sender_pub === contactX25519;
|
||||
}, [contactX25519, contactMasterEd25519]);
|
||||
|
||||
// Подгружаем кэш сообщений из AsyncStorage при открытии чата.
|
||||
// Релей держит envelope'ы всего 7 дней, поэтому без чтения кэша
|
||||
// история старше недели пропадает при каждом рестарте приложения.
|
||||
@@ -48,8 +64,8 @@ export function useMessages(contactX25519: string) {
|
||||
try {
|
||||
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||||
for (const env of envelopes) {
|
||||
// Only process messages from this contact
|
||||
if (env.sender_pub !== contactX25519) continue;
|
||||
// Only process messages that belong to this chat (see matchesChat).
|
||||
if (!matchesChat(env)) continue;
|
||||
|
||||
const text = decryptMessage(
|
||||
env.ciphertext,
|
||||
@@ -130,10 +146,17 @@ export function useMessages(contactX25519: string) {
|
||||
// the handler so we only render messages in THIS chat.
|
||||
const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
|
||||
if (frame.event !== 'inbox') return;
|
||||
const d = frame.data as { sender_pub?: string } | undefined;
|
||||
// Optimisation: if the envelope is from a different peer, skip the
|
||||
// whole refetch — we'd just drop it in the sender filter below anyway.
|
||||
if (d?.sender_pub && d.sender_pub !== contactX25519) return;
|
||||
const d = frame.data as {
|
||||
sender_pub?: string; sender_ed25519_pub?: string;
|
||||
} | undefined;
|
||||
// Optimisation: if the envelope definitely isn't for this chat,
|
||||
// skip the whole refetch. Multi-device aware — the peer may be
|
||||
// writing from any of their linked devices (different X25519
|
||||
// pubs), so we check against their master Ed25519 too.
|
||||
if (d && !matchesChat({
|
||||
sender_pub: d.sender_pub ?? '',
|
||||
sender_ed25519_pub: d.sender_ed25519_pub ?? '',
|
||||
})) return;
|
||||
pullAndDecrypt();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 => ({
|
||||
@@ -227,6 +264,9 @@ export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord
|
||||
interface InboxItemWire {
|
||||
id: string;
|
||||
sender_pub: string;
|
||||
/** sender_ed25519_pub was added in v2.2.0; older nodes omit it.
|
||||
Default to empty string when missing. */
|
||||
sender_ed25519_pub?: string;
|
||||
recipient_pub: string;
|
||||
fee_ut?: number;
|
||||
sent_at: number;
|
||||
@@ -291,6 +331,7 @@ export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
|
||||
return items.map((it): Envelope => ({
|
||||
id: it.id,
|
||||
sender_pub: it.sender_pub,
|
||||
sender_ed25519_pub: it.sender_ed25519_pub ?? '',
|
||||
recipient_pub: it.recipient_pub,
|
||||
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
||||
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
||||
@@ -302,7 +343,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 +357,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 ?? [];
|
||||
}
|
||||
|
||||
@@ -328,6 +369,58 @@ export interface IdentityInfo {
|
||||
x25519_pub: string; // hex Curve25519 key; empty string if not published
|
||||
nickname: string;
|
||||
registered: boolean;
|
||||
/**
|
||||
* Number of active (non-revoked) devices linked to this master identity
|
||||
* via LINK_DEVICE (v2.2.0). 0 for legacy identities that only published
|
||||
* a single X25519 via REGISTER_KEY — senders should fall back to
|
||||
* `x25519_pub` above and skip the device fan-out path.
|
||||
*/
|
||||
device_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One active device in an identity's multi-device registry. Returned by
|
||||
* GET /api/devices/{master_pub} as part of `devices[]`. Senders use the
|
||||
* list to fan out one sealed envelope per X25519 pub so all of the
|
||||
* recipient's devices receive the message.
|
||||
*/
|
||||
export interface DeviceInfo {
|
||||
x25519_pub_key: string;
|
||||
device_name: string;
|
||||
added_at: number; // unix seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
@@ -339,6 +432,56 @@ export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo |
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Multi-device registry (v2.2.0) ───────────────────────────────────────
|
||||
|
||||
interface DevicesResponse {
|
||||
master_pub: string;
|
||||
count: number;
|
||||
devices: DeviceInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/devices/{master_pub} — all active (non-revoked) device records
|
||||
* for the given master identity. Returns an empty array for a legacy
|
||||
* identity (device_count == 0) or a network error — callers should treat
|
||||
* both the same way and fall back to IdentityInfo.x25519_pub so the
|
||||
* pre-v2.2.0 single-device path keeps working.
|
||||
*/
|
||||
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
|
||||
try {
|
||||
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
|
||||
return resp.devices ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the right set of recipient X25519 pubs for a sender's fan-out.
|
||||
* Two paths, in priority order:
|
||||
*
|
||||
* 1. New path — /api/devices returns ≥1 entry. Send to each device.
|
||||
* 2. Legacy path — identity published an X25519 via REGISTER_KEY
|
||||
* (pre-v2.2.0 clients). Send to just that one.
|
||||
*
|
||||
* Returns an empty array only when the recipient has published nothing
|
||||
* at all — caller must surface "no encryption key" to the user rather
|
||||
* than drop the message on the floor.
|
||||
*/
|
||||
export async function resolveRecipientKeys(
|
||||
recipientMasterPub: string,
|
||||
): Promise<string[]> {
|
||||
const devs = await fetchDevices(recipientMasterPub);
|
||||
if (devs.length > 0) {
|
||||
return devs.map(d => d.x25519_pub_key);
|
||||
}
|
||||
const identity = await getIdentity(recipientMasterPub);
|
||||
if (identity?.x25519_pub) {
|
||||
return [identity.x25519_pub];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ─── Contract API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -642,6 +785,68 @@ export function buildTransferTx(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LINK_DEVICE transaction — publish a per-device X25519 pub in the
|
||||
* identity's device registry so senders can fan out envelopes across
|
||||
* every active device. Signed by the master Ed25519 (= `from`).
|
||||
*
|
||||
* `deviceName` is a short human label shown in Settings → Devices
|
||||
* (≤ 64 bytes, printable ASCII/UTF-8, no control chars).
|
||||
*/
|
||||
export function buildLinkDeviceTx(params: {
|
||||
from: string; // master Ed25519 pubkey
|
||||
x25519Pub: string; // per-device X25519 pubkey (64 hex chars, lowercase)
|
||||
deviceName: string;
|
||||
privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payloadObj = {
|
||||
x25519_pub_key: params.x25519Pub,
|
||||
device_name: params.deviceName,
|
||||
};
|
||||
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'LINK_DEVICE', from: params.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
id, type: 'LINK_DEVICE', from: params.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UNLINK_DEVICE transaction — revoke a previously-linked device so senders
|
||||
* stop shipping envelopes to its X25519 pub. The revoked device itself,
|
||||
* when it next comes online and sees its own pub in the revoked list,
|
||||
* is expected to wipe local state (master priv + cached chats).
|
||||
*/
|
||||
export function buildUnlinkDeviceTx(params: {
|
||||
from: string; // master Ed25519 pubkey
|
||||
x25519Pub: string; // pub to revoke
|
||||
privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payloadObj = { x25519_pub_key: params.x25519Pub };
|
||||
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTACT_REQUEST transaction.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,6 +14,12 @@ const KEYFILE_KEY = 'dchain_keyfile';
|
||||
const CONTACTS_KEY = 'dchain_contacts';
|
||||
const SETTINGS_KEY = 'dchain_settings';
|
||||
const CHATS_KEY = 'dchain_chats';
|
||||
// Remembers (locally, per install) that this device's X25519 pub has been
|
||||
// successfully linked on-chain at least once. Distinguishes "first boot,
|
||||
// not registered yet" from "we were registered and then revoked by another
|
||||
// device". The second case triggers self-wipe. Stored in AsyncStorage —
|
||||
// if it's missing, we simply re-link.
|
||||
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
|
||||
|
||||
/** Save the key file in secure storage (encrypted on device). */
|
||||
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
||||
@@ -99,3 +105,57 @@ export async function appendMessage(chatId: string, msg: CachedMessage): Promise
|
||||
const trimmed = msgs.slice(-500);
|
||||
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
|
||||
}
|
||||
|
||||
// ─── Multi-device bookkeeping (v2.2.0) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* isDeviceRegistered returns true if this device has ever successfully
|
||||
* linked its X25519 pub on-chain under the current master identity.
|
||||
* A true-then-absent transition (registered → not in chain's active list)
|
||||
* is interpreted as a remote revoke and triggers self-wipe.
|
||||
*/
|
||||
export async function isDeviceRegistered(): Promise<boolean> {
|
||||
return (await AsyncStorage.getItem(DEVICE_REGISTERED_KEY)) === '1';
|
||||
}
|
||||
|
||||
/** markDeviceRegistered is called after a LINK_DEVICE commits or is
|
||||
observed in the registry on startup. */
|
||||
export async function markDeviceRegistered(): Promise<void> {
|
||||
await AsyncStorage.setItem(DEVICE_REGISTERED_KEY, '1');
|
||||
}
|
||||
|
||||
/** clearDeviceRegistered is part of wipeAllLocalState; also called on
|
||||
explicit logout. */
|
||||
export async function clearDeviceRegistered(): Promise<void> {
|
||||
await AsyncStorage.removeItem(DEVICE_REGISTERED_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* wipeAllLocalState zeroes out every on-device artifact tied to the
|
||||
* current identity: secure-store key, settings, contacts, chats cache,
|
||||
* registered-device marker. Safe to call multiple times.
|
||||
*
|
||||
* Called in two scenarios:
|
||||
* 1. Explicit "Delete account" in Settings.
|
||||
* 2. Self-detected revoke — the chain says our X25519 pub is no longer
|
||||
* in the active registry but we previously marked it registered,
|
||||
* so another device issued UNLINK_DEVICE against us. We must not
|
||||
* keep using the master priv any more — it still works at the
|
||||
* crypto level, but the social contract is that we're revoked.
|
||||
*/
|
||||
export async function wipeAllLocalState(): Promise<void> {
|
||||
// Secure store (key).
|
||||
await SecureStore.deleteItemAsync(KEYFILE_KEY).catch(() => {});
|
||||
// AsyncStorage — enumerate our known keys. We don't clear() the whole
|
||||
// store because a share-provider or other app shard could live there.
|
||||
const ks = await AsyncStorage.getAllKeys();
|
||||
const ours = ks.filter(k =>
|
||||
k === CONTACTS_KEY ||
|
||||
k === SETTINGS_KEY ||
|
||||
k === DEVICE_REGISTERED_KEY ||
|
||||
k.startsWith(`${CHATS_KEY}_`),
|
||||
);
|
||||
if (ours.length > 0) {
|
||||
await AsyncStorage.multiRemove(ours);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,19 @@ export interface Contact {
|
||||
export interface Envelope {
|
||||
/** sha256(nonce||ciphertext)[:16] hex — stable server-assigned id. */
|
||||
id: string;
|
||||
sender_pub: string; // X25519 hex
|
||||
sender_pub: string; // X25519 hex (this envelope's per-device sender key)
|
||||
/**
|
||||
* sender_ed25519_pub (v2.2.0+): the sender's master Ed25519 identity.
|
||||
* Multiple X25519 pubs under the same identity all share one master —
|
||||
* clients use THIS to group messages into a single conversation even
|
||||
* when the sender replies from different devices.
|
||||
*
|
||||
* Empty string on legacy envelopes from pre-v2.2.0 senders. Consumers
|
||||
* should fall back to `sender_pub` in that case (keeps old clients'
|
||||
* messages visible, even if attribution is per-X25519 rather than
|
||||
* per-identity).
|
||||
*/
|
||||
sender_ed25519_pub: string;
|
||||
recipient_pub: string; // X25519 hex
|
||||
nonce: string; // hex 24 bytes
|
||||
ciphertext: string; // hex NaCl box
|
||||
|
||||
@@ -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));
|
||||
|
||||
140
cmd/node/main.go
140
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
|
||||
@@ -656,10 +684,16 @@ func main() {
|
||||
// /relay/inbox if it needs the full envelope. Keeps WS frames small and
|
||||
// avoids a fat push for every message.
|
||||
mailbox.SetOnStore(func(env *relay.Envelope) {
|
||||
// Summary only — no ciphertext. Multi-device (v2.2.0+) clients
|
||||
// use sender_ed25519_pub to decide whether the envelope belongs
|
||||
// to the chat they're currently viewing (messages from any of
|
||||
// the peer's linked devices share a master identity), so the
|
||||
// field must be in every push.
|
||||
sum, _ := json.Marshal(map[string]any{
|
||||
"id": env.ID,
|
||||
"recipient_pub": env.RecipientPub,
|
||||
"sender_pub": env.SenderPub,
|
||||
"sender_ed25519_pub": env.SenderEd25519PubKey,
|
||||
"sent_at": env.SentAt,
|
||||
})
|
||||
eventBus.EmitInbox(env.RecipientPub, sum)
|
||||
@@ -850,6 +884,7 @@ func main() {
|
||||
IdentityInfo: func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error) {
|
||||
return chain.IdentityInfo(pubKeyOrAddr)
|
||||
},
|
||||
DevicesOf: chain.DevicesOf,
|
||||
ValidatorSet: chain.ValidatorSet,
|
||||
SubmitTx: func(tx *blockchain.Transaction) error {
|
||||
if err := engine.AddTransaction(tx); err != nil {
|
||||
@@ -1291,11 +1326,38 @@ 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 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()
|
||||
@@ -1305,9 +1367,12 @@ func loadOrCreateIdentity(keyFile string) *identity.Identity {
|
||||
}
|
||||
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 +1385,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 +1507,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
|
||||
|
||||
202
deploy/single/join.sh
Normal file
202
deploy/single/join.sh
Normal file
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bash
|
||||
# rejoin.sh — полная переустановка dchain joiner-ноды с нуля.
|
||||
# Публичный доступ без Caddy/TLS, без API-токена (на свой страх и риск).
|
||||
# Запускать БЕЗ sudo.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── КОНФИГ ─────────────────────────────────────────────────────────────
|
||||
SEED_HTTP="${SEED_HTTP:-http://62.171.151.182:8082}"
|
||||
SEED_P2P_IP="${SEED_P2P_IP:-62.171.151.182}"
|
||||
SEED_P2P_PORT="${SEED_P2P_PORT:-4005}"
|
||||
REPO_URL="${REPO_URL:-https://git.vsecoder.vodka/vsecoder/dchain.git}"
|
||||
WORKDIR="${WORKDIR:-$HOME/dchain}"
|
||||
LOCAL_P2P_PORT="${LOCAL_P2P_PORT:-4001}"
|
||||
LOCAL_HTTP_PORT="${LOCAL_HTTP_PORT:-8082}"
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31mXX\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
need() { command -v "$1" >/dev/null || die "need $1 installed"; }
|
||||
|
||||
# ── 0. Требования ──────────────────────────────────────────────────────
|
||||
[[ $EUID -eq 0 ]] && die "don't run as root — use your user account, sudo is used per-command"
|
||||
need docker; need git; need curl; need jq
|
||||
sudo -v
|
||||
|
||||
# ── 1. Снести предыдущее состояние ─────────────────────────────────────
|
||||
log "stopping any existing dchain stack & purging volumes"
|
||||
if [[ -d "$WORKDIR/deploy/single" ]]; then
|
||||
(cd "$WORKDIR/deploy/single" && sudo docker compose down -v 2>/dev/null) || true
|
||||
fi
|
||||
sudo docker rm -f dchain_node dchain_caddy dchain 2>/dev/null || true
|
||||
mapfile -t stale_vols < <(sudo docker volume ls -q | grep -E '^(dchain|dchain-single)' || true)
|
||||
(( ${#stale_vols[@]} > 0 )) && sudo docker volume rm "${stale_vols[@]}" 2>/dev/null || true
|
||||
|
||||
# ── 2. Свежий репозиторий ──────────────────────────────────────────────
|
||||
log "fetching repo → $WORKDIR"
|
||||
if [[ -d "$WORKDIR/.git" ]]; then
|
||||
git -C "$WORKDIR" fetch --all --tags --prune
|
||||
git -C "$WORKDIR" reset --hard origin/main
|
||||
else
|
||||
rm -rf "$WORKDIR"
|
||||
git clone "$REPO_URL" "$WORKDIR"
|
||||
fi
|
||||
cd "$WORKDIR/deploy/single"
|
||||
|
||||
# ── 3. keys/ под UID 100 ───────────────────────────────────────────────
|
||||
log "preparing keys/ for container UID 100:101"
|
||||
mkdir -p keys
|
||||
sudo chown 100:101 keys
|
||||
sudo chmod 755 keys
|
||||
[[ -f keys/node.json ]] && { sudo chown 100:101 keys/node.json; sudo chmod 600 keys/node.json; }
|
||||
|
||||
# ── 4. Slim-образ ──────────────────────────────────────────────────────
|
||||
log "building dchain image (slim)"
|
||||
IMAGE=$(sudo docker build -q -f ../prod/Dockerfile.slim ../..)
|
||||
[[ -z "$IMAGE" ]] && die "docker build failed"
|
||||
|
||||
# ── 5. Ключ ноды ───────────────────────────────────────────────────────
|
||||
if [[ ! -f keys/node.json ]]; then
|
||||
log "generating node identity"
|
||||
sudo docker run --rm --entrypoint /usr/local/bin/client \
|
||||
--user 100:101 \
|
||||
-v "$PWD/keys:/out" \
|
||||
"$IMAGE" \
|
||||
keygen --out /out/node.json
|
||||
else
|
||||
log "reusing existing keys/node.json"
|
||||
fi
|
||||
sudo chown 100:101 keys/node.json
|
||||
sudo chmod 600 keys/node.json
|
||||
|
||||
# ── 6. peer_id seed'a ──────────────────────────────────────────────────
|
||||
log "fetching seed peer_id from $SEED_HTTP/stats"
|
||||
SEED_PEER_ID=$(curl -sfL "$SEED_HTTP/stats" | jq -r '.node.peer_id // empty')
|
||||
[[ -z "$SEED_PEER_ID" ]] && die "can't reach seed at $SEED_HTTP or peer_id missing"
|
||||
log "seed peer_id = $SEED_PEER_ID"
|
||||
|
||||
# ── 7. Публичный IP ────────────────────────────────────────────────────
|
||||
PUBLIC_IP="${PUBLIC_IP:-$(curl -sfL https://api.ipify.org || true)}"
|
||||
[[ -z "$PUBLIC_IP" ]] && die "can't detect public IP; export PUBLIC_IP=x.x.x.x и перезапусти"
|
||||
log "public IP = $PUBLIC_IP"
|
||||
|
||||
# ── 8. node.env (без токена, без genesis) ──────────────────────────────
|
||||
log "writing node.env"
|
||||
cp -f node.env.example node.env
|
||||
|
||||
# удалить из example'а значения, которые мы хотим держать под своим контролем,
|
||||
# чтобы они не перекрыли наши append'ы снизу
|
||||
sudo sed -i -E '/^(#\s*)?DCHAIN_(GENESIS|API_TOKEN|API_PRIVATE|JOIN|PEERS|ANNOUNCE|DB|MAILBOX_DB|FEED_DB|REGISTER_RELAY|RELAY_FEE|FEED_DISK_LIMIT_MB|CHAIN_DISK_LIMIT_MB|UPDATE_SOURCE_URL)=/d' node.env
|
||||
|
||||
sudo tee -a node.env > /dev/null <<EOF
|
||||
|
||||
# ── AUTO-GENERATED BY rejoin.sh ─────────────────────────────────────
|
||||
# joiner → seed $SEED_HTTP (public node, no token)
|
||||
DCHAIN_JOIN=$SEED_HTTP
|
||||
DCHAIN_PEERS=/ip4/$SEED_P2P_IP/tcp/$SEED_P2P_PORT/p2p/$SEED_PEER_ID
|
||||
DCHAIN_ANNOUNCE=/ip4/$PUBLIC_IP/tcp/$LOCAL_P2P_PORT
|
||||
|
||||
DCHAIN_DB=/data/chain
|
||||
DCHAIN_MAILBOX_DB=/data/mailbox
|
||||
DCHAIN_FEED_DB=/data/feed
|
||||
|
||||
DCHAIN_REGISTER_RELAY=true
|
||||
DCHAIN_RELAY_FEE=1000
|
||||
|
||||
DCHAIN_FEED_DISK_LIMIT_MB=4096
|
||||
DCHAIN_CHAIN_DISK_LIMIT_MB=20480
|
||||
|
||||
DCHAIN_UPDATE_SOURCE_URL=https://git.vsecoder.vodka/api/v1/repos/vsecoder/dchain/releases/latest
|
||||
EOF
|
||||
|
||||
# ── 9. Compose: прямой проброс 4001+8080 наружу, Caddy снести навсегда ─
|
||||
log "patching docker-compose.yml: direct ports, caddy removed"
|
||||
python3 - <<PY
|
||||
import re, pathlib
|
||||
p = pathlib.Path('docker-compose.yml')
|
||||
src = p.read_text()
|
||||
|
||||
# 9a. Вырезать весь сервис caddy (от " caddy:" до следующего сервиса
|
||||
# того же уровня или конца блока services).
|
||||
src = re.sub(
|
||||
r'(?ms)^ caddy:\n(?:(?: .*\n)|\n)+?(?=^ [A-Za-z_-]+:|\Z)',
|
||||
'', src,
|
||||
)
|
||||
|
||||
# 9b. Убрать зависимости от caddy, если где-то остались.
|
||||
src = re.sub(r'(?m)^\s*-\s*caddy\s*$\n', '', src)
|
||||
src = re.sub(r'(?m)^\s*depends_on:\s*\n(\s*-\s*caddy\s*\n)+', '', src)
|
||||
|
||||
# 9c. Заставить node светить наружу 4001 (libp2p) и 8080 (HTTP).
|
||||
# Ищем первый ports: в сервисе node; если его нет — инжектим.
|
||||
m = re.search(r'(?ms)^ node:\n(.*?)(?=^ [A-Za-z_-]+:|\Z)', src)
|
||||
if not m:
|
||||
raise SystemExit('no `node:` service in compose')
|
||||
node_block = m.group(1)
|
||||
|
||||
wanted = f' ports:\n - "$LOCAL_P2P_PORT:4001"\n - "$LOCAL_HTTP_PORT:8080"\n'
|
||||
|
||||
if re.search(r'(?m)^ ports:', node_block):
|
||||
# Заменить существующий блок ports целиком
|
||||
node_block_new = re.sub(
|
||||
r'(?ms)^ ports:\n(?: -.*\n)+',
|
||||
wanted, node_block,
|
||||
)
|
||||
else:
|
||||
# Добавить сразу после строки "container_name:" (или restart:)
|
||||
node_block_new = re.sub(
|
||||
r'(?m)^( (?:container_name|restart):.*\n)',
|
||||
r'\\1' + wanted, node_block, count=1,
|
||||
)
|
||||
if node_block_new == node_block: # fallback — в начало блока
|
||||
node_block_new = wanted + node_block
|
||||
|
||||
src = src[:m.start(1)] + node_block_new + src[m.end(1):]
|
||||
|
||||
# 9d. Убрать блок expose у node — не нужен, раз ports наружу.
|
||||
src = re.sub(
|
||||
r'(?ms)^ expose:\n(?: -.*\n)+', '', src,
|
||||
)
|
||||
|
||||
p.write_text(src)
|
||||
PY
|
||||
|
||||
# ── 10. Подъём ─────────────────────────────────────────────────────────
|
||||
log "docker compose up -d"
|
||||
sudo docker compose up -d --build
|
||||
|
||||
# ── 11. Sanity checks ──────────────────────────────────────────────────
|
||||
sleep 3
|
||||
log "sanity checks (дай ~30 сек чтобы healthcheck подхватился):"
|
||||
set +e
|
||||
echo "──── docker compose ps ────"
|
||||
sudo docker compose ps
|
||||
echo "──── docker logs dchain_node | tail ────"
|
||||
sudo docker logs --tail 30 dchain_node 2>&1
|
||||
echo "──── /api/netstats ────"
|
||||
curl -s "http://localhost:$LOCAL_HTTP_PORT/api/netstats" | jq '.'
|
||||
echo "──── seed height (для сверки) ────"
|
||||
curl -s "$SEED_HTTP/api/netstats" | jq '.total_blocks'
|
||||
|
||||
cat <<EOM
|
||||
|
||||
======================================================================
|
||||
✓ Готово. Нода публичная, без токена и без TLS.
|
||||
|
||||
API:
|
||||
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/api/netstats
|
||||
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/swagger
|
||||
|
||||
Следи за sync'ом:
|
||||
sudo docker logs -f dchain_node | grep -E 'applied|height|peer'
|
||||
|
||||
Повторный полный сброс:
|
||||
./rejoin.sh
|
||||
|
||||
Открой в firewall/security-group на этом VPS:
|
||||
- $LOCAL_P2P_PORT/tcp (libp2p)
|
||||
- $LOCAL_HTTP_PORT/tcp (HTTP API)
|
||||
======================================================================
|
||||
EOM
|
||||
9
desktop/.gitignore
vendored
Normal file
9
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-electron/
|
||||
release/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# electron-builder output
|
||||
out/
|
||||
76
desktop/README.md
Normal file
76
desktop/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# DChain Desktop
|
||||
|
||||
Electron shell for the DChain messenger and social feed.
|
||||
|
||||
Same functionality as the mobile client-app, re-imagined with a
|
||||
keyboard-first, 3-panel desktop layout:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ DChain │ titlebar (drag)
|
||||
├──────┬───────────────────┬────────────────────────────────┤
|
||||
│ nav │ list │ detail │
|
||||
│ 72px │ 340px fixed │ flex 1 │
|
||||
├──────┴───────────────────┴────────────────────────────────┤
|
||||
│ ● online · node.example:8080 · height 10942 │ status bar
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Sections (left rail): **Messages · Feed · Wallet · Contacts · Settings · Profile**.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
npm install
|
||||
npm run dev # concurrently: Vite dev server + Electron
|
||||
```
|
||||
|
||||
The first boot will show the Welcome screen. Pick Create to generate
|
||||
fresh keys, or Import a `node.json` exported from the mobile client.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build # produces dist/ (renderer) + dist-electron/ (main) + installers
|
||||
```
|
||||
|
||||
Default installers are built with `electron-builder`: `.dmg` on macOS,
|
||||
NSIS `.exe` on Windows, AppImage + `.deb` on Linux. Adjust `build.*` in
|
||||
`package.json` for signing / notarisation.
|
||||
|
||||
## Layout
|
||||
|
||||
- `electron/` — main + preload. TypeScript, compiled to `dist-electron/`
|
||||
by `tsc -p electron/tsconfig.json`.
|
||||
- `src/` — renderer. React + Vite. `@/` aliases to `src/`.
|
||||
- `src/shell/` — 3-panel chrome.
|
||||
- `src/sections/` — one folder per nav section, each exports `{ List, Detail }`.
|
||||
- `src/auth/Welcome.tsx` — shown when no key is loaded.
|
||||
- `src/lib/` — api, storage, store, types. Mirrors (without React-Native
|
||||
deps) the relevant pieces of `../client-app/lib/`.
|
||||
|
||||
## Security model
|
||||
|
||||
Master Ed25519 priv lives in the OS keychain via Electron `safeStorage`
|
||||
(macOS Keychain / Windows DPAPI / libsecret). A renderer compromise
|
||||
cannot read or exfiltrate the key — it always travels through
|
||||
`window.dchain.keyfile.*` IPC, which main.ts validates and mediates.
|
||||
|
||||
`contextIsolation: true`, `nodeIntegration: false`. CSP in `index.html`
|
||||
pins script sources to `'self'` while allowing `connect-src *` so the
|
||||
renderer can hit any node the user configures.
|
||||
|
||||
## Pairing (v2.2.0-alpha5+)
|
||||
|
||||
Desktop will reuse the same 6-digit-code + relay-envelope handshake as
|
||||
the mobile client. The scaffold in `src/auth/Welcome.tsx` stubs the
|
||||
button until the polling loop lands.
|
||||
|
||||
## Multi-device fan-out
|
||||
|
||||
When the node is at v2.2.0-alpha1+, `lib/api.ts:fetchDevices` returns
|
||||
every linked X25519 pub for a given identity; the sender then encrypts
|
||||
one envelope per device. Legacy nodes return an empty array and the
|
||||
client falls back to `IdentityInfo.x25519_pub`, preserving the
|
||||
pre-multi-device behaviour.
|
||||
175
desktop/electron/main.ts
Normal file
175
desktop/electron/main.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// Electron main process.
|
||||
//
|
||||
// Responsibilities:
|
||||
// * Create the BrowserWindow with a frameless + custom title bar so
|
||||
// the renderer owns the chrome (matches macOS traffic lights and
|
||||
// draws our 3-panel shell without OS padding).
|
||||
// * Bridge safe native APIs to the renderer through preload.ts using
|
||||
// contextBridge — keeps the renderer sandboxed (contextIsolation on,
|
||||
// nodeIntegration off).
|
||||
// * Deep-link handler for dchain://chat/<pub> and similar. Stub for now.
|
||||
//
|
||||
// Everything chain-related (HTTP / WS / crypto) still runs in the
|
||||
// renderer — Electron main stays a thin shell + native capabilities.
|
||||
|
||||
import { app, BrowserWindow, shell, ipcMain, dialog, safeStorage, session } from 'electron';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL;
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
// Content-Security-Policy is set here (not in <meta>) so we can diverge
|
||||
// dev vs. production: Vite's HMR uses eval() which needs 'unsafe-eval',
|
||||
// but shipping that in a release build would earn us a security warning
|
||||
// from Electron and weaken XSS defence for no good reason.
|
||||
function installCSP(): void {
|
||||
const policy = isDev
|
||||
? // Dev: permissive enough for Vite HMR (eval + WS) while still
|
||||
// denying random remote scripts. connect-src is wide-open because
|
||||
// the user picks their own node URL at runtime.
|
||||
"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; " +
|
||||
"connect-src 'self' ws: wss: http: https:; " +
|
||||
"img-src 'self' data: blob: http: https:;"
|
||||
: // Prod: no eval, no remote scripts. connect-src stays open so the
|
||||
// user can target any node they configure.
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"connect-src 'self' ws: wss: http: https:; " +
|
||||
"img-src 'self' data: blob: http: https:;";
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
|
||||
cb({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': [policy],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 820,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
backgroundColor: '#000000',
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
|
||||
// Expose traffic-light buttons on macOS; Windows/Linux use a custom
|
||||
// title-bar painted by the renderer.
|
||||
titleBarOverlay: process.platform === 'win32' ? {
|
||||
color: '#000000',
|
||||
symbolColor: '#ffffff',
|
||||
height: 32,
|
||||
} : undefined,
|
||||
frame: process.platform === 'darwin',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false, // safeStorage requires non-sandboxed preload
|
||||
},
|
||||
show: false,
|
||||
});
|
||||
|
||||
mainWindow.once('ready-to-show', () => mainWindow?.show());
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL!);
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||
}
|
||||
|
||||
// Open external links (http/https in <a target=_blank>) in the default
|
||||
// browser rather than a new Electron window — safer, and a desktop
|
||||
// user's muscle memory expects this.
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//.test(url)) {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
}
|
||||
return { action: 'allow' };
|
||||
});
|
||||
}
|
||||
|
||||
// ── IPC — safe subset bridged into the renderer via preload ────────────
|
||||
|
||||
// Keys are persisted encrypted by the OS keychain via safeStorage.
|
||||
// Fallback to plaintext file only if the user's OS lacks an encryption
|
||||
// backend (surfaced as a warning in Settings → Advanced).
|
||||
const KEYFILE_PATH = () => path.join(app.getPath('userData'), 'keyfile.bin');
|
||||
|
||||
ipcMain.handle('keyfile:load', async (): Promise<string | null> => {
|
||||
try {
|
||||
const raw = await fs.readFile(KEYFILE_PATH());
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
return safeStorage.decryptString(raw);
|
||||
}
|
||||
// File was stored without encryption — treat as plaintext.
|
||||
return raw.toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('keyfile:save', async (_e, json: string): Promise<void> => {
|
||||
await fs.mkdir(path.dirname(KEYFILE_PATH()), { recursive: true });
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
await fs.writeFile(KEYFILE_PATH(), safeStorage.encryptString(json));
|
||||
} else {
|
||||
// Surface the insecure path loudly in the renderer's Settings,
|
||||
// but don't refuse — on some Linux boxes libsecret isn't installed
|
||||
// and the user explicitly wants a fallback.
|
||||
await fs.writeFile(KEYFILE_PATH(), json, 'utf8');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('keyfile:delete', async (): Promise<void> => {
|
||||
await fs.rm(KEYFILE_PATH(), { force: true });
|
||||
});
|
||||
|
||||
ipcMain.handle('keyfile:encryption-available', async (): Promise<boolean> => {
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:open-file', async (_e, opts: Electron.OpenDialogOptions) => {
|
||||
if (!mainWindow) return null;
|
||||
const res = await dialog.showOpenDialog(mainWindow, opts);
|
||||
if (res.canceled || res.filePaths.length === 0) return null;
|
||||
return res.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:save-file', async (_e, opts: Electron.SaveDialogOptions) => {
|
||||
if (!mainWindow) return null;
|
||||
const res = await dialog.showSaveDialog(mainWindow, opts);
|
||||
if (res.canceled || !res.filePath) return null;
|
||||
return res.filePath;
|
||||
});
|
||||
|
||||
ipcMain.handle('fs:read-text', async (_e, filePath: string) => {
|
||||
return fs.readFile(filePath, 'utf8');
|
||||
});
|
||||
|
||||
ipcMain.handle('fs:write-text', async (_e, filePath: string, contents: string) => {
|
||||
return fs.writeFile(filePath, contents, 'utf8');
|
||||
});
|
||||
|
||||
ipcMain.handle('app:version', async () => app.getVersion());
|
||||
ipcMain.handle('app:platform', async () => process.platform);
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
app.whenReady().then(() => {
|
||||
installCSP();
|
||||
createWindow();
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
50
desktop/electron/preload.ts
Normal file
50
desktop/electron/preload.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Preload — the thin bridge between renderer and main.
|
||||
//
|
||||
// Everything exposed here is visible in the renderer as `window.dchain`.
|
||||
// We explicitly pick which IPC channels to surface rather than exposing
|
||||
// `ipcRenderer` wholesale, so a compromised renderer can't spam
|
||||
// arbitrary channels.
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
interface OpenDialogOptions {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
filters?: { name: string; extensions: string[] }[];
|
||||
properties?: ('openFile' | 'multiSelections')[];
|
||||
}
|
||||
|
||||
interface SaveDialogOptions {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
filters?: { name: string; extensions: string[] }[];
|
||||
}
|
||||
|
||||
const api = {
|
||||
keyfile: {
|
||||
load: (): Promise<string | null> => ipcRenderer.invoke('keyfile:load'),
|
||||
save: (json: string): Promise<void> => ipcRenderer.invoke('keyfile:save', json),
|
||||
delete: (): Promise<void> => ipcRenderer.invoke('keyfile:delete'),
|
||||
encryptionAvailable: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke('keyfile:encryption-available'),
|
||||
},
|
||||
dialog: {
|
||||
openFile: (opts: OpenDialogOptions): Promise<string | null> =>
|
||||
ipcRenderer.invoke('dialog:open-file', opts),
|
||||
saveFile: (opts: SaveDialogOptions): Promise<string | null> =>
|
||||
ipcRenderer.invoke('dialog:save-file', opts),
|
||||
},
|
||||
fs: {
|
||||
readText: (p: string): Promise<string> => ipcRenderer.invoke('fs:read-text', p),
|
||||
writeText: (p: string, c: string): Promise<void> =>
|
||||
ipcRenderer.invoke('fs:write-text', p, c),
|
||||
},
|
||||
app: {
|
||||
version: (): Promise<string> => ipcRenderer.invoke('app:version'),
|
||||
platform: (): Promise<string> => ipcRenderer.invoke('app:platform'),
|
||||
},
|
||||
};
|
||||
|
||||
export type DChainAPI = typeof api;
|
||||
|
||||
contextBridge.exposeInMainWorld('dchain', api);
|
||||
16
desktop/electron/tsconfig.json
Normal file
16
desktop/electron/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "../dist-electron",
|
||||
"rootDir": ".",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["main.ts", "preload.ts", "menu.ts"]
|
||||
}
|
||||
32
desktop/index.html
Normal file
32
desktop/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- CSP is applied at HTTP-response level from main.ts via
|
||||
session.webRequest — not in a <meta> here. Vite's dev server
|
||||
needs unsafe-eval for HMR, which breaks a strict meta-CSP at
|
||||
module-load time; setting CSP from main lets us flip
|
||||
dev vs. production rules cleanly. -->
|
||||
<title>DChain</title>
|
||||
<style>
|
||||
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
/* Let text fields + readable text be selectable despite global disable. */
|
||||
input, textarea, [contenteditable], .selectable {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
7540
desktop/package-lock.json
generated
Normal file
7540
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
desktop/package.json
Normal file
75
desktop/package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "dchain-desktop",
|
||||
"version": "2.2.0",
|
||||
"description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.",
|
||||
"private": true,
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n vite,electron -c blue,magenta \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && npm run electron:dev\"",
|
||||
"electron:dev": "npm run build:main && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron dist-electron/main.js",
|
||||
"build": "npm run build:main && vite build && electron-builder",
|
||||
"build:renderer": "vite build",
|
||||
"build:main": "tsc -p electron/tsconfig.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p electron/tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3",
|
||||
"wait-on": "^8.0.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.dchain.desktop",
|
||||
"productName": "DChain",
|
||||
"copyright": "Copyright © 2026 DChain contributors",
|
||||
"asar": true,
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*",
|
||||
"!**/*.map",
|
||||
"!**/node_modules/**/test/**",
|
||||
"!**/node_modules/**/tests/**"
|
||||
],
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "resources"
|
||||
},
|
||||
"mac": {
|
||||
"target": ["dmg", "zip"],
|
||||
"category": "public.app-category.social-networking",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false
|
||||
},
|
||||
"win": {
|
||||
"target": ["nsis", "portable"]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowElevation": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"linux": {
|
||||
"target": ["AppImage", "deb"],
|
||||
"category": "Network"
|
||||
},
|
||||
"publish": null
|
||||
}
|
||||
}
|
||||
65
desktop/src/App.tsx
Normal file
65
desktop/src/App.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
// Top-level component. Two responsibilities:
|
||||
// 1. Boot — load key + settings from storage, wire up the API client,
|
||||
// flip the booted flag so we stop showing the black splash.
|
||||
// 2. Render either the Welcome auth flow (no key yet) or the Shell
|
||||
// (3-panel layout + current section).
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
|
||||
import { setNodeUrl } from '@/lib/api';
|
||||
import { Shell } from '@/shell/Shell';
|
||||
import { Welcome } from '@/auth/Welcome';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const booted = useStore(s => s.booted);
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const [bootError, setBootError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const set = loadSettings();
|
||||
setNodeUrl(set.nodeUrl);
|
||||
useStore.getState().setSettings(set);
|
||||
|
||||
const cs = loadContacts();
|
||||
useStore.getState().setContacts(cs);
|
||||
|
||||
const kf = await loadKeyFile();
|
||||
useStore.getState().setKeyFile(kf);
|
||||
|
||||
useStore.getState().setBooted(true);
|
||||
} catch (err) {
|
||||
// Show the error inline — the boundary only catches render
|
||||
// throws, not async-effect throws like this one.
|
||||
setBootError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (bootError) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 24, color: '#ff6b6b', fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
<h2 style={{ color: '#ff6b6b', margin: 0 }}>Boot failed</h2>
|
||||
<p style={{ color: '#fff', marginTop: 8 }}>{bootError}</p>
|
||||
<p style={{ color: '#8b8b8b', fontSize: 12, marginTop: 12 }}>
|
||||
This usually means the Electron preload script didn't load.
|
||||
Check that `npm run build:main` has produced `dist-electron/preload.js`
|
||||
and restart `npm run dev`.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!booted) {
|
||||
// Matches the splash: whole window is already black from index.html,
|
||||
// so showing nothing is the right behaviour — no flash, no spinner.
|
||||
return <div style={{ height: '100%' }} />;
|
||||
}
|
||||
|
||||
return keyFile ? <Shell /> : <Welcome />;
|
||||
}
|
||||
55
desktop/src/ErrorBoundary.tsx
Normal file
55
desktop/src/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
// Top-level error boundary. React eats thrown errors silently by default,
|
||||
// which in an Electron app with no URL bar means "blank window, nothing
|
||||
// to click" from the user's perspective. This component at least shows
|
||||
// the error text + stack so we can copy-paste it into a bug report.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode }, State
|
||||
> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
// Surface the exception in the devtools console too, for quick
|
||||
// copy-paste when the boundary is blocking the UI.
|
||||
console.error('[ErrorBoundary]', error, info);
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (!this.state.error) return this.props.children;
|
||||
return (
|
||||
<div style={{
|
||||
padding: 24, height: '100%', overflow: 'auto',
|
||||
background: '#000', color: '#fff', fontFamily: 'monospace',
|
||||
}}>
|
||||
<h2 style={{ color: '#ff6b6b', marginTop: 0 }}>Something broke.</h2>
|
||||
<p style={{ color: '#fff' }}>{this.state.error.message}</p>
|
||||
<pre style={{
|
||||
color: '#8b8b8b', fontSize: 12, lineHeight: 1.4,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => this.setState({ error: null })}
|
||||
style={{
|
||||
marginTop: 12, padding: '8px 14px', borderRadius: 999,
|
||||
border: '1px solid #1f1f1f', background: '#111',
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
198
desktop/src/auth/Pair.tsx
Normal file
198
desktop/src/auth/Pair.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
// Pair screen — secondary-device onboarding on desktop.
|
||||
//
|
||||
// Same protocol as mobile's app/(auth)/pair.tsx:
|
||||
// 1. Generate a local X25519 keypair + random 6-digit code.
|
||||
// 2. Display them so the operator can transcribe onto their primary
|
||||
// device (mobile Settings → Devices → Link new device).
|
||||
// 3. Poll /relay/inbox every 2.5s waiting for a handshake envelope.
|
||||
// 4. On a decryptable payload with matching {v, type, code}, assemble
|
||||
// a KeyFile (master Ed25519 from the envelope + this session's
|
||||
// X25519 keypair) and persist — App then promotes us into Shell.
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import nacl from 'tweetnacl';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { bytesToHex, decryptMessage } from '@/lib/crypto';
|
||||
import { fetchInbox } from '@/lib/relay';
|
||||
import { saveKeyFile, markDeviceRegistered } from '@/lib/storage';
|
||||
import type { KeyFile } from '@/lib/types';
|
||||
|
||||
const PAIR_VERSION = 1;
|
||||
|
||||
interface PairPayload {
|
||||
v: number;
|
||||
type: 'pair-handshake';
|
||||
code: string;
|
||||
master_pub: string;
|
||||
master_priv: string;
|
||||
master_x25519_pub: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
x25519Pub: string;
|
||||
x25519Priv: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
function randomCode(): string {
|
||||
return Math.floor(Math.random() * 1_000_000).toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
function genSession(): Session {
|
||||
const kp = nacl.box.keyPair();
|
||||
return {
|
||||
x25519Pub: bytesToHex(kp.publicKey),
|
||||
x25519Priv: bytesToHex(kp.secretKey),
|
||||
code: randomCode(),
|
||||
};
|
||||
}
|
||||
|
||||
export function Pair({ onBack }: { onBack: () => void }): React.ReactElement {
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const session = useRef<Session>(genSession()).current;
|
||||
const [status, setStatus] = useState<'waiting' | 'success'>('waiting');
|
||||
|
||||
const copy = useCallback((text: string) => {
|
||||
navigator.clipboard?.writeText(text).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const envs = await fetchInbox(session.x25519Pub);
|
||||
for (const env of envs) {
|
||||
const plain = decryptMessage(
|
||||
env.ciphertext, env.nonce, env.sender_pub, session.x25519Priv,
|
||||
);
|
||||
if (!plain) continue;
|
||||
let payload: PairPayload;
|
||||
try { payload = JSON.parse(plain); } catch { continue; }
|
||||
if (
|
||||
payload.v !== PAIR_VERSION ||
|
||||
payload.type !== 'pair-handshake' ||
|
||||
payload.code !== session.code ||
|
||||
!payload.master_pub || !payload.master_priv
|
||||
) continue;
|
||||
|
||||
const kf: KeyFile = {
|
||||
pub_key: payload.master_pub,
|
||||
priv_key: payload.master_priv,
|
||||
x25519_pub: session.x25519Pub,
|
||||
x25519_priv: session.x25519Priv,
|
||||
};
|
||||
await saveKeyFile(kf);
|
||||
markDeviceRegistered();
|
||||
setKeyFile(kf);
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
} catch { /* next tick */ }
|
||||
if (!cancelled) timer = setTimeout(tick, 2_500);
|
||||
};
|
||||
|
||||
tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [session, setKeyFile]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
padding: 40, background: '#000', color: '#fff',
|
||||
}}>
|
||||
<div style={{ maxWidth: 440, width: '100%' }}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
marginBottom: 18, padding: '6px 10px', borderRadius: 999,
|
||||
background: 'transparent', color: '#8b8b8b', fontSize: 13,
|
||||
border: '1px solid #1f1f1f', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
|
||||
<h1 style={{ fontSize: 22, fontWeight: 800, margin: '0 0 8px' }}>
|
||||
Pair with your other device
|
||||
</h1>
|
||||
<p style={{ color: '#8b8b8b', fontSize: 13, margin: 0, lineHeight: 1.5 }}>
|
||||
On a device where you're already signed in, open
|
||||
Settings → Devices → Link new device and
|
||||
enter these two values.
|
||||
</p>
|
||||
|
||||
{/* Code */}
|
||||
<Card title="1. Code">
|
||||
<div style={{
|
||||
color: '#fff', fontFamily: 'monospace', fontSize: 34,
|
||||
fontWeight: 800, letterSpacing: 6, textAlign: 'center',
|
||||
}}>
|
||||
{session.code.slice(0, 3)} {session.code.slice(3)}
|
||||
</div>
|
||||
<CopyLink onClick={() => copy(session.code)}>Copy code</CopyLink>
|
||||
</Card>
|
||||
|
||||
{/* Device key */}
|
||||
<Card title="2. Device key">
|
||||
<div className="selectable" style={{
|
||||
color: '#fff', fontFamily: 'monospace', fontSize: 12,
|
||||
lineHeight: 1.5, wordBreak: 'break-all',
|
||||
}}>
|
||||
{session.x25519Pub}
|
||||
</div>
|
||||
<CopyLink onClick={() => copy(session.x25519Pub)}>Copy key</CopyLink>
|
||||
</Card>
|
||||
|
||||
{/* Status */}
|
||||
<div style={{
|
||||
marginTop: 18, textAlign: 'center',
|
||||
color: status === 'success' ? '#3ba55d' : '#8b8b8b',
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{status === 'waiting'
|
||||
? 'Waiting for your other device…'
|
||||
: 'Paired. Opening your chats…'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 18, padding: 16, borderRadius: 14,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1.2, textTransform: 'uppercase', marginBottom: 10,
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyLink({ children, onClick }: {
|
||||
children: React.ReactNode; onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
marginTop: 8, padding: 0, background: 'transparent',
|
||||
border: 'none', color: '#1d9bf0', fontSize: 12, fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>{children}</button>
|
||||
);
|
||||
}
|
||||
142
desktop/src/auth/Welcome.tsx
Normal file
142
desktop/src/auth/Welcome.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
// Welcome — shown when no key is loaded.
|
||||
//
|
||||
// Three options, matching mobile parity:
|
||||
// * Create — generate a new Ed25519 + X25519 keypair.
|
||||
// * Import — load node.json file (dialog).
|
||||
// * Pair — pair with an existing phone/desktop (QR-less, 6-digit code
|
||||
// + device key, symmetrical with mobile's /auth/pair flow).
|
||||
//
|
||||
// v2.2.0-alpha4 wires the first two functionally and stubs Pair with a
|
||||
// button that routes to a placeholder — the pairing poll loop shared
|
||||
// with mobile comes in alpha5.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { saveKeyFile } from '@/lib/storage';
|
||||
import { generateKeyFile } from '@/lib/crypto';
|
||||
import type { KeyFile } from '@/lib/types';
|
||||
import { Pair } from './Pair';
|
||||
|
||||
export function Welcome(): React.ReactElement {
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [screen, setScreen] = useState<'welcome' | 'pair'>('welcome');
|
||||
|
||||
if (screen === 'pair') return <Pair onBack={() => setScreen('welcome')} />;
|
||||
|
||||
const onCreate = async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const kf = generateKeyFile();
|
||||
await saveKeyFile(kf);
|
||||
setKeyFile(kf);
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onImport = async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const file = await window.dchain.dialog.openFile({
|
||||
title: 'Select node.json',
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
if (!file) return;
|
||||
const contents = await window.dchain.fs.readText(file);
|
||||
const parsed = JSON.parse(contents) as KeyFile;
|
||||
if (!parsed.pub_key || !parsed.priv_key) {
|
||||
throw new Error('file doesn\'t look like a key file');
|
||||
}
|
||||
await saveKeyFile(parsed);
|
||||
setKeyFile(parsed);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPair = () => {
|
||||
setErr(null);
|
||||
setScreen('pair');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
padding: 40, background: '#000', color: '#fff',
|
||||
}}>
|
||||
<div style={{ maxWidth: 400, width: '100%', textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: 80, height: 80, borderRadius: 22,
|
||||
background: '#1d9bf0', margin: '0 auto',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 36, fontWeight: 800,
|
||||
}}>
|
||||
D
|
||||
</div>
|
||||
<h1 style={{ fontSize: 30, fontWeight: 800, letterSpacing: -0.5, margin: '16px 0 6px' }}>
|
||||
DChain
|
||||
</h1>
|
||||
<p style={{ color: '#8b8b8b', fontSize: 14, margin: 0, lineHeight: 1.5 }}>
|
||||
Decentralised messenger + social feed. Your keys stay on this device.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 32 }}>
|
||||
<PrimaryBtn label="Create account" onClick={onCreate} disabled={busy} />
|
||||
<SecondaryBtn label="Import key file" onClick={onImport} disabled={busy} />
|
||||
<SecondaryBtn label="Pair with another device" onClick={onPair} disabled={busy} />
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div style={{
|
||||
marginTop: 20, padding: 10, borderRadius: 10,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||
textAlign: 'left',
|
||||
}}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryBtn({ label, onClick, disabled }: {
|
||||
label: string; onClick: () => void; disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
height: 46, borderRadius: 999, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff', fontSize: 14, fontWeight: 700,
|
||||
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
>{label}</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryBtn({ label, onClick, disabled }: {
|
||||
label: string; onClick: () => void; disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
height: 46, borderRadius: 999,
|
||||
background: '#0a0a0a', color: '#fff', fontSize: 14, fontWeight: 700,
|
||||
border: '1px solid #1f1f1f',
|
||||
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
>{label}</button>
|
||||
);
|
||||
}
|
||||
62
desktop/src/hooks/useGlobalKeybinds.ts
Normal file
62
desktop/src/hooks/useGlobalKeybinds.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Global keyboard shortcuts. Mounted at Shell.tsx so they work regardless
|
||||
// of which section is active. The section-switching bindings
|
||||
// (Ctrl/Cmd+1..5, +Settings) live in NavBar — they predate this file and
|
||||
// stay there because they're tightly coupled to the nav data structure.
|
||||
//
|
||||
// Every shortcut below:
|
||||
// * Skips itself when focus is inside a text input / textarea (so typing
|
||||
// in Compose doesn't accidentally fire app-level actions).
|
||||
// * preventDefault()'s to suppress the browser/Electron default (e.g.
|
||||
// Ctrl+W would otherwise close the whole window).
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
function inTextField(el: EventTarget | null): boolean {
|
||||
const n = el as HTMLElement | null;
|
||||
if (!n) return false;
|
||||
const tag = n.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || n.isContentEditable === true;
|
||||
}
|
||||
|
||||
export function useGlobalKeybinds(): void {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Ctrl/Cmd+W — close the current conversation (drop activeChat);
|
||||
// if no chat is open, no-op. We do not close the window, because
|
||||
// that's too abrupt for an app the user usually keeps running.
|
||||
if (mod && e.key.toLowerCase() === 'w') {
|
||||
const { section, activeChat, setActiveChat } = useStore.getState();
|
||||
if (section === 'messages' && activeChat) {
|
||||
e.preventDefault();
|
||||
setActiveChat(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+K — jump to Contacts with the search focused. The focus
|
||||
// itself is delegated to the Contacts component via a signal; for
|
||||
// now we just switch the section and rely on Contacts' autofocus
|
||||
// pattern (text input comes from memo'd ref in list pane).
|
||||
if (mod && e.key.toLowerCase() === 'k') {
|
||||
if (inTextField(e.target)) return;
|
||||
e.preventDefault();
|
||||
useStore.getState().setSection('contacts');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+, — Settings.
|
||||
if (mod && e.key === ',') {
|
||||
if (inTextField(e.target)) return;
|
||||
e.preventDefault();
|
||||
useStore.getState().setSection('settings');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
}
|
||||
117
desktop/src/hooks/useInboxPoll.ts
Normal file
117
desktop/src/hooks/useInboxPoll.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// useInboxPoll — polls GET /relay/inbox for *every* X25519 pub this
|
||||
// device owns (master identity + every linked device). In v2.2.0, senders
|
||||
// fan out one envelope per recipient device, so we need to read all of
|
||||
// them on our side to see messages that were addressed to any of our pubs.
|
||||
//
|
||||
// Poll interval is 4 seconds — desktop is typically always-on, we can
|
||||
// afford this cadence. A WebSocket-based push path is a polish pass away;
|
||||
// for alpha5 the polling loop is plenty responsive.
|
||||
//
|
||||
// Every newly-arrived envelope is:
|
||||
// 1. Decrypted with our X25519 priv + sender's pub (from envelope metadata).
|
||||
// 2. Parsed — today as JSON "pair-handshake" or plain text; group chats
|
||||
// and encrypted payloads with attachments come in later alphas.
|
||||
// 3. Routed: plain text → store.appendMessage + disk; anything we can't
|
||||
// parse is skipped silently (future clients will extend the protocol).
|
||||
//
|
||||
// We keep a local "seen" set keyed by envelope.id so a second poll cycle
|
||||
// doesn't re-deliver an already-consumed envelope while it sits in the
|
||||
// relay mailbox waiting for TTL.
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { fetchInbox, type Envelope } from '@/lib/relay';
|
||||
import { decryptMessage } from '@/lib/crypto';
|
||||
import { appendMessage as persistMessage, upsertContact as persistContact } from '@/lib/storage';
|
||||
import type { Message } from '@/lib/types';
|
||||
|
||||
const POLL_MS = 4_000;
|
||||
|
||||
export function useInboxPoll(): void {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const activeChat = useStore(s => s.activeChat);
|
||||
|
||||
// Ref-based so the tick closure sees the latest set without re-running
|
||||
// the whole effect every time a new envelope arrives.
|
||||
const seen = useRef<Set<string>>(new Set());
|
||||
const activeChatRef = useRef<string | null>(activeChat);
|
||||
useEffect(() => { activeChatRef.current = activeChat; }, [activeChat]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const envs = await fetchInbox(keyFile.x25519_pub);
|
||||
if (cancelled) return;
|
||||
for (const env of envs) {
|
||||
if (seen.current.has(env.id)) continue;
|
||||
seen.current.add(env.id);
|
||||
consume(env, keyFile.x25519_priv, activeChatRef.current);
|
||||
}
|
||||
} catch {
|
||||
// transient — try again next tick
|
||||
}
|
||||
if (!cancelled) timer = setTimeout(tick, POLL_MS);
|
||||
};
|
||||
|
||||
tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [keyFile]);
|
||||
}
|
||||
|
||||
function consume(env: Envelope, myX25519Priv: string, activeChat: string | null): void {
|
||||
const plain = decryptMessage(env.ciphertext, env.nonce, env.sender_pub, myX25519Priv);
|
||||
if (plain === null) return; // not for us / garbage / rotated keys
|
||||
|
||||
// Skip handshake envelopes — the /auth pair flow consumes those
|
||||
// separately before any chat is mounted.
|
||||
if (plain.startsWith('{') && plain.includes('"type":"pair-handshake"')) return;
|
||||
|
||||
// Conversation address = sender's master Ed25519 identity (v2.2.0+).
|
||||
// The envelope now carries this explicitly in `sender_ed25519_pub`,
|
||||
// so a reply from a different linked device still rolls into the
|
||||
// same chat. Pre-v2.2.0 senders leave the field empty; we fall back
|
||||
// to `sender_pub` (the per-device X25519) so legacy peers still
|
||||
// appear as contacts — they'll just be addressed by X25519 until
|
||||
// they upgrade.
|
||||
const from = env.sender_ed25519_pub || env.sender_pub;
|
||||
|
||||
const st = useStore.getState();
|
||||
|
||||
// Create a placeholder contact if we've never seen this peer —
|
||||
// mirrors mobile's behaviour.
|
||||
if (!st.contacts.some(c => c.address === from)) {
|
||||
const c = {
|
||||
address: from,
|
||||
x25519Pub: from,
|
||||
alias: undefined,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
st.upsertContact(c);
|
||||
persistContact(c);
|
||||
}
|
||||
|
||||
const msg: Message = {
|
||||
id: env.id,
|
||||
from: env.sender_pub,
|
||||
text: plain,
|
||||
timestamp: env.timestamp,
|
||||
mine: false,
|
||||
read: false,
|
||||
edited: false,
|
||||
};
|
||||
st.appendMessage(from, msg);
|
||||
persistMessage(from, msg);
|
||||
|
||||
// Only surface an unread badge if the recipient isn't already
|
||||
// looking at this conversation.
|
||||
if (activeChat !== from) {
|
||||
st.bumpUnread(from);
|
||||
}
|
||||
}
|
||||
100
desktop/src/hooks/useUpdateCheck.ts
Normal file
100
desktop/src/hooks/useUpdateCheck.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// useUpdateCheck — polls the configured node's /api/update-check once
|
||||
// per launch (+ every 6h while the window stays open), compares the
|
||||
// Gitea release tag against this client's app version, and exposes
|
||||
// { latest, url } when ours is older.
|
||||
//
|
||||
// Why reuse the node endpoint? The DChain node already fetches Gitea
|
||||
// releases on behalf of its operator; piggybacking on the same cached
|
||||
// JSON means the desktop client doesn't need a direct Gitea token or
|
||||
// a separate update feed. One source of truth, no new infra.
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { get } from '@/lib/api';
|
||||
|
||||
interface UpdateCheck {
|
||||
current?: { tag?: string };
|
||||
latest?: { tag?: string; commit?: string; url?: string; published_at?: string };
|
||||
update_available?: boolean;
|
||||
source?: string;
|
||||
checked_at?: string;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
latestTag: string;
|
||||
url: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export function useUpdateCheck(): UpdateInfo | null {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
// Our version (set in package.json, baked into Electron at build time).
|
||||
const myVersion = (await window.dchain.app.version()).trim();
|
||||
const r = await get<UpdateCheck>('/api/update-check');
|
||||
if (cancelled) return;
|
||||
const latest = r.latest?.tag?.trim() ?? '';
|
||||
if (!latest || !r.latest?.url) { setInfo(null); return; }
|
||||
// Compare semver-ish. The node's own `update_available` flag
|
||||
// compares vs. the NODE's version, not ours, so we re-derive.
|
||||
if (isNewer(latest, myVersion)) {
|
||||
setInfo({
|
||||
latestTag: latest,
|
||||
url: r.latest.url,
|
||||
publishedAt: r.latest.published_at ?? '',
|
||||
});
|
||||
} else {
|
||||
setInfo(null);
|
||||
}
|
||||
} catch {
|
||||
// Node doesn't have the endpoint configured, or offline — quiet fail.
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
const t = setInterval(tick, 6 * 60 * 60 * 1000);
|
||||
return () => { cancelled = true; clearInterval(t); };
|
||||
}, []);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* isNewer — loose semver compare for strings like `v2.2.0` / `2.2.0-rc1`.
|
||||
* Strips leading `v`, splits on dots and the first `-` (pre-release
|
||||
* suffix), compares numerically left-to-right. Pre-release tags are
|
||||
* considered OLDER than the bare version (so `2.2.0 > 2.2.0-rc1`).
|
||||
* Not a full semver implementation — good enough to decide whether to
|
||||
* show the "update available" badge. If our parse fails, we assume no
|
||||
* update (safer than nagging users with false positives).
|
||||
*/
|
||||
export function isNewer(candidate: string, reference: string): boolean {
|
||||
const a = parseVersion(candidate);
|
||||
const b = parseVersion(reference);
|
||||
if (!a || !b) return false;
|
||||
for (let i = 0; i < Math.max(a.nums.length, b.nums.length); i++) {
|
||||
const x = a.nums[i] ?? 0;
|
||||
const y = b.nums[i] ?? 0;
|
||||
if (x !== y) return x > y;
|
||||
}
|
||||
// All numeric parts equal → compare pre-release. `""` (stable) beats any suffix.
|
||||
if (a.pre === b.pre) return false;
|
||||
if (a.pre === '') return true; // stable > prerelease
|
||||
if (b.pre === '') return false; // prerelease < stable
|
||||
return a.pre > b.pre; // alpha6 > alpha5 lexically, fine in practice
|
||||
}
|
||||
|
||||
function parseVersion(v: string): { nums: number[]; pre: string } | null {
|
||||
if (!v) return null;
|
||||
const clean = v.trim().replace(/^v/i, '');
|
||||
const dash = clean.indexOf('-');
|
||||
const head = dash >= 0 ? clean.slice(0, dash) : clean;
|
||||
const pre = dash >= 0 ? clean.slice(dash + 1) : '';
|
||||
const nums = head.split('.').map(s => parseInt(s, 10));
|
||||
if (nums.some(n => !Number.isFinite(n))) return null;
|
||||
return { nums, pre };
|
||||
}
|
||||
225
desktop/src/lib/api.ts
Normal file
225
desktop/src/lib/api.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// Minimal API client for the scaffold. Mirrors the mobile client-app's
|
||||
// lib/api.ts semantics (endpoints, wire shapes) so the two can hit the
|
||||
// same node. As we grow the desktop client, more methods move in here;
|
||||
// for now we only need net-stats + identity + devices + submit-tx +
|
||||
// broadcast-envelope + inbox to drive the shell + pairing.
|
||||
|
||||
const DEFAULT_URL = 'http://localhost:8080';
|
||||
let nodeUrl = DEFAULT_URL;
|
||||
let apiToken: string | null = null;
|
||||
|
||||
const listeners: ((url: string) => void)[] = [];
|
||||
|
||||
export function setNodeUrl(url: string): void {
|
||||
nodeUrl = url.replace(/\/$/, '') || DEFAULT_URL;
|
||||
listeners.forEach(fn => fn(nodeUrl));
|
||||
}
|
||||
|
||||
export function getNodeUrl(): string {
|
||||
return nodeUrl;
|
||||
}
|
||||
|
||||
export function onNodeUrlChange(fn: (url: string) => void): () => void {
|
||||
listeners.push(fn);
|
||||
return () => {
|
||||
const i = listeners.indexOf(fn);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
export function setApiToken(t: string | null): void { apiToken = t; }
|
||||
|
||||
function headers(): HeadersInit {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (apiToken) h['Authorization'] = `Bearer ${apiToken}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function parse<T>(resp: Response): Promise<T> {
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '');
|
||||
throw new Error(`${resp.status} ${resp.statusText} → ${body.slice(0, 200)}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function get<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${nodeUrl}${path}`, { headers: headers() });
|
||||
return parse<T>(resp);
|
||||
}
|
||||
|
||||
export async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
const resp = await fetch(`${nodeUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parse<T>(resp);
|
||||
}
|
||||
|
||||
// ─── Thin wrappers for the shell ─────────────────────────────────────────
|
||||
|
||||
export interface NetStats {
|
||||
total_blocks: number;
|
||||
total_txs: number;
|
||||
total_supply: number;
|
||||
validator_count: number;
|
||||
relay_count: number;
|
||||
}
|
||||
|
||||
export async function getNetStats(): Promise<NetStats> {
|
||||
return get<NetStats>('/api/netstats');
|
||||
}
|
||||
|
||||
export interface IdentityInfo {
|
||||
pub_key: string;
|
||||
address: string;
|
||||
x25519_pub: string;
|
||||
nickname: string;
|
||||
registered: boolean;
|
||||
device_count?: number;
|
||||
}
|
||||
|
||||
export async function getIdentity(pub: string): Promise<IdentityInfo | null> {
|
||||
try { return await get<IdentityInfo>(`/api/identity/${pub}`); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
x25519_pub_key: string;
|
||||
device_name: string;
|
||||
added_at: number;
|
||||
}
|
||||
|
||||
interface DevicesResponse {
|
||||
master_pub: string;
|
||||
count: number;
|
||||
devices: DeviceInfo[];
|
||||
}
|
||||
|
||||
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
|
||||
try {
|
||||
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
|
||||
return resp.devices ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBalance(pub: string): Promise<number> {
|
||||
try {
|
||||
const r = await get<{ balance_ut: number }>(`/api/address/${pub}`);
|
||||
return r.balance_ut ?? 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
// ─── Wallet / transactions ───────────────────────────────────────────────
|
||||
|
||||
/** Raw tx row as it appears in /api/address/{pub}.transactions[]. */
|
||||
export interface TxRow {
|
||||
id: string;
|
||||
type: string;
|
||||
from: string;
|
||||
from_addr?: string;
|
||||
to?: string;
|
||||
to_addr?: string;
|
||||
amount_ut: number;
|
||||
fee_ut: number;
|
||||
time: string; // ISO-8601 UTC
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
interface AddressResponse {
|
||||
address: string;
|
||||
pub_key: string;
|
||||
balance_ut: number;
|
||||
transactions?: TxRow[];
|
||||
}
|
||||
|
||||
/** Full tx detail, matches node/api_explorer.go::apiTxByID shape. */
|
||||
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;
|
||||
block_index: number;
|
||||
block_hash: string;
|
||||
block_time: string;
|
||||
gas_used?: number;
|
||||
payload?: unknown;
|
||||
payload_hex?: string;
|
||||
signature_hex?: string;
|
||||
}
|
||||
|
||||
export async function getTxHistory(pub: string, limit = 100): Promise<TxRow[]> {
|
||||
try {
|
||||
const r = await get<AddressResponse>(`/api/address/${pub}?limit=${limit}`);
|
||||
return r.transactions ?? [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function getTxDetail(txID: string): Promise<TxDetail | null> {
|
||||
try {
|
||||
return await get<TxDetail>(`/api/tx/${txID}`);
|
||||
} catch (e) {
|
||||
if (/→\s*404\b/.test(String((e as Error).message))) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Contact requests (on-chain, via /relay/contacts) ───────────────────
|
||||
|
||||
export interface ContactRequestRaw {
|
||||
requester_pub: string;
|
||||
requester_addr: string;
|
||||
status: string; // "pending" | "accepted" | "blocked"
|
||||
intro: string;
|
||||
fee_ut: number;
|
||||
tx_id: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /relay/contacts?pub=<ed25519> — returns every on-chain
|
||||
* CONTACT_REQUEST addressed to `pub`, regardless of status. The UI
|
||||
* filters by pending before showing.
|
||||
*/
|
||||
export async function fetchContactRequests(edPub: string): Promise<ContactRequestRaw[]> {
|
||||
try {
|
||||
const r = await get<{ contacts?: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPub}`);
|
||||
return r.contacts ?? [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** Resolve a DC address or @username into an Ed25519 pub (hex). */
|
||||
export async function resolveAccount(input: string): Promise<string | null> {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
// Already a hex pub.
|
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
// @username — go through the username registry.
|
||||
if (trimmed.startsWith('@')) {
|
||||
try {
|
||||
const r = await get<{ pub_key?: string }>(
|
||||
`/api/contract/call?id=native:username_registry&method=resolve&arg=${encodeURIComponent(trimmed.slice(1))}`,
|
||||
);
|
||||
return r.pub_key ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
// DC… address — ask the explorer.
|
||||
if (trimmed.startsWith('DC')) {
|
||||
try {
|
||||
const r = await get<{ pub_key?: string }>(`/api/address/${trimmed}`);
|
||||
return r.pub_key ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
93
desktop/src/lib/crypto.ts
Normal file
93
desktop/src/lib/crypto.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Crypto primitives. Mirrors client-app/lib/crypto.ts function-for-
|
||||
// function (same signatures, same hex/base64 formats on the wire) so
|
||||
// the two clients decrypt each other's envelopes and sign txs the node
|
||||
// accepts interchangeably.
|
||||
//
|
||||
// The only real difference from mobile: we don't need expo-crypto — the
|
||||
// Electron renderer is a Chromium browser, so window.crypto.getRandomValues
|
||||
// is always available and we just let tweetnacl pick it up on its own
|
||||
// (tweetnacl auto-detects window.crypto when present).
|
||||
|
||||
import nacl from 'tweetnacl';
|
||||
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
|
||||
import type { KeyFile } from './types';
|
||||
|
||||
// ─── Hex / base64 ────────────────────────────────────────────────────────
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
if (hex.length % 2 !== 0) throw new Error('odd hex length');
|
||||
const b = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return b;
|
||||
}
|
||||
export function bytesToHex(b: Uint8Array): string {
|
||||
return Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
export function bytesToBase64(b: Uint8Array): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Key generation ──────────────────────────────────────────────────────
|
||||
|
||||
export function generateKeyFile(): KeyFile {
|
||||
const sign = nacl.sign.keyPair();
|
||||
const box = nacl.box.keyPair();
|
||||
return {
|
||||
pub_key: bytesToHex(sign.publicKey),
|
||||
priv_key: bytesToHex(sign.secretKey),
|
||||
x25519_pub: bytesToHex(box.publicKey),
|
||||
x25519_priv: bytesToHex(box.secretKey),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── NaCl box (E2E messaging) ────────────────────────────────────────────
|
||||
|
||||
export function encryptMessage(
|
||||
plaintext: string,
|
||||
senderSecretHex: string,
|
||||
recipientPubHex: string,
|
||||
): { nonce: string; ciphertext: string } {
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||
const msg = decodeUTF8(plaintext);
|
||||
const box = nacl.box(msg, nonce, hexToBytes(recipientPubHex), hexToBytes(senderSecretHex));
|
||||
return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(box) };
|
||||
}
|
||||
|
||||
export function decryptMessage(
|
||||
ciphertextHex: string,
|
||||
nonceHex: string,
|
||||
senderPubHex: string,
|
||||
recipientSecHex: string,
|
||||
): string | null {
|
||||
try {
|
||||
const plain = nacl.box.open(
|
||||
hexToBytes(ciphertextHex), hexToBytes(nonceHex),
|
||||
hexToBytes(senderPubHex), hexToBytes(recipientSecHex),
|
||||
);
|
||||
return plain ? encodeUTF8(plain) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Ed25519 signing ─────────────────────────────────────────────────────
|
||||
|
||||
export function signBase64(data: Uint8Array, privKeyHex: string): string {
|
||||
const sig = nacl.sign.detached(data, hexToBytes(privKeyHex));
|
||||
return bytesToBase64(sig);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function shortAddr(hex: string, chars = 8): string {
|
||||
if (hex.length <= chars * 2 + 3) return hex;
|
||||
return `${hex.slice(0, chars)}…${hex.slice(-chars)}`;
|
||||
}
|
||||
302
desktop/src/lib/feed.ts
Normal file
302
desktop/src/lib/feed.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
// Feed API + tx builders for the desktop client.
|
||||
//
|
||||
// Mirrors client-app/lib/feed.ts. Same wire formats on /feed/*, same
|
||||
// canonical-bytes for tx signatures. The only platform-specific diff
|
||||
// is the SHA-256 source — we use window.crypto.subtle (Chromium/Electron)
|
||||
// instead of expo-crypto.
|
||||
|
||||
import { get, getNodeUrl, post } from './api';
|
||||
import {
|
||||
bytesToBase64, bytesToHex, hexToBytes, signBase64,
|
||||
} from './crypto';
|
||||
import { submitTx, type RawTx } from './tx';
|
||||
|
||||
const MIN_TX_FEE = 1_000;
|
||||
const _encoder = new TextEncoder();
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FeedPostItem {
|
||||
post_id: string;
|
||||
author: string; // hex Ed25519
|
||||
content: string;
|
||||
content_type?: string;
|
||||
hashtags?: string[];
|
||||
reply_to?: string;
|
||||
quote_of?: string;
|
||||
created_at: number; // unix seconds
|
||||
size: number;
|
||||
hosting_relay: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
has_attachment: boolean;
|
||||
}
|
||||
|
||||
export interface PostStats {
|
||||
post_id: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
liked_by_me?: boolean;
|
||||
}
|
||||
|
||||
export interface PublishResponse {
|
||||
post_id: string;
|
||||
hosting_relay: string;
|
||||
content_hash: string;
|
||||
size: number;
|
||||
hashtags: string[];
|
||||
estimated_fee_ut: number;
|
||||
}
|
||||
|
||||
interface TimelineResponse {
|
||||
count: number;
|
||||
posts: FeedPostItem[];
|
||||
}
|
||||
|
||||
// ─── Reads ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchForYou(pub: string, limit = 30): Promise<FeedPostItem[]> {
|
||||
const r = await get<TimelineResponse>(`/feed/foryou?pub=${pub}&limit=${limit}`);
|
||||
return r.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchTrending(windowHours = 24, limit = 30): Promise<FeedPostItem[]> {
|
||||
const r = await get<TimelineResponse>(`/feed/trending?window=${windowHours}&limit=${limit}`);
|
||||
return r.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchAuthorPosts(
|
||||
pub: string, opts: { limit?: number; before?: number } = {},
|
||||
): Promise<FeedPostItem[]> {
|
||||
const limit = opts.limit ?? 30;
|
||||
const qs = opts.before
|
||||
? `?limit=${limit}&before=${opts.before}`
|
||||
: `?limit=${limit}`;
|
||||
const r = await get<TimelineResponse>(`/feed/author/${pub}${qs}`);
|
||||
return r.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchTimeline(
|
||||
followerPub: string, opts: { limit?: number; before?: number } = {},
|
||||
): Promise<FeedPostItem[]> {
|
||||
const limit = opts.limit ?? 30;
|
||||
let qs = `?follower=${followerPub}&limit=${limit}`;
|
||||
if (opts.before) qs += `&before=${opts.before}`;
|
||||
const r = await get<TimelineResponse>(`/feed/timeline${qs}`);
|
||||
return r.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchHashtag(tag: string, limit = 30): Promise<FeedPostItem[]> {
|
||||
const clean = tag.replace(/^#/, '');
|
||||
const r = await get<TimelineResponse>(`/feed/hashtag/${encodeURIComponent(clean)}?limit=${limit}`);
|
||||
return r.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchPost(postID: string): Promise<FeedPostItem | null> {
|
||||
try { return await get<FeedPostItem>(`/feed/post/${postID}`); }
|
||||
catch (e) {
|
||||
const m = String((e as Error).message);
|
||||
if (/→\s*(404|410)\b/.test(m)) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchStats(postID: string, me?: string): Promise<PostStats | null> {
|
||||
try {
|
||||
const path = me
|
||||
? `/feed/post/${postID}/stats?me=${me}`
|
||||
: `/feed/post/${postID}/stats`;
|
||||
return await get<PostStats>(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bump the off-chain view counter. Fire-and-forget. */
|
||||
export async function bumpView(postID: string): Promise<void> {
|
||||
try {
|
||||
await post<unknown>(`/feed/post/${postID}/view`, undefined);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Tx helpers (shared style with lib/tx.ts) ────────────────────────────
|
||||
|
||||
function rfc3339Now(): string {
|
||||
const d = new Date();
|
||||
d.setMilliseconds(0);
|
||||
return d.toISOString().replace('.000Z', 'Z');
|
||||
}
|
||||
function newTxID(): string {
|
||||
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
function canonicalBytes(tx: {
|
||||
id: string; type: string; from: string; to: string;
|
||||
amount: number; fee: number; payload: string; timestamp: string;
|
||||
}): Uint8Array {
|
||||
return _encoder.encode(JSON.stringify({
|
||||
id: tx.id, type: tx.type, from: tx.from, to: tx.to,
|
||||
amount: tx.amount, fee: tx.fee, payload: tx.payload, timestamp: tx.timestamp,
|
||||
}));
|
||||
}
|
||||
function strToBase64(s: string): string {
|
||||
return bytesToBase64(_encoder.encode(s));
|
||||
}
|
||||
|
||||
// ─── SHA-256 via WebCrypto ───────────────────────────────────────────────
|
||||
|
||||
async function sha256Hex(s: string): Promise<string> {
|
||||
const buf = await window.crypto.subtle.digest(
|
||||
'SHA-256', _encoder.encode(s),
|
||||
);
|
||||
return bytesToHex(new Uint8Array(buf));
|
||||
}
|
||||
|
||||
/** 16-byte (32-hex-char) post ID derived from author + entropy + content. */
|
||||
async function computePostID(author: string, content: string): Promise<string> {
|
||||
const seed = `${author}-${Date.now()}${Math.floor(Math.random() * 1e9)}-${content.slice(0, 64)}`;
|
||||
const hex = await sha256Hex(seed);
|
||||
return hex.slice(0, 32);
|
||||
}
|
||||
|
||||
// ─── Tx builders ─────────────────────────────────────────────────────────
|
||||
|
||||
export function buildCreatePostTx(p: {
|
||||
from: string; privKey: string;
|
||||
postID: string; contentHash: string; size: number;
|
||||
hostingRelay: string; fee: number;
|
||||
replyTo?: string; quoteOf?: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify({
|
||||
post_id: p.postID,
|
||||
content_hash: bytesToBase64(hexToBytes(p.contentHash)),
|
||||
size: p.size,
|
||||
hosting_relay: p.hostingRelay,
|
||||
reply_to: p.replyTo ?? '',
|
||||
quote_of: p.quoteOf ?? '',
|
||||
}));
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'CREATE_POST', from: p.from, to: '',
|
||||
amount: 0, fee: p.fee, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'CREATE_POST', from: p.from, to: '',
|
||||
amount: 0, fee: p.fee, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
function simpleTx(type: string, payloadObj: unknown, from: string, to: string, privKey: string): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||
const canon = canonicalBytes({ id, type, from, to, amount: 0, fee: MIN_TX_FEE, payload, timestamp });
|
||||
return {
|
||||
id, type, from, to, amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canon, privKey),
|
||||
};
|
||||
}
|
||||
|
||||
export const buildLikePostTx = (p: { from: string; privKey: string; postID: string }) =>
|
||||
simpleTx('LIKE_POST', { post_id: p.postID }, p.from, '', p.privKey);
|
||||
export const buildUnlikePostTx = (p: { from: string; privKey: string; postID: string }) =>
|
||||
simpleTx('UNLIKE_POST', { post_id: p.postID }, p.from, '', p.privKey);
|
||||
export const buildDeletePostTx = (p: { from: string; privKey: string; postID: string }) =>
|
||||
simpleTx('DELETE_POST', { post_id: p.postID }, p.from, '', p.privKey);
|
||||
export const buildFollowTx = (p: { from: string; privKey: string; target: string }) =>
|
||||
simpleTx('FOLLOW', {}, p.from, p.target, p.privKey);
|
||||
export const buildUnfollowTx = (p: { from: string; privKey: string; target: string }) =>
|
||||
simpleTx('UNFOLLOW', {}, p.from, p.target, p.privKey);
|
||||
|
||||
// ─── Publish flow ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /feed/publish with a plaintext body, server scrubs image metadata,
|
||||
* returns the final hosting_relay + content_hash + estimated fee we need
|
||||
* to commit the matching CREATE_POST tx.
|
||||
*/
|
||||
export async function publishPost(p: {
|
||||
author: string; privKey: string; content: string;
|
||||
contentType?: string;
|
||||
attachmentBytes?: Uint8Array;
|
||||
attachmentMIME?: string;
|
||||
replyTo?: string; quoteOf?: string;
|
||||
}): Promise<PublishResponse> {
|
||||
const postID = await computePostID(p.author, p.content);
|
||||
const clientHash = await sha256HexBytes(p.content, p.attachmentBytes);
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const sig = signBase64(
|
||||
_encoder.encode(`publish:${postID}:${clientHash}:${ts}`),
|
||||
p.privKey,
|
||||
);
|
||||
return post<PublishResponse>('/feed/publish', {
|
||||
post_id: postID,
|
||||
author: p.author,
|
||||
content: p.content,
|
||||
content_type: p.contentType ?? 'text/plain',
|
||||
attachment_b64: p.attachmentBytes ? bytesToBase64(p.attachmentBytes) : undefined,
|
||||
attachment_mime: p.attachmentMIME,
|
||||
reply_to: p.replyTo,
|
||||
quote_of: p.quoteOf,
|
||||
sig,
|
||||
ts,
|
||||
});
|
||||
}
|
||||
|
||||
async function sha256HexBytes(content: string, attachment?: Uint8Array): Promise<string> {
|
||||
const contentBytes = _encoder.encode(content);
|
||||
const total = new Uint8Array(contentBytes.length + (attachment?.length ?? 0));
|
||||
total.set(contentBytes, 0);
|
||||
if (attachment) total.set(attachment, contentBytes.length);
|
||||
const buf = await window.crypto.subtle.digest('SHA-256', total);
|
||||
return bytesToHex(new Uint8Array(buf));
|
||||
}
|
||||
|
||||
/**
|
||||
* Full publish flow: POST /feed/publish → submit matching CREATE_POST tx.
|
||||
* Returns the committed post_id.
|
||||
*/
|
||||
export async function publishAndCommit(p: {
|
||||
author: string; privKey: string; content: string;
|
||||
attachmentBytes?: Uint8Array; attachmentMIME?: string;
|
||||
replyTo?: string; quoteOf?: string;
|
||||
}): Promise<string> {
|
||||
const pub = await publishPost(p);
|
||||
const tx = buildCreatePostTx({
|
||||
from: p.author,
|
||||
privKey: p.privKey,
|
||||
postID: pub.post_id,
|
||||
contentHash: pub.content_hash,
|
||||
size: pub.size,
|
||||
hostingRelay: pub.hosting_relay,
|
||||
fee: pub.estimated_fee_ut,
|
||||
replyTo: p.replyTo,
|
||||
quoteOf: p.quoteOf,
|
||||
});
|
||||
await submitTx(tx);
|
||||
return pub.post_id;
|
||||
}
|
||||
|
||||
// ─── Engagement one-liners ───────────────────────────────────────────────
|
||||
|
||||
export async function likePost(p: { from: string; privKey: string; postID: string }) {
|
||||
await submitTx(buildLikePostTx(p));
|
||||
}
|
||||
export async function unlikePost(p: { from: string; privKey: string; postID: string }) {
|
||||
await submitTx(buildUnlikePostTx(p));
|
||||
}
|
||||
export async function deletePost(p: { from: string; privKey: string; postID: string }) {
|
||||
await submitTx(buildDeletePostTx(p));
|
||||
}
|
||||
export async function followUser(p: { from: string; privKey: string; target: string }) {
|
||||
await submitTx(buildFollowTx(p));
|
||||
}
|
||||
export async function unfollowUser(p: { from: string; privKey: string; target: string }) {
|
||||
await submitTx(buildUnfollowTx(p));
|
||||
}
|
||||
|
||||
/** URL for the post's attachment (image / video) — served by the hosting relay. */
|
||||
export function attachmentURL(postID: string): string {
|
||||
return `${getNodeUrl()}/feed/post/${postID}/attachment`;
|
||||
}
|
||||
113
desktop/src/lib/relay.ts
Normal file
113
desktop/src/lib/relay.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Relay mailbox client. Same wire format + semantics as
|
||||
// client-app/lib/api.ts, narrowed to the calls the desktop actually
|
||||
// needs right now: broadcast sealed envelopes, fetch inbox, resolve a
|
||||
// recipient's device pubs for fan-out.
|
||||
|
||||
import { get, post, fetchDevices, getIdentity } from './api';
|
||||
import {
|
||||
hexToBytes, bytesToHex, bytesToBase64, base64ToBytes,
|
||||
} from './crypto';
|
||||
|
||||
export interface Envelope {
|
||||
id: string;
|
||||
sender_pub: string; // X25519 hex (per-device key)
|
||||
/**
|
||||
* sender_ed25519_pub (v2.2.0+): master Ed25519 identity of the sender.
|
||||
* Empty for legacy senders; when present, clients should use this as
|
||||
* the conversation address so messages from any of the sender's
|
||||
* linked devices roll into a single chat.
|
||||
*/
|
||||
sender_ed25519_pub: string;
|
||||
recipient_pub: string;
|
||||
nonce: string; // hex
|
||||
ciphertext: string; // hex
|
||||
timestamp: number; // unix seconds
|
||||
}
|
||||
|
||||
// ─── Inbox ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface InboxItemWire {
|
||||
id: string;
|
||||
sender_pub: string;
|
||||
sender_ed25519_pub?: string; // v2.2.0+; omitted by older nodes
|
||||
recipient_pub: string;
|
||||
sent_at: number;
|
||||
nonce: string; // base64 on the wire
|
||||
ciphertext: string; // base64 on the wire
|
||||
}
|
||||
interface InboxResponseWire {
|
||||
pub: string;
|
||||
count: number;
|
||||
has_more: boolean;
|
||||
items: InboxItemWire[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /relay/inbox?pub=<x25519> → envelopes addressed to that pub.
|
||||
* Converts base64 nonce/ciphertext (Go wire format) to hex so they
|
||||
* line up with what crypto.decryptMessage expects.
|
||||
*/
|
||||
export async function fetchInbox(x25519Pub: string): Promise<Envelope[]> {
|
||||
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519Pub}`);
|
||||
const items = Array.isArray(resp?.items) ? resp.items : [];
|
||||
return items.map((it): Envelope => ({
|
||||
id: it.id,
|
||||
sender_pub: it.sender_pub,
|
||||
sender_ed25519_pub: it.sender_ed25519_pub ?? '',
|
||||
recipient_pub: it.recipient_pub,
|
||||
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
||||
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
||||
timestamp: it.sent_at ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Broadcast ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /relay/broadcast — submits a pre-sealed E2E envelope. The node
|
||||
* relays without ever reading the plaintext; only the recipient's
|
||||
* X25519 priv can open it. Sender_ed25519_pub is advisory for future
|
||||
* fee-proof flows; current node ignores it when fee_ut = 0.
|
||||
*/
|
||||
export async function sendEnvelope(params: {
|
||||
senderPub: string; // X25519 hex
|
||||
recipientPub: string; // X25519 hex
|
||||
nonce: string; // hex
|
||||
ciphertext: string; // hex
|
||||
senderEd25519Pub?: string; // optional
|
||||
}): Promise<{ id: string; status: string }> {
|
||||
const sentAt = Math.floor(Date.now() / 1000);
|
||||
const nonceB64 = bytesToBase64(hexToBytes(params.nonce));
|
||||
const ctB64 = bytesToBase64(hexToBytes(params.ciphertext));
|
||||
// Envelope.id is server-facing dedup key; first 16 bytes of the nonce
|
||||
// are cryptographically random, reuse them to avoid another RNG call.
|
||||
const id = bytesToHex(hexToBytes(params.nonce).slice(0, 16));
|
||||
return post<{ id: string; status: string }>('/relay/broadcast', {
|
||||
envelope: {
|
||||
id,
|
||||
sender_pub: params.senderPub,
|
||||
recipient_pub: params.recipientPub,
|
||||
sender_ed25519_pub: params.senderEd25519Pub ?? '',
|
||||
fee_ut: 0,
|
||||
fee_sig: null,
|
||||
nonce: nonceB64,
|
||||
ciphertext: ctB64,
|
||||
sent_at: sentAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Recipient resolution (multi-device v2.2.0) ──────────────────────────
|
||||
|
||||
/**
|
||||
* For a recipient identity, return every X25519 pub we should ship an
|
||||
* envelope to. Device registry first, identity.x25519_pub as fall-back.
|
||||
* Same helper lives in client-app — copied here rather than imported so
|
||||
* the desktop build stays React-Native-free.
|
||||
*/
|
||||
export async function resolveRecipientKeys(masterPub: string): Promise<string[]> {
|
||||
const devs = await fetchDevices(masterPub);
|
||||
if (devs.length > 0) return devs.map(d => d.x25519_pub_key);
|
||||
const id = await getIdentity(masterPub);
|
||||
return id?.x25519_pub ? [id.x25519_pub] : [];
|
||||
}
|
||||
159
desktop/src/lib/storage.ts
Normal file
159
desktop/src/lib/storage.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// Persistence for the desktop shell.
|
||||
//
|
||||
// Two tiers, both different from the mobile client:
|
||||
// * KeyFile lives in the OS keychain (via Electron safeStorage in main.ts,
|
||||
// exposed as `window.dchain.keyfile`). We never touch it here from
|
||||
// renderer code except through that IPC.
|
||||
// * Everything else — settings, contacts, chat cache, "this device was
|
||||
// registered" marker — lives in localStorage. It's synchronous,
|
||||
// origin-isolated inside the renderer, and plenty durable for
|
||||
// per-install state. A future polish could move chats to IndexedDB
|
||||
// for streaming writes, but localStorage is fine for v2.2.0.
|
||||
|
||||
import type { KeyFile, NodeSettings, Contact, Message } from './types';
|
||||
import type { DChainAPI } from '../../electron/preload';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dchain: DChainAPI;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── KeyFile (safeStorage-backed via IPC) ────────────────────────────────
|
||||
//
|
||||
// All keyfile operations go through window.dchain.keyfile — the preload
|
||||
// script bridges them to Electron's safeStorage. If preload failed to
|
||||
// load (dev misconfig, broken build), we surface a loud error rather
|
||||
// than silently failing, since a missing keyfile layer means nothing
|
||||
// else in the app can work.
|
||||
|
||||
function requireDchain() {
|
||||
if (typeof window === 'undefined' || !window.dchain) {
|
||||
throw new Error(
|
||||
'window.dchain is not available — the Electron preload failed to ' +
|
||||
'load. Check dist-electron/preload.js exists and that main.ts is ' +
|
||||
'pointing at it.',
|
||||
);
|
||||
}
|
||||
return window.dchain;
|
||||
}
|
||||
|
||||
export async function loadKeyFile(): Promise<KeyFile | null> {
|
||||
const raw = await requireDchain().keyfile.load();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as KeyFile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
||||
await requireDchain().keyfile.save(JSON.stringify(kf));
|
||||
}
|
||||
|
||||
export async function deleteKeyFile(): Promise<void> {
|
||||
await requireDchain().keyfile.delete();
|
||||
}
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SETTINGS_KEY = 'dchain_settings';
|
||||
|
||||
const DEFAULT_SETTINGS: NodeSettings = {
|
||||
nodeUrl: 'http://localhost:8080',
|
||||
contractId: '',
|
||||
};
|
||||
|
||||
export function loadSettings(): NodeSettings {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||
if (!raw) return DEFAULT_SETTINGS;
|
||||
try {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(s: Partial<NodeSettings>): void {
|
||||
const cur = loadSettings();
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...cur, ...s }));
|
||||
}
|
||||
|
||||
// ─── Contacts ─────────────────────────────────────────────────────────────
|
||||
|
||||
const CONTACTS_KEY = 'dchain_contacts';
|
||||
|
||||
export function loadContacts(): Contact[] {
|
||||
const raw = localStorage.getItem(CONTACTS_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
return JSON.parse(raw) as Contact[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveContacts(list: Contact[]): void {
|
||||
localStorage.setItem(CONTACTS_KEY, JSON.stringify(list));
|
||||
}
|
||||
|
||||
export function upsertContact(c: Contact): void {
|
||||
const cs = loadContacts();
|
||||
const i = cs.findIndex(x => x.address === c.address);
|
||||
if (i >= 0) cs[i] = c; else cs.push(c);
|
||||
saveContacts(cs);
|
||||
}
|
||||
|
||||
// ─── Chat cache (per-conversation, capped) ───────────────────────────────
|
||||
|
||||
const CHATS_PREFIX = 'dchain_chats_';
|
||||
const CHAT_CAP = 500;
|
||||
|
||||
export function loadMessages(chatAddr: string): Message[] {
|
||||
const raw = localStorage.getItem(CHATS_PREFIX + chatAddr);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
return JSON.parse(raw) as Message[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append + persist. Deduplicates by id, trims to CHAT_CAP newest. Callers
|
||||
* in the UI should prefer zustand's store.appendMessage for reactivity
|
||||
* and call this from effects to flush to disk.
|
||||
*/
|
||||
export function appendMessage(chatAddr: string, m: Message): void {
|
||||
const cur = loadMessages(chatAddr);
|
||||
if (cur.some(x => x.id === m.id)) return;
|
||||
cur.push(m);
|
||||
const trimmed = cur.slice(-CHAT_CAP);
|
||||
localStorage.setItem(CHATS_PREFIX + chatAddr, JSON.stringify(trimmed));
|
||||
}
|
||||
|
||||
// ─── Multi-device bookkeeping (shared semantic with mobile client) ───────
|
||||
|
||||
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
|
||||
|
||||
export function isDeviceRegistered(): boolean {
|
||||
return localStorage.getItem(DEVICE_REGISTERED_KEY) === '1';
|
||||
}
|
||||
|
||||
export function markDeviceRegistered(): void {
|
||||
localStorage.setItem(DEVICE_REGISTERED_KEY, '1');
|
||||
}
|
||||
|
||||
export async function wipeAllLocalState(): Promise<void> {
|
||||
await deleteKeyFile();
|
||||
// Everything else in localStorage we control; iterate + clear our prefix.
|
||||
const ours = [
|
||||
SETTINGS_KEY, CONTACTS_KEY, DEVICE_REGISTERED_KEY,
|
||||
];
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
if (ours.includes(key) || key.startsWith('dchain_chats_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
139
desktop/src/lib/store.ts
Normal file
139
desktop/src/lib/store.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// Zustand store — mirrors client-app/lib/store.ts, trimmed to what the
|
||||
// desktop shell needs today. Holds identity, node settings, live chat
|
||||
// state (contacts + per-chat messages + unread counters) and UI nav
|
||||
// (current section + selected contact). Persistence lives in
|
||||
// lib/storage.ts and hooks (auto-save on mutations).
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { KeyFile, NodeSettings, Contact, Message } from './types';
|
||||
|
||||
export type Section = 'messages' | 'feed' | 'wallet' | 'contacts' | 'settings' | 'profile';
|
||||
|
||||
/**
|
||||
* FeedTab is the current filter applied to the Feed section.
|
||||
* foryou — recommended (unfollowed) posts
|
||||
* timeline — posts from authors we follow
|
||||
* trending — top by engagement, last 24h
|
||||
* hashtag — posts containing a specific tag
|
||||
* author — wall of a single author
|
||||
*/
|
||||
export type FeedTab =
|
||||
| { kind: 'foryou' }
|
||||
| { kind: 'timeline' }
|
||||
| { kind: 'trending' }
|
||||
| { kind: 'hashtag'; tag: string }
|
||||
| { kind: 'author'; pub: string };
|
||||
|
||||
/** Current Wallet selection — either the overview (history) or a tx. */
|
||||
export type WalletSelection =
|
||||
| { kind: 'overview' }
|
||||
| { kind: 'tx'; id: string };
|
||||
|
||||
/** Which Settings subsection is visible in the detail pane. */
|
||||
export type SettingsPage = 'node' | 'identity' | 'devices' | 'about';
|
||||
|
||||
interface State {
|
||||
booted: boolean;
|
||||
keyFile: KeyFile | null;
|
||||
settings: NodeSettings;
|
||||
contacts: Contact[];
|
||||
section: Section;
|
||||
/** address of the currently-open conversation (mirrors mobile's route param). */
|
||||
activeChat: string | null;
|
||||
/** Messages keyed by contact.address. Each list is chronological (old → new). */
|
||||
messages: Record<string, Message[]>;
|
||||
/** Unread counters keyed by contact.address; 0 (or absent) = nothing pending. */
|
||||
unread: Record<string, number>;
|
||||
|
||||
setBooted: (v: boolean) => void;
|
||||
setKeyFile: (k: KeyFile | null) => void;
|
||||
setSettings: (s: Partial<NodeSettings>) => void;
|
||||
setContacts: (cs: Contact[]) => void;
|
||||
upsertContact: (c: Contact) => void;
|
||||
setSection: (s: Section) => void;
|
||||
setActiveChat: (addr: string | null) => void;
|
||||
|
||||
setMessages: (addr: string, msgs: Message[]) => void;
|
||||
appendMessage: (addr: string, m: Message) => void;
|
||||
bumpUnread: (addr: string) => void;
|
||||
clearUnread: (addr: string) => void;
|
||||
|
||||
/** Feed state — persists across section switches within the session. */
|
||||
feedTab: FeedTab;
|
||||
feedSelectedPost: string | null;
|
||||
setFeedTab: (t: FeedTab) => void;
|
||||
setFeedSelectedPost: (id: string | null) => void;
|
||||
|
||||
/** Wallet state. */
|
||||
walletSel: WalletSelection;
|
||||
setWalletSel: (s: WalletSelection) => void;
|
||||
|
||||
/** Settings state. */
|
||||
settingsPage: SettingsPage;
|
||||
setSettingsPage: (p: SettingsPage) => void;
|
||||
|
||||
/** Currently-selected contact in the Contacts section. */
|
||||
selectedContact: string | null;
|
||||
setSelectedContact: (addr: string | null) => void;
|
||||
}
|
||||
|
||||
export const useStore = create<State>((set) => ({
|
||||
booted: false,
|
||||
keyFile: null,
|
||||
settings: { nodeUrl: 'http://localhost:8080', contractId: '' },
|
||||
contacts: [],
|
||||
section: 'messages',
|
||||
activeChat: null,
|
||||
messages: {},
|
||||
unread: {},
|
||||
|
||||
setBooted: (v) => set({ booted: v }),
|
||||
setKeyFile: (k) => set({ keyFile: k }),
|
||||
setSettings: (s) => set((st) => ({ settings: { ...st.settings, ...s } })),
|
||||
setContacts: (cs) => set({ contacts: cs }),
|
||||
upsertContact: (c) => set((st) => {
|
||||
const i = st.contacts.findIndex((x) => x.address === c.address);
|
||||
if (i >= 0) {
|
||||
const next = [...st.contacts];
|
||||
next[i] = c;
|
||||
return { contacts: next };
|
||||
}
|
||||
return { contacts: [...st.contacts, c] };
|
||||
}),
|
||||
setSection: (s) => set({ section: s }),
|
||||
setActiveChat: (addr) => set({ activeChat: addr }),
|
||||
|
||||
setMessages: (addr, msgs) => set((st) => ({
|
||||
messages: { ...st.messages, [addr]: msgs },
|
||||
})),
|
||||
appendMessage: (addr, m) => set((st) => {
|
||||
const cur = st.messages[addr] ?? [];
|
||||
// Idempotent — duplicate envelope deliveries (WS + HTTP race) shouldn't
|
||||
// double-insert.
|
||||
if (cur.some(x => x.id === m.id)) return {};
|
||||
return { messages: { ...st.messages, [addr]: [...cur, m] } };
|
||||
}),
|
||||
bumpUnread: (addr) => set((st) => ({
|
||||
unread: { ...st.unread, [addr]: (st.unread[addr] ?? 0) + 1 },
|
||||
})),
|
||||
clearUnread: (addr) => set((st) => {
|
||||
if (!(addr in st.unread)) return {};
|
||||
const next = { ...st.unread };
|
||||
delete next[addr];
|
||||
return { unread: next };
|
||||
}),
|
||||
|
||||
feedTab: { kind: 'foryou' },
|
||||
feedSelectedPost: null,
|
||||
setFeedTab: (t) => set({ feedTab: t, feedSelectedPost: null }),
|
||||
setFeedSelectedPost: (id) => set({ feedSelectedPost: id }),
|
||||
|
||||
walletSel: { kind: 'overview' },
|
||||
setWalletSel: (s) => set({ walletSel: s }),
|
||||
|
||||
settingsPage: 'node',
|
||||
setSettingsPage: (p) => set({ settingsPage: p }),
|
||||
|
||||
selectedContact: null,
|
||||
setSelectedContact: (addr) => set({ selectedContact: addr }),
|
||||
}));
|
||||
216
desktop/src/lib/tx.ts
Normal file
216
desktop/src/lib/tx.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// Transaction builders + submission.
|
||||
//
|
||||
// Mirrors the handful of builders we actually use from client-app/lib/api.ts
|
||||
// (Transfer, Link/UnlinkDevice for now; more will follow as sections land).
|
||||
// Canonical bytes and wire format are identical to the mobile client —
|
||||
// both talk to the same Go node, so any divergence here is a bug.
|
||||
|
||||
import { bytesToBase64, signBase64 } from './crypto';
|
||||
import { post } from './api';
|
||||
|
||||
const MIN_TX_FEE = 1_000;
|
||||
const _encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction
|
||||
* JSON. `payload` and `signature` are base64 because Go's json.Marshal
|
||||
* encodes []byte that way; `timestamp` is RFC3339 because Go's time.Time
|
||||
* does the same.
|
||||
*/
|
||||
export interface RawTx {
|
||||
id: string;
|
||||
type: string;
|
||||
from: string;
|
||||
to: string;
|
||||
amount: number;
|
||||
fee: number;
|
||||
memo?: string;
|
||||
payload: string;
|
||||
signature: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
function rfc3339Now(): string {
|
||||
const d = new Date();
|
||||
d.setMilliseconds(0);
|
||||
return d.toISOString().replace('.000Z', 'Z');
|
||||
}
|
||||
|
||||
function newTxID(): string {
|
||||
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical bytes the node re-derives to verify tx.signature. Order of
|
||||
* keys matches Go's field order in identity.txSignBytes — JS object
|
||||
* literals preserve insertion order so JSON.stringify is enough.
|
||||
*/
|
||||
function canonicalBytes(tx: {
|
||||
id: string; type: string; from: string; to: string;
|
||||
amount: number; fee: number; payload: string; timestamp: string;
|
||||
}): Uint8Array {
|
||||
return _encoder.encode(JSON.stringify({
|
||||
id: tx.id,
|
||||
type: tx.type,
|
||||
from: tx.from,
|
||||
to: tx.to,
|
||||
amount: tx.amount,
|
||||
fee: tx.fee,
|
||||
payload: tx.payload,
|
||||
timestamp: tx.timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
function strToBase64(s: string): string {
|
||||
return bytesToBase64(_encoder.encode(s));
|
||||
}
|
||||
|
||||
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
|
||||
return post<{ id: string; status: string }>('/api/tx', tx);
|
||||
}
|
||||
|
||||
// ─── Builders ────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildTransferTx(p: {
|
||||
from: string; to: string; amount: number; fee: number;
|
||||
privKey: string; memo?: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify(p.memo ? { memo: p.memo } : {}));
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'TRANSFER', from: p.from, to: p.to,
|
||||
amount: p.amount, fee: p.fee, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'TRANSFER', from: p.from, to: p.to,
|
||||
amount: p.amount, fee: p.fee, memo: p.memo, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLinkDeviceTx(p: {
|
||||
from: string; x25519Pub: string; deviceName: string; privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify({
|
||||
x25519_pub_key: p.x25519Pub,
|
||||
device_name: p.deviceName,
|
||||
}));
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'LINK_DEVICE', from: p.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'LINK_DEVICE', from: p.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUnlinkDeviceTx(p: {
|
||||
from: string; x25519Pub: string; privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify({ x25519_pub_key: p.x25519Pub }));
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTACT_REQUEST — paid first-contact tx. `amount` carries the
|
||||
* anti-spam fee (≥ MinContactFee = 5000 µT on the node), credited to
|
||||
* the recipient's balance as an incentive to accept; `fee` is the
|
||||
* regular network fee. Optional `intro` plaintext is embedded in the
|
||||
* payload so the receiver sees "who is this" before accepting.
|
||||
*/
|
||||
export function buildContactRequestTx(p: {
|
||||
from: string;
|
||||
to: string;
|
||||
contactFee: number; // µT — ≥ 5000, paid to recipient
|
||||
privKey: string;
|
||||
intro?: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify(p.intro ? { intro: p.intro } : {}));
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'CONTACT_REQUEST', from: p.from, to: p.to,
|
||||
amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'CONTACT_REQUEST', from: p.from, to: p.to,
|
||||
amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ACCEPT_CONTACT — recipient side, empties the pending request and
|
||||
* publishes the peer's X25519 key so the requester can start sending
|
||||
* encrypted envelopes. Tx.to = original requester's pub.
|
||||
*/
|
||||
export function buildAcceptContactTx(p: {
|
||||
from: string; to: string; privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64('{}');
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to,
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to,
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* BLOCK_CONTACT — sticky rejection. Subsequent CONTACT_REQUEST txs
|
||||
* from the same sender are dropped at applyTx level on the node.
|
||||
*/
|
||||
export function buildBlockContactTx(p: {
|
||||
from: string; to: string; privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64('{}');
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'BLOCK_CONTACT', from: p.from, to: p.to,
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'BLOCK_CONTACT', from: p.from, to: p.to,
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* humanizeTxError unwraps the server's `{"error":"…"}` shape and common
|
||||
* message wrappers into a one-line user-facing string. Same helper the
|
||||
* mobile client exposes from lib/api.ts; copied here to keep the two
|
||||
* codebases independent until we factor into a shared package.
|
||||
*/
|
||||
export function humanizeTxError(err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
const m = /→\s*({[^}]+})/.exec(raw);
|
||||
if (m) {
|
||||
try {
|
||||
const parsed = JSON.parse(m[1]);
|
||||
if (parsed.error) return parsed.error;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
41
desktop/src/lib/types.ts
Normal file
41
desktop/src/lib/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Mirrors client-app/lib/types.ts — keep wire formats identical so the
|
||||
// two codebases can share a single node. Copied (not imported) on
|
||||
// purpose: we want the desktop build isolated from React-Native deps,
|
||||
// and the drift window between this file and the mobile one is small
|
||||
// enough to hand-sync. When we consolidate into a shared package
|
||||
// (post-v2.2.0), this file goes away.
|
||||
|
||||
export interface KeyFile {
|
||||
pub_key: string; // hex Ed25519 public key (32 bytes)
|
||||
priv_key: string; // hex Ed25519 secret key (64 bytes)
|
||||
x25519_pub: string; // hex X25519 public key (32 bytes)
|
||||
x25519_priv: string; // hex X25519 secret key (32 bytes)
|
||||
}
|
||||
|
||||
export interface NodeSettings {
|
||||
nodeUrl: string;
|
||||
contractId: string;
|
||||
apiToken?: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
address: string; // Ed25519 master pub hex
|
||||
x25519Pub: string; // legacy single-X25519; device registry superseded on v2.2.0
|
||||
username?: string;
|
||||
alias?: string;
|
||||
addedAt: number; // unix ms
|
||||
kind?: 'direct' | 'group';
|
||||
unread?: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
from: string; // X25519 hex (sender device)
|
||||
text: string;
|
||||
timestamp: number;
|
||||
mine: boolean;
|
||||
read: boolean;
|
||||
edited: boolean;
|
||||
attachment?: unknown;
|
||||
replyTo?: { id: string; text: string; author: string };
|
||||
}
|
||||
24
desktop/src/main.tsx
Normal file
24
desktop/src/main.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
|
||||
// Last-resort fallback: if even rendering ErrorBoundary+App fails (say, a
|
||||
// syntax error in some lazy import), paint a visible message into #root
|
||||
// so the window isn't just black. window.onerror catches async errors
|
||||
// that escape React's boundaries.
|
||||
window.addEventListener('error', (e) => {
|
||||
const root = document.getElementById('root');
|
||||
if (root && !root.firstChild) {
|
||||
root.innerHTML = `<pre style="color:#ff6b6b;background:#000;padding:20px;font-family:monospace;white-space:pre-wrap;">` +
|
||||
`Fatal: ${String(e.error ?? e.message)}\n\n${e.error?.stack ?? ''}</pre>`;
|
||||
}
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
200
desktop/src/sections/contacts/ContactsDetail.tsx
Normal file
200
desktop/src/sections/contacts/ContactsDetail.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
// Right-pane for Contacts — profile card for the selected contact.
|
||||
// Shows identity, balance, device count, linked action buttons:
|
||||
// Open chat (switch to Messages section), Transfer, View posts (switch
|
||||
// to Feed author wall), Block (local only for now).
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getIdentity, fetchDevices, getBalance } from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import type { IdentityInfo } from '@/lib/api';
|
||||
|
||||
function formatT(ut: number): string {
|
||||
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
export function ContactsDetail(): React.ReactElement {
|
||||
const sel = useStore(s => s.selectedContact);
|
||||
const contact = useStore(s => s.contacts.find(c => c.address === sel));
|
||||
const setSection = useStore(s => s.setSection);
|
||||
const setActive = useStore(s => s.setActiveChat);
|
||||
const setFeedTab = useStore(s => s.setFeedTab);
|
||||
|
||||
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
|
||||
const [balance, setBalance] = useState<number | null>(null);
|
||||
const [deviceCount, setDeviceCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sel) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [id, bal, devs] = await Promise.all([
|
||||
getIdentity(sel),
|
||||
getBalance(sel),
|
||||
fetchDevices(sel),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setIdentity(id);
|
||||
setBalance(bal);
|
||||
setDeviceCount(devs.length);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [sel]);
|
||||
|
||||
if (!sel || !contact) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: '#6a6a6a', fontSize: 13, padding: 40, textAlign: 'center',
|
||||
}}>
|
||||
Pick a contact on the left to view their profile.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = contact.username
|
||||
? `@${contact.username}`
|
||||
: (identity?.nickname ? `@${identity.nickname}` : (contact.alias ?? shortAddr(contact.address, 8)));
|
||||
|
||||
const openChat = () => {
|
||||
setActive(contact.address);
|
||||
setSection('messages');
|
||||
};
|
||||
const viewPosts = () => {
|
||||
setFeedTab({ kind: 'author', pub: contact.address });
|
||||
setSection('feed');
|
||||
};
|
||||
const copy = (s: string) => navigator.clipboard.writeText(s).catch(() => {});
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', overflowY: 'auto',
|
||||
padding: '22px 26px', background: '#000',
|
||||
}}>
|
||||
{/* Header card */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: 32,
|
||||
background: '#1a1a1a', color: '#d0d0d0',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 28, fontWeight: 700,
|
||||
}}>{displayName.replace(/^@/, '').charAt(0).toUpperCase()}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: '#fff', fontSize: 22, fontWeight: 800 }}>
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="selectable" style={{
|
||||
color: '#8b8b8b', fontSize: 12, fontFamily: 'monospace',
|
||||
marginTop: 4, wordBreak: 'break-all',
|
||||
}}>{contact.address}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 16, flexWrap: 'wrap' }}>
|
||||
<Btn primary onClick={openChat}>Open chat</Btn>
|
||||
<Btn onClick={viewPosts}>View posts</Btn>
|
||||
<Btn onClick={() => copy(contact.address)}>Copy address</Btn>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div style={{
|
||||
marginTop: 22, display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10,
|
||||
}}>
|
||||
<Stat label="Balance" value={balance === null ? '…' : `${formatT(balance)} T`} />
|
||||
<Stat label="Devices" value={deviceCount === null ? '…' : String(deviceCount)} />
|
||||
<Stat label="Encryption" value={contact.x25519Pub ? 'E2E (NaCl)' : 'no key'} />
|
||||
<Stat label="Added" value={new Date(contact.addedAt).toLocaleDateString()} />
|
||||
</div>
|
||||
|
||||
{/* Identity details */}
|
||||
{identity && (
|
||||
<div style={{
|
||||
marginTop: 22, padding: 14, borderRadius: 12,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8,
|
||||
}}>Identity</div>
|
||||
<Row k="DC address" v={identity.address} copyable onCopy={() => copy(identity.address)} />
|
||||
{identity.nickname && <Row k="Username" v={`@${identity.nickname}`} />}
|
||||
{identity.x25519_pub && (
|
||||
<Row
|
||||
k="Published X25519"
|
||||
v={shortAddr(identity.x25519_pub, 8)}
|
||||
copyable
|
||||
onCopy={() => copy(identity.x25519_pub)}
|
||||
/>
|
||||
)}
|
||||
{typeof identity.device_count === 'number' && (
|
||||
<Row k="Device count" v={String(identity.device_count)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Btn({ children, onClick, primary }: {
|
||||
children: React.ReactNode; onClick: () => void; primary?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '9px 16px', borderRadius: 999,
|
||||
background: primary ? '#1d9bf0' : 'transparent',
|
||||
border: primary ? 'none' : '1px solid #1f1f1f',
|
||||
color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>{children}</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 12, borderRadius: 12,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1, textTransform: 'uppercase',
|
||||
}}>{label}</div>
|
||||
<div style={{ color: '#fff', fontSize: 15, fontWeight: 700, marginTop: 4 }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
k, v, copyable, onCopy,
|
||||
}: { k: string; v: string; copyable?: boolean; onCopy?: () => void }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', padding: '6px 0',
|
||||
borderBottom: '1px solid #141414',
|
||||
alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<div style={{ color: '#8b8b8b', fontSize: 12, flex: '0 0 140px' }}>{k}</div>
|
||||
<div className="selectable" style={{
|
||||
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||
flex: 1, wordBreak: 'break-all',
|
||||
}}>{v}</div>
|
||||
{copyable && (
|
||||
<button
|
||||
onClick={onCopy}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: '#1d9bf0', fontSize: 11, fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>copy</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
desktop/src/sections/contacts/ContactsList.tsx
Normal file
198
desktop/src/sections/contacts/ContactsList.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
// Left-pane of Contacts — flat alphabetical list with a text filter.
|
||||
// Richer grouping (Online / Blocked / Requests) arrives once we have
|
||||
// WS presence + request inbox plumbing; placeholder headers are left
|
||||
// in the UI so the shape is visible.
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { fetchContactRequests, type ContactRequestRaw } from '@/lib/api';
|
||||
import type { Contact } from '@/lib/types';
|
||||
import { NewContactModal } from './NewContactModal';
|
||||
import { RequestsList } from './RequestsList';
|
||||
|
||||
export function ContactsList(): React.ReactElement {
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const sel = useStore(s => s.selectedContact);
|
||||
const setSel = useStore(s => s.setSelectedContact);
|
||||
|
||||
const [q, setQ] = useState('');
|
||||
const [tab, setTab] = useState<'list' | 'requests'>('list');
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [requests, setRequests] = useState<ContactRequestRaw[]>([]);
|
||||
|
||||
// Load pending contact requests (on-chain inbox). Refreshes when the
|
||||
// tab is opened and after a new request is sent so the counter moves.
|
||||
const refreshRequests = async () => {
|
||||
if (!keyFile) return;
|
||||
const list = await fetchContactRequests(keyFile.pub_key);
|
||||
// Filter to pending only — accepted ones turn into contacts.
|
||||
const knownContacts = new Set(contacts.map(c => c.address));
|
||||
setRequests(list.filter(r =>
|
||||
r.status === 'pending' && !knownContacts.has(r.requester_pub),
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() => { refreshRequests(); const t = setInterval(refreshRequests, 15_000); return () => clearInterval(t); },
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[keyFile, contacts]);
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
if (!needle) return contacts;
|
||||
return contacts.filter(c =>
|
||||
(c.username ?? '').toLowerCase().includes(needle) ||
|
||||
(c.alias ?? '').toLowerCase().includes(needle) ||
|
||||
c.address.toLowerCase().includes(needle),
|
||||
);
|
||||
}, [contacts, q]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...filtered].sort((a, b) => {
|
||||
const an = (a.username ?? a.alias ?? a.address).toLowerCase();
|
||||
const bn = (b.username ?? b.alias ?? b.address).toLowerCase();
|
||||
return an.localeCompare(bn);
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Sticky header: tab switcher + search / action row */}
|
||||
<div style={{
|
||||
position: 'sticky', top: 0, zIndex: 1,
|
||||
background: '#000', borderBottom: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', padding: '8px 10px 0', gap: 4,
|
||||
}}>
|
||||
<TabBtn
|
||||
label="Contacts"
|
||||
active={tab === 'list'}
|
||||
onClick={() => setTab('list')}
|
||||
/>
|
||||
<TabBtn
|
||||
label="Requests"
|
||||
active={tab === 'requests'}
|
||||
badge={requests.length}
|
||||
onClick={() => setTab('requests')}
|
||||
/>
|
||||
</div>
|
||||
{tab === 'list' && (
|
||||
<div style={{ padding: 10, display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
placeholder="Filter…"
|
||||
style={{
|
||||
flex: 1, boxSizing: 'border-box',
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
borderRadius: 8, padding: '8px 10px',
|
||||
color: '#fff', fontSize: 13, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setNewOpen(true)}
|
||||
title="Send contact request"
|
||||
style={{
|
||||
padding: '8px 12px', borderRadius: 8, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>+ New</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === 'requests' ? (
|
||||
<RequestsList
|
||||
requests={requests}
|
||||
onChanged={refreshRequests}
|
||||
/>
|
||||
) : sorted.length === 0 ? (
|
||||
<div style={{
|
||||
padding: 32, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||
}}>
|
||||
No contacts yet. Tap <b>+ New</b> above to send a contact request,
|
||||
or pair another of your own devices via Settings → Devices.
|
||||
</div>
|
||||
) : (
|
||||
sorted.map(c => (
|
||||
<Row key={c.address} c={c} active={c.address === sel} onClick={() => setSel(c.address)} />
|
||||
))
|
||||
)}
|
||||
|
||||
{newOpen && (
|
||||
<NewContactModal
|
||||
onClose={() => setNewOpen(false)}
|
||||
onSent={() => { setNewOpen(false); refreshRequests(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabBtn({
|
||||
label, active, onClick, badge,
|
||||
}: {
|
||||
label: string; active: boolean; onClick: () => void; badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '8px 12px', borderRadius: 8,
|
||||
border: 'none', background: 'transparent',
|
||||
color: active ? '#1d9bf0' : '#8b8b8b',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
position: 'relative',
|
||||
borderBottom: active ? '2px solid #1d9bf0' : '2px solid transparent',
|
||||
marginBottom: -2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span style={{
|
||||
marginLeft: 6, padding: '0 6px', height: 16,
|
||||
borderRadius: 8, background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 10, fontWeight: 700,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>{badge > 99 ? '99+' : badge}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ c, active, onClick }: {
|
||||
c: Contact; active: boolean; onClick: () => void;
|
||||
}) {
|
||||
const name = c.username ? `@${c.username}` : (c.alias ?? shortAddr(c.address, 6));
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '10px 14px', borderBottom: '1px solid #1f1f1f',
|
||||
background: active ? '#0a1a29' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#d0d0d0', fontWeight: 700,
|
||||
}}>{name.replace(/^@/, '').charAt(0).toUpperCase()}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
color: '#fff', fontSize: 13, fontWeight: 700,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{shortAddr(c.address, 8)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
323
desktop/src/sections/contacts/NewContactModal.tsx
Normal file
323
desktop/src/sections/contacts/NewContactModal.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
// NewContactModal — send an on-chain CONTACT_REQUEST to a new peer.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Enter @username / DC / hex → resolve into an Ed25519 pub.
|
||||
// 2. Optional intro + fee-tier pick (5k / 10k / 50k µT).
|
||||
// 3. Submit CONTACT_REQUEST tx with amount = contactFee.
|
||||
// The peer sees the request in their Contacts → Requests tab and can
|
||||
// Accept / Reject. After acceptance an encrypted chat becomes possible
|
||||
// via the existing /relay/broadcast pipeline.
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
resolveAccount, getIdentity, getBalance,
|
||||
type IdentityInfo,
|
||||
} from '@/lib/api';
|
||||
import { buildContactRequestTx, submitTx, humanizeTxError } from '@/lib/tx';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
|
||||
const FEE_TIERS = [
|
||||
{ value: 5_000, label: 'Min', hint: 'enough for a low-spam node' },
|
||||
{ value: 10_000, label: 'Standard', hint: 'default' },
|
||||
{ value: 50_000, label: 'Priority', hint: 'more attention-grabbing' },
|
||||
];
|
||||
const MIN_NETWORK_FEE = 1_000;
|
||||
|
||||
export function NewContactModal({ onClose, onSent }: {
|
||||
onClose: () => void;
|
||||
onSent: () => void;
|
||||
}): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [resolved, setResolved] = useState<{
|
||||
pub: string; identity: IdentityInfo | null;
|
||||
} | null>(null);
|
||||
const [intro, setIntro] = useState('');
|
||||
const [fee, setFee] = useState<number>(FEE_TIERS[1].value);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [balance, setBalance] = useState<number | null>(null);
|
||||
|
||||
const totalCost = fee + MIN_NETWORK_FEE;
|
||||
const insufficient = balance !== null && balance < totalCost;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
|
||||
}, [keyFile]);
|
||||
|
||||
const search = async () => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
setSearching(true); setErr(null); setResolved(null);
|
||||
try {
|
||||
const pub = await resolveAccount(q);
|
||||
if (!pub) { setErr(`Couldn't resolve "${q}"`); return; }
|
||||
if (keyFile && pub.toLowerCase() === keyFile.pub_key.toLowerCase()) {
|
||||
setErr('That\'s you — open Saved Messages in the chat list instead.');
|
||||
return;
|
||||
}
|
||||
const id = await getIdentity(pub);
|
||||
setResolved({ pub, identity: id });
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (!keyFile || !resolved || sending) return;
|
||||
setSending(true); setErr(null);
|
||||
try {
|
||||
const tx = buildContactRequestTx({
|
||||
from: keyFile.pub_key,
|
||||
to: resolved.pub,
|
||||
contactFee: fee,
|
||||
intro: intro.trim() || undefined,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
onSent();
|
||||
} catch (e) {
|
||||
setErr(humanizeTxError(e));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const peerName = useMemo(() => {
|
||||
if (!resolved) return '';
|
||||
if (resolved.identity?.nickname) return `@${resolved.identity.nickname}`;
|
||||
return shortAddr(resolved.pub, 8);
|
||||
}, [resolved]);
|
||||
|
||||
return (
|
||||
<Backdrop onClose={sending ? () => {} : onClose}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 480, padding: 20, borderRadius: 16,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<Header title="Send contact request" onClose={onClose} busy={sending} />
|
||||
|
||||
{/* Search */}
|
||||
<Label>Who</Label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') search(); }}
|
||||
placeholder="@username, DC-address, or hex pub"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1, background: '#000', border: '1px solid #1f1f1f',
|
||||
borderRadius: 8, padding: '10px 12px',
|
||||
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={search}
|
||||
disabled={searching || query.trim().length === 0}
|
||||
style={{
|
||||
padding: '9px 14px', borderRadius: 8, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 13, fontWeight: 700,
|
||||
cursor: searching ? 'default' : 'pointer',
|
||||
opacity: searching || query.trim().length === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>{searching ? '…' : 'Find'}</button>
|
||||
</div>
|
||||
|
||||
{/* Resolved peer preview */}
|
||||
{resolved && (
|
||||
<div style={{
|
||||
marginTop: 12, padding: 12, borderRadius: 10,
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#d0d0d0', fontWeight: 700,
|
||||
}}>{peerName.replace(/^@/, '').charAt(0).toUpperCase()}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: '#fff', fontSize: 13, fontWeight: 700 }}>
|
||||
{peerName}
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{resolved.pub}
|
||||
</div>
|
||||
<div style={{
|
||||
color: resolved.identity?.x25519_pub ? '#3ba55d' : '#f0b35a',
|
||||
fontSize: 11, marginTop: 3,
|
||||
}}>
|
||||
{resolved.identity?.x25519_pub
|
||||
? '✓ has encryption key published'
|
||||
: '⚠ no encryption key on chain yet (messaging disabled until they register)'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intro */}
|
||||
{resolved && (
|
||||
<>
|
||||
<Label style={{ marginTop: 14 }}>Intro (optional)</Label>
|
||||
<textarea
|
||||
value={intro}
|
||||
onChange={e => setIntro(e.target.value)}
|
||||
placeholder="Hey — we met at …"
|
||||
rows={2}
|
||||
maxLength={280}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
borderRadius: 8, padding: '10px 12px',
|
||||
color: '#fff', fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none', resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fee tiers */}
|
||||
{resolved && (
|
||||
<>
|
||||
<Label style={{ marginTop: 14 }}>Anti-spam fee (paid to recipient)</Label>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{FEE_TIERS.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => setFee(t.value)}
|
||||
style={{
|
||||
flex: 1, minWidth: 120,
|
||||
padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
|
||||
background: fee === t.value ? '#0a1a29' : '#000',
|
||||
border: fee === t.value ? '1px solid #1d9bf0' : '1px solid #1f1f1f',
|
||||
color: '#fff', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 700,
|
||||
color: fee === t.value ? '#1d9bf0' : '#fff',
|
||||
}}>{t.label}</div>
|
||||
<div style={{ fontSize: 11, color: '#8b8b8b', marginTop: 2 }}>
|
||||
{(t.value / 1_000_000).toFixed(3)} T · {t.hint}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Summary + actions */}
|
||||
{resolved && (
|
||||
<div style={{
|
||||
marginTop: 14, color: '#8b8b8b', fontSize: 11, lineHeight: 1.5,
|
||||
}}>
|
||||
Cost: <span style={{ color: '#fff' }}>
|
||||
{(totalCost / 1_000_000).toFixed(3)} T
|
||||
</span> ({(fee / 1_000_000).toFixed(3)} to recipient · {(MIN_NETWORK_FEE / 1_000_000).toFixed(3)} network fee)
|
||||
{balance !== null && (
|
||||
<> · Balance: <span style={{
|
||||
color: insufficient ? '#f4212e' : '#fff',
|
||||
}}>{(balance / 1_000_000).toFixed(3)} T</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && (
|
||||
<div style={{
|
||||
marginTop: 12, padding: 10, borderRadius: 8,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||
}}>{err}</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={sending}
|
||||
style={{
|
||||
padding: '9px 14px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #1f1f1f',
|
||||
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
|
||||
cursor: sending ? 'default' : 'pointer',
|
||||
}}
|
||||
>Cancel</button>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={!resolved || insufficient || sending}
|
||||
style={{
|
||||
padding: '9px 18px', borderRadius: 999, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 13, fontWeight: 700,
|
||||
cursor: (!resolved || insufficient || sending) ? 'default' : 'pointer',
|
||||
opacity: (!resolved || insufficient || sending) ? 0.5 : 1,
|
||||
}}
|
||||
>{sending ? '…' : 'Send request'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── small shared primitives (private to this file — Contacts is the only caller)
|
||||
|
||||
function Backdrop({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 20,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div onClick={e => e.stopPropagation()} style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ title, onClose, busy }: {
|
||||
title: string; onClose: () => void; busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>{title}</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 6,
|
||||
...style,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
168
desktop/src/sections/contacts/RequestsList.tsx
Normal file
168
desktop/src/sections/contacts/RequestsList.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
// RequestsList — pending contact requests inbox.
|
||||
//
|
||||
// Each row shows the requester (identity if known + DC address + fee paid)
|
||||
// and their intro message. Accept publishes ACCEPT_CONTACT on-chain,
|
||||
// adds the peer to the local contacts store, and optimistically drops
|
||||
// the row. Reject (Block) publishes BLOCK_CONTACT; subsequent requests
|
||||
// from the same sender are refused by the node.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
buildAcceptContactTx, buildBlockContactTx, submitTx, humanizeTxError,
|
||||
} from '@/lib/tx';
|
||||
import { upsertContact as persistContact } from '@/lib/storage';
|
||||
import { getIdentity, type ContactRequestRaw } from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
|
||||
export function RequestsList({
|
||||
requests, onChanged,
|
||||
}: {
|
||||
requests: ContactRequestRaw[];
|
||||
onChanged: () => void;
|
||||
}): React.ReactElement {
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 32, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||
}}>
|
||||
No pending requests. Inbound CONTACT_REQUEST txs will show up here
|
||||
for you to accept or block.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{requests.map(r => (
|
||||
<RequestRow key={r.tx_id} req={r} onChanged={onChanged} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestRow({
|
||||
req, onChanged,
|
||||
}: { req: ContactRequestRaw; onChanged: () => void }) {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const upsertContact = useStore(s => s.upsertContact);
|
||||
|
||||
const [busy, setBusy] = useState<'accept' | 'block' | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const act = async (kind: 'accept' | 'block') => {
|
||||
if (!keyFile) return;
|
||||
setBusy(kind); setErr(null);
|
||||
try {
|
||||
if (kind === 'accept') {
|
||||
// Need the requester's X25519 so a local contact is created
|
||||
// with encryption enabled out of the gate — without it the
|
||||
// first outgoing message would surface "no key" until we
|
||||
// refetched via resolveRecipientKeys.
|
||||
const identity = await getIdentity(req.requester_pub);
|
||||
const tx = buildAcceptContactTx({
|
||||
from: keyFile.pub_key,
|
||||
to: req.requester_pub,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
const c = {
|
||||
address: req.requester_pub,
|
||||
x25519Pub: identity?.x25519_pub ?? '',
|
||||
username: identity?.nickname || undefined,
|
||||
alias: undefined,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
upsertContact(c);
|
||||
persistContact(c);
|
||||
} else {
|
||||
const tx = buildBlockContactTx({
|
||||
from: keyFile.pub_key,
|
||||
to: req.requester_pub,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
}
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(humanizeTxError(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: 14, borderBottom: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#d0d0d0', fontWeight: 700,
|
||||
}}>{shortAddr(req.requester_pub, 1).charAt(0).toUpperCase()}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
color: '#fff', fontSize: 13, fontWeight: 700,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{shortAddr(req.requester_pub, 8)}
|
||||
</div>
|
||||
<div style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace' }}>
|
||||
{req.requester_addr}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#f0b35a', fontSize: 11, fontWeight: 700,
|
||||
}}>
|
||||
+{(req.fee_ut / 1_000_000).toFixed(3)} T
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{req.intro && (
|
||||
<div className="selectable" style={{
|
||||
padding: 10, borderRadius: 8,
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
color: '#e0e0e0', fontSize: 12, lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{req.intro}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => act('block')}
|
||||
disabled={!!busy}
|
||||
style={{
|
||||
padding: '7px 12px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #3a2020',
|
||||
color: '#ff6b6b', fontSize: 12, fontWeight: 700,
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
}}
|
||||
>{busy === 'block' ? '…' : 'Block'}</button>
|
||||
<button
|
||||
onClick={() => act('accept')}
|
||||
disabled={!!busy}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 999,
|
||||
border: 'none', background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 12, fontWeight: 700,
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
}}
|
||||
>{busy === 'accept' ? '…' : 'Accept'}</button>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div style={{
|
||||
marginTop: 8, padding: 8, borderRadius: 6,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 11,
|
||||
}}>{err}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
desktop/src/sections/contacts/index.tsx
Normal file
6
desktop/src/sections/contacts/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// Contacts section.
|
||||
// List pane: contact list + quick filter.
|
||||
// Detail pane: selected contact's profile card + actions.
|
||||
|
||||
export { ContactsList } from './ContactsList';
|
||||
export { ContactsDetail } from './ContactsDetail';
|
||||
159
desktop/src/sections/feed/ComposeModal.tsx
Normal file
159
desktop/src/sections/feed/ComposeModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
// ComposeModal — new-post modal reachable from the Feed section header
|
||||
// or the Ctrl/Cmd+N keybind. Minimal for alpha6: text-only, 4000 char
|
||||
// limit, no attachments (those come with the image-picker + client-side
|
||||
// scrub in rc1). Publish flow is identical to mobile — server returns
|
||||
// content_hash + fee; client commits the matching CREATE_POST tx.
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { publishAndCommit } from '@/lib/feed';
|
||||
import { humanizeTxError } from '@/lib/tx';
|
||||
|
||||
const MAX_CONTENT_LEN = 4000;
|
||||
|
||||
export function ComposeModal({
|
||||
onClose, onPublished,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onPublished: () => void;
|
||||
}): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const [content, setContent] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Focus the textarea on mount; close on Escape.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !busy) onClose();
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { submit(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [content, busy]);
|
||||
|
||||
const bytes = useMemo(
|
||||
() => new TextEncoder().encode(content).length,
|
||||
[content],
|
||||
);
|
||||
|
||||
const hashtags = useMemo(() => {
|
||||
const m = content.match(/#[A-Za-z0-9_\u0400-\u04FF]{1,40}/g) ?? [];
|
||||
return Array.from(new Set(m.map(t => t.slice(1).toLowerCase())));
|
||||
}, [content]);
|
||||
|
||||
const canPublish = !busy && content.trim().length > 0 && bytes <= MAX_CONTENT_LEN;
|
||||
|
||||
const submit = async () => {
|
||||
if (!keyFile || !canPublish) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await publishAndCommit({
|
||||
author: keyFile.pub_key,
|
||||
privKey: keyFile.priv_key,
|
||||
content: content.trim(),
|
||||
});
|
||||
onPublished();
|
||||
} catch (e) {
|
||||
setError(humanizeTxError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 20,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}} onClick={() => !busy && onClose()}>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%', maxWidth: 560,
|
||||
background: '#0a0a0a',
|
||||
borderRadius: 16, border: '1px solid #1f1f1f',
|
||||
padding: 18,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>
|
||||
New post
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
autoFocus
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder="What's happening?"
|
||||
rows={6}
|
||||
style={{
|
||||
width: '100%', resize: 'vertical',
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
borderRadius: 10, padding: '12px',
|
||||
color: '#fff', fontSize: 14, fontFamily: 'inherit',
|
||||
outline: 'none', lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{
|
||||
marginTop: 8, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', gap: 12,
|
||||
}}>
|
||||
<div style={{ color: '#8b8b8b', fontSize: 11 }}>
|
||||
{bytes.toLocaleString()} / {MAX_CONTENT_LEN.toLocaleString()} bytes
|
||||
{hashtags.length > 0 && (
|
||||
<> · <span style={{ color: '#1d9bf0' }}>
|
||||
{hashtags.slice(0, 3).map(t => `#${t}`).join(' ')}
|
||||
</span></>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
style={{
|
||||
padding: '8px 14px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #1f1f1f',
|
||||
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
}}
|
||||
>Cancel</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!canPublish}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 999,
|
||||
border: 'none', background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 13, fontWeight: 700,
|
||||
cursor: canPublish ? 'pointer' : 'default',
|
||||
opacity: canPublish ? 1 : 0.5,
|
||||
}}
|
||||
>{busy ? '…' : 'Publish'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
marginTop: 12, padding: 10, borderRadius: 8,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||
}}>{error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
desktop/src/sections/feed/FeedPane.tsx
Normal file
133
desktop/src/sections/feed/FeedPane.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
// FeedPane — the right pane. A two-column split: scrollable post list
|
||||
// on the left (~430px), thread/post detail on the right.
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useStore, type FeedTab } from '@/lib/store';
|
||||
import {
|
||||
fetchForYou, fetchTrending, fetchTimeline, fetchHashtag, fetchAuthorPosts,
|
||||
type FeedPostItem,
|
||||
} from '@/lib/feed';
|
||||
import { PostList } from './PostList';
|
||||
import { PostDetail } from './PostDetail';
|
||||
import { ComposeModal } from './ComposeModal';
|
||||
|
||||
export function FeedPane(): React.ReactElement {
|
||||
const tab = useStore(s => s.feedTab);
|
||||
const selected = useStore(s => s.feedSelectedPost);
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [posts, setPosts] = useState<FeedPostItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [composing, setComposing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await fetchByTab(tab, keyFile?.pub_key);
|
||||
setPosts(list);
|
||||
} catch {
|
||||
setPosts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tab, keyFile]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Ctrl/Cmd+N → compose (scoped to Feed being active).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'n') {
|
||||
e.preventDefault();
|
||||
setComposing(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex' }}>
|
||||
<div style={{
|
||||
width: 430, flexShrink: 0, borderRight: '1px solid #1f1f1f',
|
||||
overflowY: 'auto', background: '#000',
|
||||
}}>
|
||||
{/* Header strip — tab label + compose CTA */}
|
||||
<div style={{
|
||||
position: 'sticky', top: 0, zIndex: 1,
|
||||
padding: '10px 14px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #1f1f1f',
|
||||
background: 'rgba(0,0,0,0.9)', backdropFilter: 'blur(6px)',
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>
|
||||
{titleFor(tab)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setComposing(true)}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 999, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
title="Ctrl/Cmd+N"
|
||||
>New post</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{
|
||||
padding: 40, textAlign: 'center', color: '#6a6a6a', fontSize: 13,
|
||||
}}>Loading…</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div style={{
|
||||
padding: 40, textAlign: 'center', color: '#6a6a6a', fontSize: 13,
|
||||
}}>No posts in this feed yet.</div>
|
||||
) : (
|
||||
<PostList posts={posts} activeID={selected} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<PostDetail postID={selected} onDeleted={load} />
|
||||
</div>
|
||||
|
||||
{composing && keyFile && (
|
||||
<ComposeModal
|
||||
onClose={() => setComposing(false)}
|
||||
onPublished={() => {
|
||||
setComposing(false);
|
||||
// Re-pull so the new post shows up immediately.
|
||||
setTimeout(load, 800);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function titleFor(tab: FeedTab): string {
|
||||
switch (tab.kind) {
|
||||
case 'foryou': return 'For You';
|
||||
case 'timeline': return 'Following';
|
||||
case 'trending': return 'Trending 24h';
|
||||
case 'hashtag': return `#${tab.tag}`;
|
||||
case 'author': return 'Author wall';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchByTab(tab: FeedTab, selfPub: string | undefined): Promise<FeedPostItem[]> {
|
||||
switch (tab.kind) {
|
||||
case 'foryou':
|
||||
if (!selfPub) return fetchTrending(24, 30);
|
||||
return fetchForYou(selfPub, 30);
|
||||
case 'timeline':
|
||||
if (!selfPub) return [];
|
||||
return fetchTimeline(selfPub, { limit: 30 });
|
||||
case 'trending':
|
||||
return fetchTrending(24, 30);
|
||||
case 'hashtag':
|
||||
return fetchHashtag(tab.tag, 30);
|
||||
case 'author':
|
||||
return fetchAuthorPosts(tab.pub, { limit: 30 });
|
||||
}
|
||||
}
|
||||
146
desktop/src/sections/feed/FeedTabs.tsx
Normal file
146
desktop/src/sections/feed/FeedTabs.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// FeedTabs — left-pane navigation for the Feed section.
|
||||
//
|
||||
// Four top-level tabs (For You / Following / Trending / Hashtag) plus
|
||||
// an inline hashtag input that promotes to a dedicated tab when you
|
||||
// press Enter. Sub-states — viewing a specific author's wall — are
|
||||
// reachable by clicking an @handle in the post list; a breadcrumb
|
||||
// appears at the top for back-navigation.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useStore, type FeedTab } from '@/lib/store';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
|
||||
interface TabOption {
|
||||
kind: FeedTab['kind'];
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const STATIC_TABS: TabOption[] = [
|
||||
{ kind: 'foryou', label: 'For You', hint: 'Recommended posts from authors you don\'t follow yet' },
|
||||
{ kind: 'timeline', label: 'Following', hint: 'Posts from authors you follow' },
|
||||
{ kind: 'trending', label: 'Trending 24h', hint: 'Top posts by engagement in the last day' },
|
||||
];
|
||||
|
||||
export function FeedTabs(): React.ReactElement {
|
||||
const tab = useStore(s => s.feedTab);
|
||||
const setTab = useStore(s => s.setFeedTab);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
|
||||
// Sub-tab renderers reachable from list items (author wall, hashtag tab).
|
||||
// Instead of hiding them in a dropdown we surface a breadcrumb so the
|
||||
// operator can jump back out cleanly.
|
||||
const breadcrumb = renderBreadcrumb(tab, setTab);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 10 }}>
|
||||
{breadcrumb}
|
||||
|
||||
{STATIC_TABS.map(t => (
|
||||
<TabRow
|
||||
key={t.kind}
|
||||
label={t.label}
|
||||
hint={t.hint}
|
||||
active={tab.kind === t.kind}
|
||||
onClick={() => setTab({ kind: t.kind } as FeedTab)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hashtag input — promotes to a tab on Enter. */}
|
||||
<div style={{
|
||||
marginTop: 14, padding: 10, borderRadius: 10,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1.2, textTransform: 'uppercase', marginBottom: 6,
|
||||
}}>Hashtag</div>
|
||||
<input
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value.replace(/^#/, ''))}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && tagInput.trim().length > 0) {
|
||||
setTab({ kind: 'hashtag', tag: tagInput.trim() });
|
||||
}
|
||||
}}
|
||||
placeholder="type a tag…"
|
||||
style={{
|
||||
width: '100%', background: '#000',
|
||||
border: '1px solid #1f1f1f', borderRadius: 8,
|
||||
padding: '8px 10px', color: '#fff', fontSize: 13,
|
||||
fontFamily: 'monospace', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabRow({
|
||||
label, hint, active, onClick,
|
||||
}: {
|
||||
label: string; hint: string; active: boolean; onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
|
||||
background: active ? '#0a1a29' : 'transparent',
|
||||
border: active ? '1px solid #1d9bf022' : '1px solid transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{
|
||||
color: active ? '#1d9bf0' : '#fff',
|
||||
fontSize: 14, fontWeight: 700,
|
||||
}}>{label}</div>
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2, lineHeight: 1.4 }}>
|
||||
{hint}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBreadcrumb(tab: FeedTab, setTab: (t: FeedTab) => void): React.ReactNode | null {
|
||||
if (tab.kind === 'hashtag') {
|
||||
return (
|
||||
<Breadcrumb
|
||||
label={`#${tab.tag}`}
|
||||
onClear={() => setTab({ kind: 'foryou' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tab.kind === 'author') {
|
||||
return (
|
||||
<Breadcrumb
|
||||
label={`Author: ${shortAddr(tab.pub, 6)}`}
|
||||
onClear={() => setTab({ kind: 'foryou' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function Breadcrumb({ label, onClear }: { label: string; onClear: () => void }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 10px', marginBottom: 10,
|
||||
borderRadius: 8, background: '#0a0a0a',
|
||||
border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<button
|
||||
onClick={onClear}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', color: '#8b8b8b',
|
||||
cursor: 'pointer', padding: 0, fontSize: 14,
|
||||
}}
|
||||
>←</button>
|
||||
<div style={{ color: '#fff', fontSize: 13, fontWeight: 700, flex: 1 }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
desktop/src/sections/feed/PostDetail.tsx
Normal file
230
desktop/src/sections/feed/PostDetail.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
// PostDetail — right-hand-inner pane showing a single post with full
|
||||
// body, attachment, engagement bar, delete-if-mine.
|
||||
//
|
||||
// Side effects on mount:
|
||||
// * bumps the view counter (off-chain)
|
||||
// * refreshes stats for the liked-by-me badge
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
fetchPost, fetchStats, bumpView, likePost, unlikePost, deletePost,
|
||||
attachmentURL, type FeedPostItem, type PostStats,
|
||||
} from '@/lib/feed';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { humanizeTxError } from '@/lib/tx';
|
||||
|
||||
interface Props {
|
||||
postID: string | null;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
export function PostDetail({ postID, onDeleted }: Props): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const setTab = useStore(s => s.setFeedTab);
|
||||
|
||||
const [post, setPost] = useState<FeedPostItem | null>(null);
|
||||
const [stats, setStats] = useState<PostStats | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load + side-effects.
|
||||
useEffect(() => {
|
||||
if (!postID) { setPost(null); setStats(null); return; }
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
fetchPost(postID).then(p => { if (!cancelled) setPost(p); }).catch(() => {});
|
||||
fetchStats(postID, keyFile?.pub_key).then(s => { if (!cancelled) setStats(s); }).catch(() => {});
|
||||
bumpView(postID);
|
||||
return () => { cancelled = true; };
|
||||
}, [postID, keyFile?.pub_key]);
|
||||
|
||||
const toggleLike = useCallback(async () => {
|
||||
if (!keyFile || !post || busy) return;
|
||||
const liked = stats?.liked_by_me ?? false;
|
||||
setBusy(true); setError(null);
|
||||
// Optimistic — roll back if the tx fails.
|
||||
setStats(s => s ? { ...s, liked_by_me: !liked, likes: s.likes + (liked ? -1 : 1) } : s);
|
||||
try {
|
||||
if (liked) await unlikePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||||
else await likePost ({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||||
} catch (e) {
|
||||
setStats(s => s ? { ...s, liked_by_me: liked, likes: s.likes + (liked ? 1 : -1) } : s);
|
||||
setError(humanizeTxError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [keyFile, post, stats, busy]);
|
||||
|
||||
const onDelete = useCallback(async () => {
|
||||
if (!keyFile || !post || busy) return;
|
||||
if (!confirm('Delete this post? This cannot be undone.')) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await deletePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||||
onDeleted();
|
||||
useStore.getState().setFeedSelectedPost(null);
|
||||
} catch (e) {
|
||||
setError(humanizeTxError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [keyFile, post, busy, onDeleted]);
|
||||
|
||||
if (!postID) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#6a6a6a', fontSize: 13, padding: 40, textAlign: 'center',
|
||||
}}>
|
||||
Select a post from the list on the left.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#6a6a6a', fontSize: 13,
|
||||
}}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mine = !!keyFile && keyFile.pub_key === post.author;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', overflowY: 'auto',
|
||||
padding: '18px 22px', background: '#000',
|
||||
}}>
|
||||
{/* Author line */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 18,
|
||||
background: '#1a1a1a',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#d0d0d0', fontWeight: 700,
|
||||
}}>
|
||||
{post.author.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<button
|
||||
onClick={() => setTab({ kind: 'author', pub: post.author })}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', padding: 0,
|
||||
color: '#fff', fontWeight: 700, fontSize: 14, cursor: 'pointer',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{shortAddr(post.author, 8)}
|
||||
</button>
|
||||
<div style={{ color: '#8b8b8b', fontSize: 11 }}>
|
||||
{new Date(post.created_at * 1000).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
{mine && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 999,
|
||||
border: '1px solid #3a2020', background: 'transparent',
|
||||
color: '#ff6b6b', fontSize: 11, fontWeight: 700,
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
}}
|
||||
>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="selectable" style={{
|
||||
color: '#fff', fontSize: 15, lineHeight: 1.55,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
{renderBody(post.content, setTab)}
|
||||
</div>
|
||||
|
||||
{post.has_attachment && (
|
||||
<img
|
||||
src={attachmentURL(post.post_id)}
|
||||
alt=""
|
||||
style={{
|
||||
maxWidth: '100%', maxHeight: 520, borderRadius: 14,
|
||||
display: 'block', marginBottom: 14,
|
||||
}}
|
||||
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Engagement bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 16, alignItems: 'center',
|
||||
padding: '10px 0', borderTop: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<button
|
||||
onClick={toggleLike}
|
||||
disabled={busy || !keyFile}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: stats?.liked_by_me ? '#f4212e' : '#8b8b8b',
|
||||
fontSize: 13, fontWeight: 700, cursor: keyFile ? 'pointer' : 'default',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
{stats?.liked_by_me ? '❤' : '♡'} {stats?.likes ?? post.likes}
|
||||
</button>
|
||||
<div style={{ color: '#8b8b8b', fontSize: 13 }}>
|
||||
👁 {stats?.views ?? post.views}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
marginTop: 12, padding: 10, borderRadius: 10,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||
}}>{error}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render post body with #hashtags turned into clickable buttons that
|
||||
* jump the feed tab. Basic — no markdown, no emoji polish yet.
|
||||
*/
|
||||
function renderBody(
|
||||
text: string,
|
||||
setTab: (t: { kind: 'hashtag'; tag: string }) => void,
|
||||
): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const re = /(#[A-Za-z0-9_\u0400-\u04FF]{1,40})/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) parts.push(text.slice(last, m.index));
|
||||
const tag = m[1].slice(1);
|
||||
parts.push(
|
||||
<button
|
||||
key={`tag-${m.index}`}
|
||||
onClick={() => setTab({ kind: 'hashtag', tag })}
|
||||
style={{
|
||||
color: '#1d9bf0', background: 'transparent', border: 'none',
|
||||
padding: 0, font: 'inherit', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{m[1]}
|
||||
</button>,
|
||||
);
|
||||
last = m.index + m[1].length;
|
||||
}
|
||||
if (last < text.length) parts.push(text.slice(last));
|
||||
return parts;
|
||||
}
|
||||
93
desktop/src/sections/feed/PostList.tsx
Normal file
93
desktop/src/sections/feed/PostList.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// PostList — rows within the Feed middle column. Clicking a row sets
|
||||
// the selected post in the store; the detail pane reacts.
|
||||
|
||||
import React from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import type { FeedPostItem } from '@/lib/feed';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
|
||||
interface Props {
|
||||
posts: FeedPostItem[];
|
||||
activeID: string | null;
|
||||
}
|
||||
|
||||
export function PostList({ posts, activeID }: Props): React.ReactElement {
|
||||
const select = useStore(s => s.setFeedSelectedPost);
|
||||
return (
|
||||
<div>
|
||||
{posts.map(p => (
|
||||
<PostRow
|
||||
key={p.post_id}
|
||||
post={p}
|
||||
active={p.post_id === activeID}
|
||||
onClick={() => select(p.post_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostRow({ post, active, onClick }: {
|
||||
post: FeedPostItem; active: boolean; onClick: () => void;
|
||||
}) {
|
||||
const author = shortAddr(post.author, 6);
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '12px 14px', borderBottom: '1px solid #1f1f1f',
|
||||
cursor: 'pointer',
|
||||
background: active ? '#0a1a29' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
color: '#8b8b8b', fontSize: 11, marginBottom: 4,
|
||||
}}>
|
||||
<span style={{ fontFamily: 'monospace', color: '#d0d0d0' }}>{author}</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelative(post.created_at)}</span>
|
||||
</div>
|
||||
<div className="selectable" style={{
|
||||
color: '#fff', fontSize: 13, lineHeight: 1.45,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
// Visual truncate; the detail pane shows the full thing.
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 4,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
} as React.CSSProperties}>
|
||||
{post.content}
|
||||
</div>
|
||||
{post.has_attachment && (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 4 }}>
|
||||
🖼 attachment
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
color: '#6a6a6a', fontSize: 11, marginTop: 6,
|
||||
display: 'flex', gap: 12,
|
||||
}}>
|
||||
<span>❤ {post.likes}</span>
|
||||
<span>👁 {post.views}</span>
|
||||
{post.hashtags && post.hashtags.length > 0 && (
|
||||
<span style={{ color: '#1d9bf0' }}>
|
||||
{post.hashtags.slice(0, 3).map(t => `#${t}`).join(' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(unixSec: number): string {
|
||||
const diff = Math.floor(Date.now() / 1000) - unixSec;
|
||||
if (diff < 60) return `${diff}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
||||
const d = new Date(unixSec * 1000);
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
6
desktop/src/sections/feed/index.tsx
Normal file
6
desktop/src/sections/feed/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// Feed section — re-exports into the Shell's PANES map. Real
|
||||
// implementation lives in FeedTabs (left) + FeedPane (right); they
|
||||
// share state via zustand's store.feedTab / store.feedSelectedPost.
|
||||
|
||||
export { FeedTabs as FeedList } from './FeedTabs';
|
||||
export { FeedPane as FeedDetail } from './FeedPane';
|
||||
165
desktop/src/sections/messages/ChatList.tsx
Normal file
165
desktop/src/sections/messages/ChatList.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
// ChatList — the Messages left-pane list of conversations.
|
||||
// Rows sort by last-activity timestamp (most recent first); empty state
|
||||
// renders as a full-height notice so the layout doesn't collapse.
|
||||
|
||||
import React from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import type { Contact, Message } from '@/lib/types';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
|
||||
export function ChatList(): React.ReactElement {
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const messages = useStore(s => s.messages);
|
||||
const unread = useStore(s => s.unread);
|
||||
const activeChat = useStore(s => s.activeChat);
|
||||
const setActive = useStore(s => s.setActiveChat);
|
||||
|
||||
const lastOf = (c: Contact): Message | null => {
|
||||
const list = messages[c.address];
|
||||
return list && list.length > 0 ? list[list.length - 1] : null;
|
||||
};
|
||||
|
||||
const sorted = [...contacts]
|
||||
.map(c => ({ c, last: lastOf(c) }))
|
||||
.sort((a, b) => {
|
||||
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
|
||||
const kb = b.last ? b.last.timestamp : b.c.addedAt / 1000;
|
||||
return kb - ka;
|
||||
});
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 28, textAlign: 'center', color: '#8b8b8b', fontSize: 13,
|
||||
}}>
|
||||
No conversations yet. Messages from pairing devices or contacts
|
||||
will appear here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sorted.map(({ c, last }) => (
|
||||
<ChatRow
|
||||
key={c.address}
|
||||
contact={c}
|
||||
last={last}
|
||||
unread={unread[c.address] ?? 0}
|
||||
active={activeChat === c.address}
|
||||
onClick={() => {
|
||||
setActive(c.address);
|
||||
useStore.getState().clearUnread(c.address);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatRow({
|
||||
contact, last, unread, active, onClick,
|
||||
}: {
|
||||
contact: Contact;
|
||||
last: Message | null;
|
||||
unread: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const name = contact.alias || contact.username
|
||||
? (contact.username ? `@${contact.username}` : contact.alias!)
|
||||
: shortAddr(contact.address, 6);
|
||||
|
||||
const time = last
|
||||
? formatWhen(last.timestamp)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
borderBottom: '1px solid #1f1f1f',
|
||||
background: active ? '#0a1a29' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<Avatar name={name} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#fff', fontSize: 14, fontWeight: 700,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{name}
|
||||
</div>
|
||||
{time && (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, flexShrink: 0 }}>
|
||||
{time}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, marginTop: 3,
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1, color: '#8b8b8b', fontSize: 12,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{last ? preview(last) : 'Tap to start'}
|
||||
</div>
|
||||
{unread > 0 && (
|
||||
<div style={{
|
||||
minWidth: 18, height: 18, borderRadius: 9,
|
||||
padding: '0 6px', background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 11, fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ name }: { name: string }) {
|
||||
const letter = name.replace(/^@/, '').charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 20, flexShrink: 0,
|
||||
background: '#1a1a1a',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#d0d0d0', fontWeight: 700,
|
||||
}}>{letter}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function preview(m: Message): string {
|
||||
const t = m.text.trim();
|
||||
if (t.length === 0) return m.attachment ? '(attachment)' : '';
|
||||
return t.length > 60 ? t.slice(0, 60) + '…' : t;
|
||||
}
|
||||
|
||||
function formatWhen(unixSec: number): string {
|
||||
const d = new Date(unixSec * 1000);
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
if (sameDay) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
return sameYear
|
||||
? d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
: d.toLocaleDateString();
|
||||
}
|
||||
213
desktop/src/sections/messages/Conversation.tsx
Normal file
213
desktop/src/sections/messages/Conversation.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
// Conversation — the Messages right-pane showing one chat + composer.
|
||||
//
|
||||
// Responsibilities:
|
||||
// * Render header with contact identity + close button.
|
||||
// * Auto-scroll the message list to the bottom on new arrival.
|
||||
// * Composer with Enter-to-send, Shift+Enter for newline.
|
||||
// * Fan out every outgoing message across the recipient's device
|
||||
// registry (falls back to legacy single-X25519 for pre-v2.2.0
|
||||
// peers). One envelope per device; Promise.all, any failure
|
||||
// rejects the batch so the user sees it.
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { encryptMessage, shortAddr } from '@/lib/crypto';
|
||||
import { sendEnvelope, resolveRecipientKeys } from '@/lib/relay';
|
||||
import { appendMessage as persist } from '@/lib/storage';
|
||||
import type { Message } from '@/lib/types';
|
||||
|
||||
export function Conversation({ address }: { address: string }): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const contact = useStore(s => s.contacts.find(c => c.address === address));
|
||||
const messages = useStore(s => s.messages[address] ?? []);
|
||||
const clearUnread = useStore(s => s.clearUnread);
|
||||
const appendMsg = useStore(s => s.appendMessage);
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Seeing a conversation drops its unread count.
|
||||
useEffect(() => { clearUnread(address); }, [address, clearUnread]);
|
||||
|
||||
// Pin the scroll to the bottom on new messages. Only if the user
|
||||
// is already near the bottom — don't yank them back if they're
|
||||
// scrolling through older history.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||
if (nearBottom) el.scrollTop = el.scrollHeight;
|
||||
}, [messages.length]);
|
||||
|
||||
const isSelf = !!keyFile && keyFile.pub_key === address;
|
||||
|
||||
const send = async () => {
|
||||
if (!keyFile || sending) return;
|
||||
const body = text.trim();
|
||||
if (!body) return;
|
||||
|
||||
setSending(true); setError(null);
|
||||
try {
|
||||
// Saved Messages path — the conversation address equals our own
|
||||
// master pub. Mobile parity: append locally, skip the relay
|
||||
// round-trip entirely (no fees, no ciphertext ever leaves).
|
||||
if (!isSelf) {
|
||||
const pubs = await resolveRecipientKeys(address);
|
||||
if (pubs.length === 0) {
|
||||
throw new Error('recipient has no encryption key published');
|
||||
}
|
||||
await Promise.all(pubs.map(async (rpub) => {
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
body, keyFile.x25519_priv, rpub,
|
||||
);
|
||||
await sendEnvelope({
|
||||
senderPub: keyFile.x25519_pub,
|
||||
recipientPub: rpub,
|
||||
senderEd25519Pub: keyFile.pub_key,
|
||||
nonce, ciphertext,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
const m: Message = {
|
||||
id: `out-${Date.now()}${Math.floor(Math.random() * 1e6)}`,
|
||||
from: keyFile.x25519_pub,
|
||||
text: body,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
mine: true,
|
||||
read: false,
|
||||
edited: false,
|
||||
};
|
||||
appendMsg(address, m);
|
||||
persist(address, m);
|
||||
setText('');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
const name = contact?.username ? `@${contact.username}`
|
||||
: contact?.alias
|
||||
? contact.alias
|
||||
: isSelf
|
||||
? 'Saved Messages'
|
||||
: shortAddr(address, 8);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '12px 16px', borderBottom: '1px solid #1f1f1f',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 16,
|
||||
background: isSelf ? '#1d9bf0' : '#1a1a1a',
|
||||
color: '#fff', fontWeight: 700, fontSize: 14,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{isSelf ? '★' : name.replace(/^@/, '').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>{name}</div>
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace' }}>
|
||||
{shortAddr(address, 6)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{ flex: 1, overflowY: 'auto', padding: '14px 16px' }}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div style={{
|
||||
color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||
marginTop: 40,
|
||||
}}>
|
||||
{isSelf
|
||||
? 'Notes to self. Messages here stay on this device only.'
|
||||
: 'No messages yet. Type below to send the first one.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{messages.map(m => <Bubble key={m.id} message={m} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div style={{
|
||||
borderTop: '1px solid #1f1f1f', padding: 12,
|
||||
display: 'flex', gap: 10, alignItems: 'flex-end',
|
||||
}}>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Message…"
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1, resize: 'none',
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
borderRadius: 10, padding: '10px 12px',
|
||||
color: '#fff', fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none', lineHeight: 1.4, maxHeight: 140,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={sending || text.trim().length === 0}
|
||||
style={{
|
||||
padding: '10px 16px', borderRadius: 999, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff', fontSize: 13, fontWeight: 700,
|
||||
cursor: sending || text.trim().length === 0 ? 'default' : 'pointer',
|
||||
opacity: sending || text.trim().length === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{sending ? '…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '6px 16px 10px', fontSize: 11, color: '#ff6b6b',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({ message }: { message: Message }) {
|
||||
const mine = message.mine;
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: mine ? 'flex-end' : 'flex-start',
|
||||
}}>
|
||||
<div className="selectable" style={{
|
||||
maxWidth: '70%',
|
||||
padding: '8px 12px', borderRadius: 14,
|
||||
background: mine ? '#1d9bf0' : '#1a1a1a',
|
||||
color: mine ? '#fff' : '#e0e0e0',
|
||||
fontSize: 13, lineHeight: 1.45,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
desktop/src/sections/messages/EmptyConversation.tsx
Normal file
13
desktop/src/sections/messages/EmptyConversation.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export function EmptyConversation(): React.ReactElement {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 40, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||
}}>
|
||||
Select a conversation from the list,<br/>or wait for one to appear as messages arrive.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
desktop/src/sections/messages/index.tsx
Normal file
44
desktop/src/sections/messages/index.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// Messages section — full implementation. Left pane is the chat list;
|
||||
// right pane mounts the active conversation or an empty-state.
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { loadMessages } from '@/lib/storage';
|
||||
import { useInboxPoll } from '@/hooks/useInboxPoll';
|
||||
import { ChatList } from './ChatList';
|
||||
import { Conversation } from './Conversation';
|
||||
import { EmptyConversation } from './EmptyConversation';
|
||||
|
||||
export function MessagesList(): React.ReactElement {
|
||||
// Warm cached messages from localStorage once per mount, so toggling
|
||||
// back into Messages after visiting another section doesn't forget
|
||||
// history. Zustand wins on conflict — once the hook has appended
|
||||
// live messages, we don't overwrite them with stale disk snapshots.
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const setMsgs = useStore(s => s.setMessages);
|
||||
const hydrated = useRef(false);
|
||||
|
||||
// Kick off the inbox polling loop while Messages is mounted.
|
||||
// Section-scoped for now so we don't pay the bandwidth cost when
|
||||
// the user is in Feed / Wallet / etc.; a future alpha can promote
|
||||
// it to the shell if we want notifications in other sections too.
|
||||
useInboxPoll();
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated.current) return;
|
||||
hydrated.current = true;
|
||||
const st = useStore.getState();
|
||||
for (const c of contacts) {
|
||||
if ((st.messages[c.address] ?? []).length > 0) continue;
|
||||
const cached = loadMessages(c.address);
|
||||
if (cached.length > 0) setMsgs(c.address, cached);
|
||||
}
|
||||
}, [contacts, setMsgs]);
|
||||
|
||||
return <ChatList />;
|
||||
}
|
||||
|
||||
export function MessagesDetail(): React.ReactElement {
|
||||
const activeChat = useStore(s => s.activeChat);
|
||||
return activeChat ? <Conversation address={activeChat} /> : <EmptyConversation />;
|
||||
}
|
||||
218
desktop/src/sections/profile/index.tsx
Normal file
218
desktop/src/sections/profile/index.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
// Profile section — "You" view. List pane shows the avatar card,
|
||||
// detail pane shows stats + devices summary.
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getBalance, getIdentity, fetchDevices, type IdentityInfo, type DeviceInfo } from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
|
||||
function formatT(ut: number): string {
|
||||
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
export function ProfileList(): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const contactsCount = useStore(s => s.contacts.length);
|
||||
if (!keyFile) return <></>;
|
||||
const letter = keyFile.pub_key.slice(0, 1).toUpperCase();
|
||||
return (
|
||||
<div style={{ padding: 14 }}>
|
||||
<div style={{
|
||||
padding: 16, borderRadius: 14,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 72, height: 72, borderRadius: 36, margin: '0 auto 10px',
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 30, fontWeight: 800,
|
||||
}}>{letter}</div>
|
||||
<div style={{ color: '#fff', fontSize: 18, fontWeight: 700 }}>
|
||||
You
|
||||
</div>
|
||||
<div className="selectable" style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||
marginTop: 6, wordBreak: 'break-all',
|
||||
}}>{keyFile.pub_key}</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: 14, padding: 12, borderRadius: 12,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
color: '#8b8b8b', fontSize: 12, lineHeight: 1.5,
|
||||
}}>
|
||||
{contactsCount} contact{contactsCount === 1 ? '' : 's'} stored on this device.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileDetail(): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const setSection = useStore(s => s.setSection);
|
||||
const setPage = useStore(s => s.setSettingsPage);
|
||||
const setFeedTab = useStore(s => s.setFeedTab);
|
||||
|
||||
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
|
||||
const [balance, setBalance] = useState<number | null>(null);
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [id, bal, devs] = await Promise.all([
|
||||
getIdentity(keyFile.pub_key),
|
||||
getBalance(keyFile.pub_key),
|
||||
fetchDevices(keyFile.pub_key),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setIdentity(id);
|
||||
setBalance(bal);
|
||||
setDevices(devs);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [keyFile]);
|
||||
|
||||
if (!keyFile) return <></>;
|
||||
|
||||
const copy = (s: string) => navigator.clipboard.writeText(s).catch(() => {});
|
||||
|
||||
const viewMyPosts = () => {
|
||||
setFeedTab({ kind: 'author', pub: keyFile.pub_key });
|
||||
setSection('feed');
|
||||
};
|
||||
const openDevices = () => {
|
||||
setSection('settings');
|
||||
setPage('devices');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', overflowY: 'auto',
|
||||
padding: '22px 26px', background: '#000',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between',
|
||||
gap: 12, flexWrap: 'wrap',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, letterSpacing: 1, textTransform: 'uppercase',
|
||||
}}>Balance</div>
|
||||
<div style={{ color: '#fff', fontSize: 34, fontWeight: 800 }}>
|
||||
{balance === null ? '—' : `${formatT(balance)} T`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Action onClick={viewMyPosts}>My posts</Action>
|
||||
<Action onClick={openDevices}>Manage devices ({devices.length})</Action>
|
||||
<Action onClick={() => copy(keyFile.pub_key)}>Copy address</Action>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{identity && (
|
||||
<div style={{
|
||||
marginTop: 24, padding: 14, borderRadius: 12,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<Row k="DC address" v={identity.address} onCopy={() => copy(identity.address)} />
|
||||
<Row k="Username" v={identity.nickname ? `@${identity.nickname}` : '—'} />
|
||||
<Row k="Published X25519" v={shortAddr(identity.x25519_pub, 10) || '—'} />
|
||||
<Row k="Registered" v={identity.registered ? 'yes' : 'no'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Devices summary */}
|
||||
<div style={{
|
||||
marginTop: 14, padding: 14, borderRadius: 12,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 10,
|
||||
}}>Linked devices</div>
|
||||
{devices.length === 0 ? (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 13 }}>
|
||||
No devices registered yet.
|
||||
</div>
|
||||
) : (
|
||||
devices.map((d, i) => (
|
||||
<div
|
||||
key={d.x25519_pub_key}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 0',
|
||||
borderTop: i === 0 ? undefined : '1px solid #141414',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 6,
|
||||
background: d.x25519_pub_key === keyFile.x25519_pub ? '#0d2540' : '#1a1a1a',
|
||||
color: d.x25519_pub_key === keyFile.x25519_pub ? '#1d9bf0' : '#d0d0d0',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14,
|
||||
}}>📱</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: '#fff', fontSize: 13, fontWeight: 600 }}>
|
||||
{d.device_name}
|
||||
</div>
|
||||
<div style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace' }}>
|
||||
{shortAddr(d.x25519_pub_key, 8)}
|
||||
</div>
|
||||
</div>
|
||||
{d.x25519_pub_key === keyFile.x25519_pub && (
|
||||
<span style={{
|
||||
padding: '1px 6px', borderRadius: 6,
|
||||
background: '#0d2540', color: '#1d9bf0',
|
||||
fontSize: 10, fontWeight: 700,
|
||||
}}>THIS DEVICE</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Action({ children, onClick }: {
|
||||
children: React.ReactNode; onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '9px 14px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #1f1f1f',
|
||||
color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>{children}</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ k, v, onCopy }: { k: string; v: string; onCopy?: () => void }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', padding: '6px 0',
|
||||
borderBottom: '1px solid #141414',
|
||||
alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<div style={{ color: '#8b8b8b', fontSize: 12, flex: '0 0 160px' }}>{k}</div>
|
||||
<div className="selectable" style={{
|
||||
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||
flex: 1, wordBreak: 'break-all',
|
||||
}}>{v}</div>
|
||||
{onCopy && (
|
||||
<button
|
||||
onClick={onCopy}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: '#1d9bf0', fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>copy</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
desktop/src/sections/settings/AboutPage.tsx
Normal file
59
desktop/src/sections/settings/AboutPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
// AboutPage — version info, platform, build links. Reads app.version
|
||||
// via the preload IPC bridge.
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PageLayout } from './PageLayout';
|
||||
import { Card, Label, Hint } from './NodePage';
|
||||
|
||||
export function AboutPage(): React.ReactElement {
|
||||
const [version, setVersion] = useState('dev');
|
||||
const [platform, setPlatform] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
window.dchain?.app.version().then(setVersion).catch(() => {});
|
||||
window.dchain?.app.platform().then(setPlatform).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout title="About">
|
||||
<Card>
|
||||
<Label>Build</Label>
|
||||
<div style={{ color: '#fff', fontSize: 14, fontFamily: 'monospace' }}>
|
||||
DChain Desktop v{version}
|
||||
</div>
|
||||
<Hint>
|
||||
Running on {platform || 'unknown'} · Electron / Chromium
|
||||
</Hint>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Label>Links</Label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<LinkRow
|
||||
href="https://git.vsecoder.vodka/vsecoder/dchain"
|
||||
label="Source code (Gitea)"
|
||||
/>
|
||||
<LinkRow
|
||||
href="https://git.vsecoder.vodka/vsecoder/dchain/releases"
|
||||
label="Releases"
|
||||
/>
|
||||
<LinkRow
|
||||
href="https://git.vsecoder.vodka/vsecoder/dchain/src/branch/main/docs"
|
||||
label="Documentation"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkRow({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#1d9bf0', fontSize: 13, textDecoration: 'none' }}
|
||||
>{label} ↗</a>
|
||||
);
|
||||
}
|
||||
353
desktop/src/sections/settings/DevicesPage.tsx
Normal file
353
desktop/src/sections/settings/DevicesPage.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
// DevicesPage — multi-device registry UI.
|
||||
//
|
||||
// Top: list of on-chain devices for this identity. Each row has:
|
||||
// * badge for "this device" (cannot be unlinked from here — you'd
|
||||
// wipe yourself on next boot)
|
||||
// * device name + truncated X25519 pub + added-at
|
||||
// * Unlink button for others (submits UNLINK_DEVICE tx)
|
||||
//
|
||||
// Bottom: "Link new device" modal, same protocol as mobile's
|
||||
// Settings → Devices → Link new device.
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
fetchDevices, type DeviceInfo,
|
||||
} from '@/lib/api';
|
||||
import { buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx, humanizeTxError } from '@/lib/tx';
|
||||
import { sendEnvelope } from '@/lib/relay';
|
||||
import { encryptMessage, shortAddr } from '@/lib/crypto';
|
||||
import { PageLayout } from './PageLayout';
|
||||
import { Card, Label, Hint, inputStyle } from './NodePage';
|
||||
import { Button } from './IdentityPage';
|
||||
|
||||
export function DevicesPage(): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [devs, setDevs] = useState<DeviceInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [unlinking, setUnlinking] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!keyFile) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setDevs(await fetchDevices(keyFile.pub_key));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyFile]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const onUnlink = useCallback(async (d: DeviceInfo) => {
|
||||
if (!keyFile) return;
|
||||
if (!confirm(
|
||||
`Unlink "${d.device_name}"? It will stop receiving messages sent to you. ` +
|
||||
`The device itself will wipe its local state next time it checks in. ` +
|
||||
`This costs a small network fee.`,
|
||||
)) return;
|
||||
setUnlinking(d.x25519_pub_key);
|
||||
setNotice(null);
|
||||
try {
|
||||
const tx = buildUnlinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: d.x25519_pub_key,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
setDevs(prev => prev.filter(x => x.x25519_pub_key !== d.x25519_pub_key));
|
||||
setNotice(`Unlinked — registry will converge in a block or two.`);
|
||||
} catch (e) {
|
||||
setNotice(`Unlink failed: ${humanizeTxError(e)}`);
|
||||
} finally {
|
||||
setUnlinking(null);
|
||||
}
|
||||
}, [keyFile]);
|
||||
|
||||
const meX25519 = keyFile?.x25519_pub ?? '';
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Devices"
|
||||
subtitle="Every linked device gets its own encryption key; messages sent to you are delivered to all of them."
|
||||
>
|
||||
<Card>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
}}>
|
||||
<Label>Linked devices</Label>
|
||||
<Button onClick={() => setLinkOpen(true)}>Link new device</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 12 }}>Loading…</div>
|
||||
) : devs.length === 0 ? (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 12 }}>
|
||||
No devices registered yet. This device auto-links once a small
|
||||
network fee is available in your balance — pull to refresh after
|
||||
a first transfer if the list stays empty.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{devs.map((d, i) => (
|
||||
<DeviceRow
|
||||
key={d.x25519_pub_key}
|
||||
d={d}
|
||||
isMe={d.x25519_pub_key === meX25519}
|
||||
unlinking={unlinking === d.x25519_pub_key}
|
||||
onUnlink={() => onUnlink(d)}
|
||||
first={i === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div style={{
|
||||
marginTop: 10, padding: 10, borderRadius: 8,
|
||||
background: notice.startsWith('Unlink failed')
|
||||
? '#2a1414' : '#0d2540',
|
||||
color: notice.startsWith('Unlink failed') ? '#ff9b9b' : '#1d9bf0',
|
||||
fontSize: 12,
|
||||
}}>{notice}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{linkOpen && (
|
||||
<LinkNewDeviceModal
|
||||
onClose={() => setLinkOpen(false)}
|
||||
onLinked={() => { setLinkOpen(false); setTimeout(load, 1000); }}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function DeviceRow({
|
||||
d, isMe, unlinking, onUnlink, first,
|
||||
}: {
|
||||
d: DeviceInfo; isMe: boolean; unlinking: boolean;
|
||||
onUnlink: () => void; first: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px 0',
|
||||
borderTop: first ? undefined : '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: isMe ? '#0d2540' : '#1a1a1a',
|
||||
color: isMe ? '#1d9bf0' : '#d0d0d0',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 16,
|
||||
}}>📱</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<span style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>
|
||||
{d.device_name || 'Unnamed device'}
|
||||
</span>
|
||||
{isMe && (
|
||||
<span style={{
|
||||
padding: '1px 6px', borderRadius: 6,
|
||||
background: '#0d2540', color: '#1d9bf0',
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: 0.5,
|
||||
}}>THIS DEVICE</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||
marginTop: 3,
|
||||
}}>
|
||||
{shortAddr(d.x25519_pub_key, 10)}
|
||||
</div>
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
|
||||
Linked {new Date(d.added_at * 1000).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
{!isMe && (
|
||||
<button
|
||||
onClick={onUnlink}
|
||||
disabled={unlinking}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #3a2020',
|
||||
color: '#ff6b6b', fontSize: 11, fontWeight: 700,
|
||||
cursor: unlinking ? 'default' : 'pointer',
|
||||
opacity: unlinking ? 0.5 : 1,
|
||||
}}
|
||||
>{unlinking ? '…' : 'Unlink'}</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Link New Device modal ───────────────────────────────────────────────
|
||||
|
||||
function LinkNewDeviceModal({
|
||||
onClose, onLinked,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onLinked: () => void;
|
||||
}): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [key, setKey] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!keyFile) return;
|
||||
const c = code.replace(/\s+/g, '').trim();
|
||||
const k = key.replace(/\s+/g, '').trim().toLowerCase();
|
||||
if (!/^\d{6}$/.test(c)) { setErr('Code must be 6 digits.'); return; }
|
||||
if (!/^[0-9a-f]{64}$/.test(k)) { setErr('Device key must be 64 hex chars.'); return; }
|
||||
const nm = name.trim() || 'New device';
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
// 1. LINK_DEVICE tx → registry learns the new pub.
|
||||
const linkTx = buildLinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: k,
|
||||
deviceName: nm,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(linkTx);
|
||||
// 2. Handshake envelope — encrypt master priv for the new device.
|
||||
const payload = JSON.stringify({
|
||||
v: 1,
|
||||
type: 'pair-handshake',
|
||||
code: c,
|
||||
master_pub: keyFile.pub_key,
|
||||
master_priv: keyFile.priv_key,
|
||||
master_x25519_pub: keyFile.x25519_pub,
|
||||
});
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
payload, keyFile.x25519_priv, k,
|
||||
);
|
||||
await sendEnvelope({
|
||||
senderPub: keyFile.x25519_pub,
|
||||
recipientPub: k,
|
||||
senderEd25519Pub: keyFile.pub_key,
|
||||
nonce, ciphertext,
|
||||
});
|
||||
onLinked();
|
||||
} catch (e) {
|
||||
setErr(humanizeTxError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => !busy && onClose()}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 20,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%', maxWidth: 520, padding: 20, borderRadius: 16,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>
|
||||
Link new device
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose} disabled={busy}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
<Hint>
|
||||
On the new device, tap <b>Pair</b> on the welcome screen and
|
||||
transcribe the 6-digit code and device key from there into the
|
||||
fields below.
|
||||
</Hint>
|
||||
|
||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Field>
|
||||
<Label>6-digit code</Label>
|
||||
<input
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
style={{ ...inputStyle, letterSpacing: 4, textAlign: 'center' }}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Device key (64 hex)</Label>
|
||||
<input
|
||||
value={key}
|
||||
onChange={e => setKey(e.target.value)}
|
||||
placeholder="a1b2c3…"
|
||||
spellCheck={false}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Name (optional)</Label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Alice's laptop"
|
||||
maxLength={64}
|
||||
style={{ ...inputStyle, fontFamily: 'inherit' }}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div style={{
|
||||
marginTop: 12, padding: 10, borderRadius: 8,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||
}}>{err}</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
style={{
|
||||
padding: '9px 14px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #1f1f1f',
|
||||
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
}}
|
||||
>Cancel</button>
|
||||
<Button onClick={submit} disabled={busy}>{busy ? '…' : 'Link'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ children }: { children: React.ReactNode }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
159
desktop/src/sections/settings/IdentityPage.tsx
Normal file
159
desktop/src/sections/settings/IdentityPage.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
// Identity settings — pub key, copy, export/import key file, delete account.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { saveKeyFile, wipeAllLocalState } from '@/lib/storage';
|
||||
import type { KeyFile } from '@/lib/types';
|
||||
import { PageLayout } from './PageLayout';
|
||||
import { Card, Label, Hint } from './NodePage';
|
||||
|
||||
export function IdentityPage(): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
if (!keyFile) return <PageLayout title="Identity"><div>No identity loaded.</div></PageLayout>;
|
||||
|
||||
const copy = async (s: string, label: string) => {
|
||||
await navigator.clipboard.writeText(s);
|
||||
setNotice(`${label} copied`);
|
||||
setTimeout(() => setNotice(null), 1500);
|
||||
};
|
||||
|
||||
const exportKey = async () => {
|
||||
const target = await window.dchain.dialog.saveFile({
|
||||
title: 'Export key file',
|
||||
defaultPath: 'node.json',
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||
});
|
||||
if (!target) return;
|
||||
try {
|
||||
await window.dchain.fs.writeText(target, JSON.stringify(keyFile, null, 2));
|
||||
setNotice('Key saved — keep it offline + backed up.');
|
||||
} catch (e) {
|
||||
setNotice(`Export failed: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const importKey = async () => {
|
||||
const src = await window.dchain.dialog.openFile({
|
||||
title: 'Import key file',
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
if (!src) return;
|
||||
try {
|
||||
const raw = await window.dchain.fs.readText(src);
|
||||
const parsed = JSON.parse(raw) as KeyFile;
|
||||
if (!parsed.pub_key || !parsed.priv_key) throw new Error('not a key file');
|
||||
if (!confirm('Replace the current identity with the imported one? The current identity will be wiped from this device.')) return;
|
||||
await wipeAllLocalState();
|
||||
await saveKeyFile(parsed);
|
||||
setKeyFile(parsed);
|
||||
setNotice('Imported — reload is not needed, new identity active.');
|
||||
} catch (e) {
|
||||
setNotice(`Import failed: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
if (!confirm('Delete this identity from this device? Keys are NOT recoverable from the server — export first if you want to keep them.')) return;
|
||||
await wipeAllLocalState();
|
||||
setKeyFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout title="Identity" subtitle="Your Ed25519 master key. Keep it safe — there is no password recovery.">
|
||||
<Card>
|
||||
<Label>Public key (Ed25519, hex)</Label>
|
||||
<div className="selectable" style={{
|
||||
color: '#fff', fontSize: 12, fontFamily: 'monospace',
|
||||
wordBreak: 'break-all', lineHeight: 1.5,
|
||||
}}>
|
||||
{keyFile.pub_key}
|
||||
</div>
|
||||
<ActionRow>
|
||||
<Button onClick={() => copy(keyFile.pub_key, 'Pub key')}>Copy</Button>
|
||||
</ActionRow>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Label>Device encryption key (X25519, hex)</Label>
|
||||
<div className="selectable" style={{
|
||||
color: '#fff', fontSize: 12, fontFamily: 'monospace',
|
||||
wordBreak: 'break-all', lineHeight: 1.5,
|
||||
}}>
|
||||
{keyFile.x25519_pub}
|
||||
</div>
|
||||
<Hint>
|
||||
Only this device uses this X25519 pair. Sharing the master Ed25519
|
||||
pub (above) is how contacts find you across all your devices.
|
||||
</Hint>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Label>Backup</Label>
|
||||
<ActionRow>
|
||||
<Button onClick={exportKey}>Export key file</Button>
|
||||
<Button onClick={importKey} danger>Import / replace</Button>
|
||||
</ActionRow>
|
||||
<Hint>
|
||||
Exports a JSON file compatible with the mobile client and
|
||||
server's <code>--key</code> flag. The file is <strong>not</strong>
|
||||
encrypted on disk — store it somewhere safe.
|
||||
</Hint>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Label>Danger zone</Label>
|
||||
<ActionRow>
|
||||
<Button onClick={deleteAccount} danger>Delete this identity</Button>
|
||||
</ActionRow>
|
||||
<Hint>
|
||||
Wipes the key, contacts, and chat cache from this device.
|
||||
Without an export, this is irreversible.
|
||||
</Hint>
|
||||
</Card>
|
||||
|
||||
{notice && (
|
||||
<div style={{
|
||||
padding: 10, borderRadius: 8,
|
||||
background: '#0d2540', color: '#1d9bf0', fontSize: 12,
|
||||
}}>{notice}</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap',
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children, onClick, danger, disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
padding: '8px 14px', borderRadius: 999,
|
||||
background: danger ? 'transparent' : '#1d9bf0',
|
||||
border: danger ? '1px solid #3a2020' : 'none',
|
||||
color: danger ? '#ff6b6b' : '#fff',
|
||||
fontSize: 12, fontWeight: 700,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>{children}</button>
|
||||
);
|
||||
}
|
||||
115
desktop/src/sections/settings/NodePage.tsx
Normal file
115
desktop/src/sections/settings/NodePage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
// Node settings page — URL, connection ping-on-commit, token field.
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getNetStats, setNodeUrl, setApiToken } from '@/lib/api';
|
||||
import { saveSettings } from '@/lib/storage';
|
||||
import { PageLayout } from './PageLayout';
|
||||
|
||||
export function NodePage(): React.ReactElement {
|
||||
const settings = useStore(s => s.settings);
|
||||
const setSettings = useStore(s => s.setSettings);
|
||||
|
||||
const [url, setUrl] = useState(settings.nodeUrl);
|
||||
const [token, setToken] = useState(settings.apiToken ?? '');
|
||||
const [ok, setOk] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => { setUrl(settings.nodeUrl); setToken(settings.apiToken ?? ''); },
|
||||
[settings.nodeUrl, settings.apiToken]);
|
||||
|
||||
const apply = async () => {
|
||||
const clean = url.trim().replace(/\/$/, '');
|
||||
if (!clean) return;
|
||||
setBusy(true); setOk(null);
|
||||
setNodeUrl(clean);
|
||||
setApiToken(token.trim() || null);
|
||||
try {
|
||||
await getNetStats();
|
||||
setOk(true);
|
||||
const next = { nodeUrl: clean, apiToken: token.trim() || undefined };
|
||||
setSettings(next);
|
||||
saveSettings(next);
|
||||
} catch {
|
||||
setOk(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dot = ok === true ? '#3ba55d' : ok === false ? '#f4212e' : '#8b8b8b';
|
||||
|
||||
return (
|
||||
<PageLayout title="Node" subtitle="Which DChain node this client talks to">
|
||||
<Card>
|
||||
<Label>Node URL</Label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 3.5, background: dot }} />
|
||||
<input
|
||||
value={url}
|
||||
onChange={e => { setUrl(e.target.value); setOk(null); }}
|
||||
onBlur={apply}
|
||||
onKeyDown={e => { if (e.key === 'Enter') apply(); }}
|
||||
placeholder="http://node.example:8080"
|
||||
spellCheck={false}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{busy && <span style={{ color: '#8b8b8b', fontSize: 11 }}>…</span>}
|
||||
</div>
|
||||
<Hint>
|
||||
Enter or tab-out to ping. Green dot = `/api/netstats` replied.
|
||||
</Hint>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Label>API token (optional)</Label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
onBlur={apply}
|
||||
placeholder="paste Bearer token if node requires it"
|
||||
spellCheck={false}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<Hint>
|
||||
Some nodes gate writes with DCHAIN_API_TOKEN; leave blank for
|
||||
public ones.
|
||||
</Hint>
|
||||
</Card>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reusable primitives (also imported by Identity / Devices / About) ───
|
||||
|
||||
export function Card({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 14, marginBottom: 14, borderRadius: 12,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
export function Label({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1.2, textTransform: 'uppercase', marginBottom: 8,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
export function Hint({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6, lineHeight: 1.5 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export const inputStyle: React.CSSProperties = {
|
||||
flex: 1, boxSizing: 'border-box',
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
borderRadius: 8, padding: '10px 12px',
|
||||
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||
outline: 'none', width: '100%',
|
||||
};
|
||||
33
desktop/src/sections/settings/PageLayout.tsx
Normal file
33
desktop/src/sections/settings/PageLayout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
// Shared layout for Settings subsection pages — sticky header with the
|
||||
// page title + scroll body. Keeps spacing consistent across Node /
|
||||
// Identity / Devices / About.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export function PageLayout({
|
||||
title, subtitle, children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', overflowY: 'auto', background: '#000',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'sticky', top: 0, zIndex: 1,
|
||||
padding: '14px 22px', borderBottom: '1px solid #1f1f1f',
|
||||
background: 'rgba(0,0,0,0.9)', backdropFilter: 'blur(6px)',
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: 16, fontWeight: 800 }}>{title}</div>
|
||||
{subtitle && (
|
||||
<div style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '18px 22px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
desktop/src/sections/settings/SettingsDetail.tsx
Normal file
18
desktop/src/sections/settings/SettingsDetail.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
// Right-pane content for Settings. Renders by store.settingsPage.
|
||||
|
||||
import React from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { NodePage } from './NodePage';
|
||||
import { IdentityPage } from './IdentityPage';
|
||||
import { DevicesPage } from './DevicesPage';
|
||||
import { AboutPage } from './AboutPage';
|
||||
|
||||
export function SettingsDetail(): React.ReactElement {
|
||||
const page = useStore(s => s.settingsPage);
|
||||
switch (page) {
|
||||
case 'node': return <NodePage />;
|
||||
case 'identity': return <IdentityPage />;
|
||||
case 'devices': return <DevicesPage />;
|
||||
case 'about': return <AboutPage />;
|
||||
}
|
||||
}
|
||||
61
desktop/src/sections/settings/SettingsNav.tsx
Normal file
61
desktop/src/sections/settings/SettingsNav.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// Left-pane category list for Settings. Keeps selection in
|
||||
// store.settingsPage so switching away and back preserves place.
|
||||
|
||||
import React from 'react';
|
||||
import { useStore, type SettingsPage } from '@/lib/store';
|
||||
|
||||
interface Row {
|
||||
key: SettingsPage;
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const ROWS: Row[] = [
|
||||
{ key: 'node', label: 'Node', hint: 'URL, connection status' },
|
||||
{ key: 'identity', label: 'Identity', hint: 'Your keys and address' },
|
||||
{ key: 'devices', label: 'Devices', hint: 'Linked devices, pair a new one' },
|
||||
{ key: 'about', label: 'About', hint: 'Version, links' },
|
||||
];
|
||||
|
||||
export function SettingsNav(): React.ReactElement {
|
||||
const page = useStore(s => s.settingsPage);
|
||||
const setPage = useStore(s => s.setSettingsPage);
|
||||
return (
|
||||
<div style={{ padding: 10 }}>
|
||||
{ROWS.map(r => (
|
||||
<NavEntry
|
||||
key={r.key}
|
||||
label={r.label}
|
||||
hint={r.hint}
|
||||
active={page === r.key}
|
||||
onClick={() => setPage(r.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavEntry({
|
||||
label, hint, active, onClick,
|
||||
}: { label: string; hint: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
|
||||
background: active ? '#0a1a29' : 'transparent',
|
||||
border: active ? '1px solid #1d9bf022' : '1px solid transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{
|
||||
color: active ? '#1d9bf0' : '#fff',
|
||||
fontSize: 14, fontWeight: 700,
|
||||
}}>{label}</div>
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
|
||||
{hint}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
desktop/src/sections/settings/index.tsx
Normal file
6
desktop/src/sections/settings/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// Settings section — two-pane.
|
||||
// List pane: category nav (Node, Identity, Devices, About).
|
||||
// Detail pane: selected category's content.
|
||||
|
||||
export { SettingsNav as SettingsList } from './SettingsNav';
|
||||
export { SettingsDetail } from './SettingsDetail';
|
||||
75
desktop/src/sections/wallet/ReceiveModal.tsx
Normal file
75
desktop/src/sections/wallet/ReceiveModal.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// ReceiveModal — shows this wallet's pub key + a copy button. QR-code
|
||||
// polish goes in rc1 (needs a deps pull for qrcode-svg or similar).
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Backdrop, Header, primaryBtnStyle } from './SendModal';
|
||||
|
||||
export function ReceiveModal({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Paint the QR on mount. Skip if there's no key (shouldn't happen but
|
||||
// component is safe against it).
|
||||
useEffect(() => {
|
||||
if (!keyFile || !canvasRef.current) return;
|
||||
QRCode.toCanvas(canvasRef.current, keyFile.pub_key, {
|
||||
width: 196,
|
||||
margin: 1,
|
||||
color: { dark: '#ffffff', light: '#00000000' },
|
||||
errorCorrectionLevel: 'M',
|
||||
}).catch(() => { /* fall back to text only */ });
|
||||
}, [keyFile]);
|
||||
|
||||
if (!keyFile) return <></>;
|
||||
|
||||
const copy = async () => {
|
||||
try { await navigator.clipboard.writeText(keyFile.pub_key); setCopied(true); }
|
||||
catch { /* ignore */ }
|
||||
setTimeout(() => setCopied(false), 1400);
|
||||
};
|
||||
|
||||
return (
|
||||
<Backdrop onClose={onClose}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 460, padding: 20, borderRadius: 16,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<Header title="Receive" onClose={onClose} busy={false} />
|
||||
|
||||
<div style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 1.5 }}>
|
||||
Share your public key — anyone can send you tokens or add you as
|
||||
a contact using this address.
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: 14, display: 'flex', justifyContent: 'center',
|
||||
padding: 16, borderRadius: 10,
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<canvas ref={canvasRef} style={{ imageRendering: 'pixelated' }} />
|
||||
</div>
|
||||
|
||||
<div className="selectable" style={{
|
||||
marginTop: 10, padding: 14, borderRadius: 10,
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
color: '#fff', fontFamily: 'monospace', fontSize: 12,
|
||||
wordBreak: 'break-all', lineHeight: 1.5,
|
||||
}}>
|
||||
{keyFile.pub_key}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
|
||||
}}>
|
||||
<button
|
||||
onClick={copy}
|
||||
style={primaryBtnStyle(false)}
|
||||
>{copied ? 'Copied!' : 'Copy'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
219
desktop/src/sections/wallet/SendModal.tsx
Normal file
219
desktop/src/sections/wallet/SendModal.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// SendModal — a focused little dialog for Transfer tx's. Accepts a
|
||||
// hex pub, DC-address, or @username and resolves to the Ed25519 pub
|
||||
// before submitting. Validates amount against balance + min fee.
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getBalance, resolveAccount } from '@/lib/api';
|
||||
import { buildTransferTx, submitTx, humanizeTxError } from '@/lib/tx';
|
||||
|
||||
const MIN_FEE_UT = 1_000;
|
||||
|
||||
function parseAmountT(s: string): number | null {
|
||||
const n = parseFloat(s);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return Math.round(n * 1_000_000);
|
||||
}
|
||||
|
||||
export function SendModal({
|
||||
onClose, onSent,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSent: () => void;
|
||||
}): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [toInput, setToInput] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [memo, setMemo] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [balance, setBalance] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
|
||||
}, [keyFile]);
|
||||
|
||||
const amountUT = useMemo(() => parseAmountT(amount), [amount]);
|
||||
const totalUT = amountUT === null ? null : amountUT + MIN_FEE_UT;
|
||||
|
||||
const canSend = !!keyFile && !busy && amountUT !== null
|
||||
&& balance !== null && totalUT !== null && balance >= totalUT
|
||||
&& toInput.trim().length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (!keyFile || !canSend || amountUT === null) return;
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const to = await resolveAccount(toInput);
|
||||
if (!to) throw new Error('Can\'t resolve recipient');
|
||||
if (to === keyFile.pub_key) throw new Error('Refusing self-transfer');
|
||||
const tx = buildTransferTx({
|
||||
from: keyFile.pub_key,
|
||||
to,
|
||||
amount: amountUT,
|
||||
fee: MIN_FEE_UT,
|
||||
privKey: keyFile.priv_key,
|
||||
memo: memo.trim() || undefined,
|
||||
});
|
||||
await submitTx(tx);
|
||||
onSent();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setErr(humanizeTxError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Backdrop onClose={busy ? () => {} : onClose}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 460, padding: 20, borderRadius: 16,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<Header title="Send" onClose={onClose} busy={busy} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Field label="To" hint="@username, DC-address or hex pubkey">
|
||||
<input
|
||||
value={toInput}
|
||||
onChange={e => setToInput(e.target.value)}
|
||||
placeholder="@alice or DC… or <hex>"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Amount (T)">
|
||||
<input
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
placeholder="0.0"
|
||||
inputMode="decimal"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 4 }}>
|
||||
Balance: {balance === null ? '…' : `${(balance / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 6 })} T`}
|
||||
{amountUT !== null && (
|
||||
<> · Fee: {(MIN_FEE_UT / 1_000_000).toFixed(6)} T</>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Memo (optional)">
|
||||
<input
|
||||
value={memo}
|
||||
onChange={e => setMemo(e.target.value)}
|
||||
placeholder="Invoice #42"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div style={{
|
||||
marginTop: 12, padding: 10, borderRadius: 8,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||
}}>{err}</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
style={secondaryBtnStyle(busy)}
|
||||
>Cancel</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!canSend}
|
||||
style={primaryBtnStyle(!canSend)}
|
||||
>{busy ? '…' : 'Send'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared modal primitives used by Send/Receive ────────────────────────
|
||||
|
||||
function Backdrop({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 20,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div onClick={e => e.stopPropagation()} style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ title, onClose, busy }: {
|
||||
title: string; onClose: () => void; busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>{title}</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: {
|
||||
label: string; hint?: string; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 6,
|
||||
}}>{label}</div>
|
||||
{children}
|
||||
{hint && (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 4 }}>{hint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
borderRadius: 8, padding: '10px 12px',
|
||||
color: '#fff', fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const primaryBtnStyle = (disabled: boolean): React.CSSProperties => ({
|
||||
padding: '9px 18px', borderRadius: 999, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 13, fontWeight: 700,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
});
|
||||
const secondaryBtnStyle = (disabled: boolean): React.CSSProperties => ({
|
||||
padding: '9px 14px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #1f1f1f',
|
||||
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
});
|
||||
|
||||
export { Backdrop, Header, Field, inputStyle, primaryBtnStyle, secondaryBtnStyle };
|
||||
147
desktop/src/sections/wallet/WalletDetailPane.tsx
Normal file
147
desktop/src/sections/wallet/WalletDetailPane.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
// WalletDetailPane — right pane of the Wallet section. Either the
|
||||
// selected tx's detail or a placeholder when nothing is selected.
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getTxDetail, type TxDetail } from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
|
||||
function formatT(ut: number | string): string {
|
||||
const n = typeof ut === 'string' ? parseInt(ut, 10) : ut;
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return (n / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 6 });
|
||||
}
|
||||
|
||||
export function WalletDetailPane(): React.ReactElement {
|
||||
const sel = useStore(s => s.walletSel);
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const [tx, setTx] = useState<TxDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sel.kind !== 'tx') { setTx(null); return; }
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getTxDetail(sel.id)
|
||||
.then(t => { if (!cancelled) setTx(t); })
|
||||
.catch(() => { if (!cancelled) setTx(null); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [sel]);
|
||||
|
||||
if (sel.kind !== 'tx') {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: '#6a6a6a', fontSize: 13, padding: 40, textAlign: 'center',
|
||||
}}>
|
||||
Pick a transaction from the list on the left to see its details.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <Placeholder note="Loading…" />;
|
||||
if (!tx) return <Placeholder note="Transaction not found on this node." />;
|
||||
|
||||
const outgoing = !!keyFile && tx.from === keyFile.pub_key;
|
||||
const amountUT = tx.amount_ut;
|
||||
const amountColor = amountUT === 0 ? '#8b8b8b'
|
||||
: outgoing ? '#f0b35a' : '#3ba55d';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', overflowY: 'auto',
|
||||
padding: '20px 24px', background: '#000',
|
||||
}}>
|
||||
<div style={{ color: '#8b8b8b', fontSize: 11, letterSpacing: 1, textTransform: 'uppercase' }}>
|
||||
{tx.type.replace(/_/g, ' ')}
|
||||
</div>
|
||||
<div style={{
|
||||
color: amountColor, fontSize: 30, fontWeight: 800, marginTop: 4,
|
||||
}}>
|
||||
{amountUT === 0 ? '—' : `${outgoing ? '−' : '+'}${formatT(amountUT)} T`}
|
||||
</div>
|
||||
{tx.memo && (
|
||||
<div style={{ color: '#e0e0e0', fontSize: 13, marginTop: 6, fontStyle: 'italic' }}>
|
||||
“{tx.memo}”
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 22, display: 'grid',
|
||||
gridTemplateColumns: 'minmax(120px, auto) 1fr', rowGap: 10, columnGap: 20,
|
||||
}}>
|
||||
<Cell label="ID">{tx.id}</Cell>
|
||||
<Cell label="From">{tx.from_addr ?? shortAddr(tx.from, 8)}</Cell>
|
||||
{tx.to && <Cell label="To">{tx.to_addr ?? shortAddr(tx.to, 8)}</Cell>}
|
||||
<Cell label="Amount">{formatT(tx.amount_ut)} T</Cell>
|
||||
<Cell label="Fee">{formatT(tx.fee_ut)} T</Cell>
|
||||
<Cell label="Time">{new Date(tx.time).toLocaleString()}</Cell>
|
||||
<Cell label="Block">#{tx.block_index} · {shortAddr(tx.block_hash, 8)}</Cell>
|
||||
{typeof tx.gas_used === 'number' && tx.gas_used > 0 && (
|
||||
<Cell label="Gas used">{tx.gas_used.toLocaleString()}</Cell>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Boolean(tx.payload) && (
|
||||
<details style={{
|
||||
marginTop: 22, background: '#0a0a0a',
|
||||
borderRadius: 10, border: '1px solid #1f1f1f', padding: 12,
|
||||
}}>
|
||||
<summary style={{ cursor: 'pointer', color: '#8b8b8b', fontSize: 12, fontWeight: 700 }}>
|
||||
Payload
|
||||
</summary>
|
||||
<pre className="selectable" style={{
|
||||
marginTop: 8, color: '#d0d0d0', fontSize: 11, lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
{JSON.stringify(tx.payload, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{tx.payload_hex && (
|
||||
<details style={{
|
||||
marginTop: 10, background: '#0a0a0a',
|
||||
borderRadius: 10, border: '1px solid #1f1f1f', padding: 12,
|
||||
}}>
|
||||
<summary style={{ cursor: 'pointer', color: '#8b8b8b', fontSize: 12, fontWeight: 700 }}>
|
||||
Payload (hex)
|
||||
</summary>
|
||||
<div className="selectable" style={{
|
||||
marginTop: 8, color: '#d0d0d0', fontSize: 11, fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{tx.payload_hex}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1, textTransform: 'uppercase',
|
||||
}}>{label}</div>
|
||||
<div className="selectable" style={{
|
||||
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
}}>{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ note }: { note: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: '#6a6a6a', fontSize: 13, padding: 40,
|
||||
}}>{note}</div>
|
||||
);
|
||||
}
|
||||
222
desktop/src/sections/wallet/WalletOverview.tsx
Normal file
222
desktop/src/sections/wallet/WalletOverview.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
// WalletOverview — Wallet section left pane.
|
||||
//
|
||||
// Top card: address + balance + primary actions (Send, Receive).
|
||||
// Bottom list: tx history pulled from /api/address/{pub}?limit=100,
|
||||
// clicking a row sets store.walletSel = { kind: 'tx', id } so the
|
||||
// detail pane renders it.
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getBalance, getTxHistory, type TxRow } from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { SendModal } from './SendModal';
|
||||
import { ReceiveModal } from './ReceiveModal';
|
||||
|
||||
function formatT(ut: number): string {
|
||||
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 6 });
|
||||
}
|
||||
|
||||
export function WalletOverview(): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const sel = useStore(s => s.walletSel);
|
||||
const setSel = useStore(s => s.setWalletSel);
|
||||
|
||||
const [balance, setBalance] = useState<number | null>(null);
|
||||
const [txs, setTxs] = useState<TxRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sendOpen, setSendOpen] = useState(false);
|
||||
const [receiveOpen, setReceiveOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!keyFile) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [bal, rows] = await Promise.all([
|
||||
getBalance(keyFile.pub_key),
|
||||
getTxHistory(keyFile.pub_key, 100),
|
||||
]);
|
||||
setBalance(bal);
|
||||
setTxs(rows);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyFile]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (!keyFile) return <></>;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 14 }}>
|
||||
{/* Account card */}
|
||||
<div style={{
|
||||
borderRadius: 14, padding: 16,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1.2, textTransform: 'uppercase',
|
||||
}}>Balance</div>
|
||||
<div style={{ color: '#fff', fontSize: 26, fontWeight: 800, marginTop: 4 }}>
|
||||
{balance === null ? '—' : `${formatT(balance)} T`}
|
||||
</div>
|
||||
<div className="selectable" style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||
marginTop: 8, wordBreak: 'break-all',
|
||||
}}>
|
||||
{keyFile.pub_key}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<PrimaryBtn label="Send" onClick={() => setSendOpen(true)} />
|
||||
<SecondaryBtn label="Receive" onClick={() => setReceiveOpen(true)} />
|
||||
<SecondaryBtn label="Refresh" onClick={load} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TX list */}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{
|
||||
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1.2, textTransform: 'uppercase',
|
||||
padding: '0 4px 6px',
|
||||
}}>History</div>
|
||||
{loading ? (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 20, textAlign: 'center' }}>
|
||||
Loading…
|
||||
</div>
|
||||
) : txs.length === 0 ? (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 20, textAlign: 'center' }}>
|
||||
No transactions yet.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
borderRadius: 12, overflow: 'hidden',
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
{txs.map((t, i) => (
|
||||
<TxRowView
|
||||
key={t.id}
|
||||
tx={t}
|
||||
me={keyFile.pub_key}
|
||||
active={sel.kind === 'tx' && sel.id === t.id}
|
||||
first={i === 0}
|
||||
onClick={() => setSel({ kind: 'tx', id: t.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sendOpen && <SendModal onClose={() => setSendOpen(false)} onSent={load} />}
|
||||
{receiveOpen && <ReceiveModal onClose={() => setReceiveOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TxRowView({
|
||||
tx, me, active, first, onClick,
|
||||
}: {
|
||||
tx: TxRow; me: string; active: boolean; first: boolean; onClick: () => void;
|
||||
}) {
|
||||
const outgoing = tx.from === me;
|
||||
const amountColor = tx.amount_ut === 0 ? '#8b8b8b'
|
||||
: outgoing ? '#f0b35a' : '#3ba55d';
|
||||
const sign = tx.amount_ut === 0 ? '' : outgoing ? '−' : '+';
|
||||
|
||||
const counterparty = outgoing ? (tx.to_addr || tx.to || '—')
|
||||
: (tx.from_addr || tx.from);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderTop: first ? undefined : '1px solid #1f1f1f',
|
||||
background: active ? '#0a1a29' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#111'; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
color: '#fff', fontSize: 13, fontWeight: 600,
|
||||
}}>
|
||||
{prettyType(tx.type)}
|
||||
{tx.memo && (
|
||||
<span style={{ color: '#6a6a6a', fontSize: 11, fontWeight: 400 }}>
|
||||
· {tx.memo.slice(0, 30)}{tx.memo.length > 30 ? '…' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11,
|
||||
fontFamily: 'monospace', marginTop: 2,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{outgoing ? 'to ' : 'from '}
|
||||
{counterparty.startsWith('DC') ? counterparty : shortAddr(counterparty, 6)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ color: amountColor, fontSize: 13, fontWeight: 700 }}>
|
||||
{tx.amount_ut === 0 ? '' : `${sign}${formatT(tx.amount_ut)} T`}
|
||||
</div>
|
||||
<div style={{ color: '#6a6a6a', fontSize: 10 }}>
|
||||
{tx.time ? new Date(tx.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function prettyType(t: string): string {
|
||||
const map: Record<string, string> = {
|
||||
TRANSFER: 'Transfer',
|
||||
RELAY_PROOF: 'Relay fee',
|
||||
REGISTER_RELAY: 'Register relay',
|
||||
HEARTBEAT: 'Heartbeat',
|
||||
CONTACT_REQUEST: 'Contact request',
|
||||
ACCEPT_CONTACT: 'Contact accepted',
|
||||
BLOCK_CONTACT: 'Contact blocked',
|
||||
REGISTER_KEY: 'Identity registered',
|
||||
LINK_DEVICE: 'Device linked',
|
||||
UNLINK_DEVICE: 'Device unlinked',
|
||||
CREATE_POST: 'Post published',
|
||||
DELETE_POST: 'Post deleted',
|
||||
FOLLOW: 'Follow',
|
||||
UNFOLLOW: 'Unfollow',
|
||||
LIKE_POST: 'Like',
|
||||
UNLIKE_POST: 'Unlike',
|
||||
BLOCK_REWARD: 'Block reward',
|
||||
};
|
||||
return map[t] ?? t;
|
||||
}
|
||||
|
||||
function PrimaryBtn({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 999, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>{label}</button>
|
||||
);
|
||||
}
|
||||
function SecondaryBtn({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '8px 14px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #1f1f1f',
|
||||
color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>{label}</button>
|
||||
);
|
||||
}
|
||||
9
desktop/src/sections/wallet/index.tsx
Normal file
9
desktop/src/sections/wallet/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
// Wallet section — full implementation.
|
||||
//
|
||||
// List pane: account card (address + balance + Send/Receive buttons)
|
||||
// + transaction history, grouped by day.
|
||||
// Detail pane: picked tx — full block/fee/payload details, or a
|
||||
// prompt to pick one on empty selection.
|
||||
|
||||
export { WalletOverview as WalletList } from './WalletOverview';
|
||||
export { WalletDetailPane as WalletDetail } from './WalletDetailPane';
|
||||
122
desktop/src/shell/NavBar.tsx
Normal file
122
desktop/src/shell/NavBar.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
// NavBar — the left 72px rail. Six icons, one for each section.
|
||||
// The active icon is drawn in accent blue; everything else is mid-grey.
|
||||
// Keyboard shortcuts (Ctrl/Cmd+1..5) are registered in useKeybinds().
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useStore, type Section } from '@/lib/store';
|
||||
|
||||
interface Tab {
|
||||
key: Section;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// Icons are SF Symbol-ish monochrome glyphs from lucide's set, inlined as
|
||||
// SVGs to avoid another runtime dependency at this stage. If the set
|
||||
// grows, we'll move to a lucide-react import.
|
||||
const TABS: Tab[] = [
|
||||
{ key: 'messages', label: 'Messages', icon: 'chat' },
|
||||
{ key: 'feed', label: 'Feed', icon: 'feed' },
|
||||
{ key: 'wallet', label: 'Wallet', icon: 'wallet' },
|
||||
{ key: 'contacts', label: 'Contacts', icon: 'contacts' },
|
||||
{ key: 'settings', label: 'Settings', icon: 'cog' },
|
||||
];
|
||||
|
||||
export function NavBar(): React.ReactElement {
|
||||
const section = useStore(s => s.section);
|
||||
const setSection = useStore(s => s.setSection);
|
||||
|
||||
// Global keybinds for section switch.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return;
|
||||
const i = Number(e.key) - 1;
|
||||
if (Number.isInteger(i) && i >= 0 && i < TABS.length) {
|
||||
e.preventDefault();
|
||||
setSection(TABS[i].key);
|
||||
} else if (e.key === ',' || e.key === '.') {
|
||||
// Cmd+, is standard for Settings on macOS
|
||||
e.preventDefault();
|
||||
setSection('settings');
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [setSection]);
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
width: 72, flexShrink: 0,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
padding: '16px 0 10px',
|
||||
borderRight: '1px solid #1f1f1f',
|
||||
background: '#000',
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{TABS.map(t => (
|
||||
<NavItem
|
||||
key={t.key}
|
||||
label={t.label}
|
||||
icon={t.icon}
|
||||
active={section === t.key}
|
||||
onClick={() => setSection(t.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<NavItem
|
||||
key="profile"
|
||||
label="Profile"
|
||||
icon="user"
|
||||
active={section === 'profile'}
|
||||
onClick={() => setSection('profile')}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
label, icon, active, onClick,
|
||||
}: { label: string; icon: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
style={{
|
||||
width: 56, height: 52, borderRadius: 12,
|
||||
background: active ? '#0a1a29' : 'transparent',
|
||||
color: active ? '#1d9bf0' : '#8b8b8b',
|
||||
border: 'none', cursor: 'pointer',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', gap: 2,
|
||||
}}
|
||||
>
|
||||
<NavGlyph icon={icon} color={active ? '#1d9bf0' : '#8b8b8b'} />
|
||||
<span style={{ fontSize: 10, fontWeight: 600, letterSpacing: 0.2 }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NavGlyph({ icon, color }: { icon: string; color: string }) {
|
||||
const d = GLYPHS[icon] ?? GLYPHS.cog;
|
||||
return (
|
||||
<svg width={20} height={20} viewBox="0 0 24 24" fill="none"
|
||||
stroke={color} strokeWidth={1.8}
|
||||
strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={d} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const GLYPHS: Record<string, string> = {
|
||||
// Minimal lucide-style single-path icons.
|
||||
chat: 'M21 12a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z',
|
||||
feed: 'M4 11a9 9 0 0 1 9 9 M4 4a16 16 0 0 1 16 16 M5 19a2 2 0 1 0 0 .01',
|
||||
wallet: 'M20 12V8H4a2 2 0 0 1 0-4h12 M4 6v12a2 2 0 0 0 2 2h14v-4 M18 12a2 2 0 1 0 0 4h4v-4h-4z',
|
||||
contacts: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8 M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75',
|
||||
cog: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
|
||||
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2 M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
|
||||
};
|
||||
40
desktop/src/shell/SectionPlaceholder.tsx
Normal file
40
desktop/src/shell/SectionPlaceholder.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// Simple inner placeholder used by every section until real content
|
||||
// lands. Shows a title + a short note; `centered` flips the layout into
|
||||
// a vertically centred message for empty-detail panes.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
note?: string;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
export function SectionPlaceholder({ title, note, centered }: Props): React.ReactElement {
|
||||
if (centered) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 32,
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', maxWidth: 360 }}>
|
||||
<div style={{ color: '#d0d0d0', fontSize: 16, fontWeight: 700 }}>{title}</div>
|
||||
{note && (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 13, lineHeight: 1.5, marginTop: 6 }}>
|
||||
{note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 14 }}>
|
||||
<div style={{ color: '#fff', fontSize: 15, fontWeight: 700 }}>{title}</div>
|
||||
{note && (
|
||||
<div style={{ color: '#8b8b8b', fontSize: 12, marginTop: 6 }}>{note}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
desktop/src/shell/Shell.tsx
Normal file
76
desktop/src/shell/Shell.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// Shell — the permanent 3-panel chrome around every non-auth screen.
|
||||
//
|
||||
// Layout:
|
||||
// ┌──────────────────────────────────────────────────────────────┐
|
||||
// │ DChain [ minimise | maximise | × ] │ 32px titlebar (drag region)
|
||||
// ├──────┬───────────────────┬─────────────────────────────────────┤
|
||||
// │ │ │ │
|
||||
// │ nav │ list │ detail │
|
||||
// │ 72px │ ~340px fixed │ flex 1 │
|
||||
// │ │ │ │
|
||||
// ├──────┴───────────────────┴─────────────────────────────────────┤
|
||||
// │ ● online · height 10942 · fee 1000 µT │ 28px status bar
|
||||
// └──────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// Current section is driven by store.section. NavBar flips it. List +
|
||||
// Detail are each decided by the section, composed from the appropriate
|
||||
// module under sections/. Until sections ship their real content, they
|
||||
// render simple placeholders so we can walk through the shell end-to-end.
|
||||
|
||||
import React from 'react';
|
||||
import { useStore, type Section } from '@/lib/store';
|
||||
import { useGlobalKeybinds } from '@/hooks/useGlobalKeybinds';
|
||||
import { TitleBar } from './TitleBar';
|
||||
import { NavBar } from './NavBar';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { UpdateBanner } from './UpdateBanner';
|
||||
import { MessagesList, MessagesDetail } from '@/sections/messages';
|
||||
import { FeedList, FeedDetail } from '@/sections/feed';
|
||||
import { WalletList, WalletDetail } from '@/sections/wallet';
|
||||
import { ContactsList, ContactsDetail } from '@/sections/contacts';
|
||||
import { SettingsList, SettingsDetail } from '@/sections/settings';
|
||||
import { ProfileList, ProfileDetail } from '@/sections/profile';
|
||||
|
||||
export function Shell(): React.ReactElement {
|
||||
const section = useStore(s => s.section);
|
||||
const { List, Detail } = PANES[section];
|
||||
useGlobalKeybinds();
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: '100%', background: '#000',
|
||||
}}>
|
||||
<TitleBar />
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', overflow: 'hidden',
|
||||
borderTop: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<NavBar />
|
||||
<div style={{
|
||||
width: 340, flexShrink: 0,
|
||||
borderRight: '1px solid #1f1f1f',
|
||||
overflowY: 'auto',
|
||||
}}>
|
||||
<List />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<Detail />
|
||||
</div>
|
||||
</div>
|
||||
<UpdateBanner />
|
||||
<StatusBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PANES: Record<
|
||||
Section,
|
||||
{ List: React.ComponentType; Detail: React.ComponentType }
|
||||
> = {
|
||||
messages: { List: MessagesList, Detail: MessagesDetail },
|
||||
feed: { List: FeedList, Detail: FeedDetail },
|
||||
wallet: { List: WalletList, Detail: WalletDetail },
|
||||
contacts: { List: ContactsList, Detail: ContactsDetail },
|
||||
settings: { List: SettingsList, Detail: SettingsDetail },
|
||||
profile: { List: ProfileList, Detail: ProfileDetail },
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user