37 Commits

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

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

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

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

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

Components

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

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

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

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

Translation quirk

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

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

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

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

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

2. Prevent self-contact-request

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  module go-blockchain
  go 1.25.0

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:32:22 +03:00
vsecoder
29e95485fa Merge 'feature/feed' into main — v2.0.0
Two minor-version cycles and one major:
  v1.0.1 — relay hardening (canonical envelope ID, server SentAt,
           /relay/send marked non-E2E)
  v1.0.2 — further relay hardening (RELAY_PROOF dedup, DELETE auth,
           rate limits, WS soft-fail close)
  v2.0.0 — social feed replacing channels:
             • on-chain CREATE_POST / DELETE_POST / FOLLOW / LIKE
             • relay feed-mailbox with EXIF-scrub pipeline
             • Twitter-style client (Feed tab + post detail +
               compose + profile Follow + share-to-chat)
             • infinite scroll + lazy render on both feed and chat
             • docs: full API reference + architecture update
2026-04-18 22:06:18 +03:00
vsecoder
1a8731f479 docs: update README + api docs + architecture for v2.0.0 feed
README
  - Mention social feed in the one-line description and feature bullets
  - Add relay + feed endpoint tables to the API overview (was
    previously empty on messaging)
  - List media/ package in the repo structure

docs/api/
  - New docs/api/feed.md: full reference for /feed/publish, fetch,
    stats, view, author, timeline, trending, foryou, hashtag; all
    on-chain CREATE_POST / DELETE_POST / FOLLOW / LIKE payloads;
    fee economics; server-side scrubbing contract.
  - docs/api/relay.md rewritten: /relay/broadcast is now the primary
    E2E path with a complete envelope schema; /relay/send kept but
    flagged ⚠ NOT E2E; DELETE /relay/inbox/{id} documented with the
    new Ed25519 signed-auth body.
  - docs/api/README.md index: added feed.md row.

docs/architecture.md
  - L2 Transport layer description updated to include the feed
    mailbox alongside the 1:1 relay mailbox.
  - New "Социальная лента (v2.0.0)" section right after the 1:1
    message flow: ASCII diagram of publish + on-chain commit +
    timeline fetch, economic summary, metadata-scrub summary.

docs/node/README.md
  - Removed stale chan:/chan-member: keys from the BadgerDB schema
    reference; replaced with the v2.0.0 feed keys (post:,
    postbyauthor:, follow:, followin:, like:, likecount:).

docs/update-system.md
  - Example features[] array updated to match the actual node output
    (channels_v1 removed, feed_v2 / media_scrub / relay_broadcast added).

Node feature flags
  - api_well_known_version.go: dropped channels_v1 tag (the
    /api/channels/:id endpoint was removed in the feed refactor);
    added feed_v2, media_scrub, relay_broadcast so clients can
    feature-detect the v2.0.0 surface.
  - Comment example updated channels_v2/v1 → feed_v3/v2.

Client
  - CLIENT_REQUIRED_FEATURES expanded to include the v2.0.0 feature
    flags the client now depends on (feed_v2, media_scrub,
    relay_broadcast); checkNodeVersion() will flag older nodes as
    unsupported and surface an upgrade prompt.

All 7 Go test packages green; tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:06:06 +03:00
vsecoder
6425b5cffb feat(feed/chat): lazy-render + pagination for long scrolls
Server pagination
  - blockchain.PostsByAuthor signature extended with beforeTs int64;
    passing 0 keeps the previous "everything, newest first" behaviour,
    non-zero skips posts with CreatedAt >= beforeTs so clients can
    paginate older results.
  - node.FeedConfig.PostsByAuthor callback type updated; the two
    /feed endpoints that use it (timeline + author) now accept
    `?before=<unix_seconds>` and forward it through. /feed/author
    limit default dropped from 50 to 30 to match the client's page
    size.
  - node/api_common.go: new queryInt64 helper for parsing the cursor
    param safely (matches the queryInt pattern already used).

Client infinite scroll (Feed tab)
  - lib/feed.ts: fetchTimeline / fetchAuthorPosts accept
    `{limit?, before?}` options. Old signatures still work for other
    callers (fetchForYou / fetchTrending / fetchHashtag) — those are
    ranked feeds that don't have a stable cursor so they stay
    single-shot.
  - feed/index.tsx: tracks loadingMore / exhausted state. onEndReached
    (threshold 0.6) fires loadMore() which fetches the next 20 posts
    using the oldest currently-loaded post's created_at as `before`.
    Deduplicates on post_id before appending. Stops when the server
    returns < PAGE_SIZE items. ListFooterComponent shows a small
    spinner during paginated fetches.
  - FlatList lazy-render tuning on all feed lists (index + hashtag):
    initialNumToRender:10, maxToRenderPerBatch:8, windowSize:7,
    removeClippedSubviews — first paint stays quick even with 100+
    posts loaded.

Chat lazy render
  - chats/[id].tsx FlatList: initialNumToRender:25 (~1.5 screens),
    maxToRenderPerBatch:12, windowSize:10, removeClippedSubviews.
    Keeps initial chat open snappy on conversations with thousands
    of messages; RN re-renders a small window around the viewport
    and drops the rest.

Tests
  - chain_test.go updated for new PostsByAuthor signature.
  - All 7 Go packages green.
  - tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:51:43 +03:00
vsecoder
1c6622e809 fix(feed): more vertical gap between header and post content
marginTop 8 → 14 on the content Pressable. Gives the body text a
clear visual separation from the avatar/name header instead of
sitting flush under it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:37:46 +03:00
vsecoder
ab98f21aac fix(feed): stack header + full-width content instead of avatar-sibling column
The previous layout put body text, attachments and action row inside
a column next to the avatar. Two recurring bugs came from that:

  1. The column's width = screen - 16 - 44 - 10 - 16 = screen - 86px.
     Long text or attachments computed against that narrower width,
     and on a few RN builds the measurement was off enough that text
     visibly ran past the card's right edge.
  2. The column visually looked weird: a photo rendered only 3/4 of
     the card width because the avatar stole 54px on the left.

Fix: make the card a vertical stack.

  ┌─────────────────────────────────────────┐
  │ [avatar] [name · time]          [menu]  │   ← HEADER row
  ├─────────────────────────────────────────┤
  │ body text, full card width              │   ← content column
  │ [attachment image, full card width]     │
  │ [action row, full card width]           │
  └─────────────────────────────────────────┘

Now body and media always occupy the full card width (paddingLeft:16
paddingRight:16 from the outer View), long lines wrap inside that,
and the earlier overflow-tricks / width-100% / paddingRight-4
band-aids aren't needed. Removed them.

Header row is unchanged structurally (avatar + name-row Pressable +
menu button) — just lifted into a dedicated View so the content
column below starts at the left card edge instead of alongside the
avatar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:35:07 +03:00
vsecoder
9be1b60ef1 fix(feed): move card layout off Pressable style-fn so paddings stick
Same Pressable-dynamic-style-function bug that bit the FAB: on some
RN versions the style function is re-evaluated mid-render in a way
that drops properties — here we lost paddingLeft:16 and sometimes
flexDirection:'row' on the outer PostCard Pressable, which is why
the avatar ended up flush against the left edge and the header/body
sometimes stacked into a column instead of row.

Fix: move layout to a plain <View> wrapper (static styles, can never
be dropped). Tap handling stays on two Pressables:
  - the avatar wrapper (opens author profile),
  - the content column (opens post detail + long-press for menu).

The card area visually covered by these two Pressables is ~100% —
tap anywhere on a post still navigates to detail, tap on the avatar
still goes to the author. No interaction regression.

Also paired opacity press-state on the content-column Pressable so
the feedback visual matches what the old bg-colour press gave.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:28:34 +03:00
vsecoder
0bb5780a5d feat(feed/chat): VK-style share post to chats + list breathing room
Feed list padding
  FlatList had no inner padding so the first post bumped against the
  tab strip and the last post against the NavBar. Added paddingTop: 8
  / paddingBottom: 24 on contentContainerStyle in both /feed and
  /feed/tag/[tag] — first card now has a clear top gap, last card
  doesn't get hidden behind the FAB or NavBar.

Share-to-chat flow
  Replaces the placeholder share button (which showed an Alert with
  the post URL) with a real "forward to chats" flow modeled on VK's
  shared-wall-post embed.

  New modules
    lib/forwardPost.ts       — encodePostRef / tryParsePostRef +
                               forwardPostToContacts(). Serialises a
                               feed post into a tiny JSON payload that
                               rides the same encrypted envelope as any
                               chat message; decode side distinguishes
                               "post_ref" payloads from regular text by
                               trying JSON.parse on decrypted text.
                               Mirrors the sent message into the sender's
                               local history so they see "you shared
                               this" in the chat they forwarded to.

    components/feed/ShareSheet.tsx
                             — bottom-sheet picker. Multi-select
                               contacts via tick-box, search by
                               username / alias / address prefix.
                               "Send (N)" dispatches N parallel
                               encrypted envelopes. Contacts with no
                               X25519 key are filtered out (can't
                               encrypt for them).

    components/chat/PostRefCard.tsx
                             — compact embedded-post card for chat
                               bubbles. Ribbon "ПОСТ" label +
                               author + 3-line excerpt + "с фото"
                               indicator. Tap → /(app)/feed/{id} full
                               post detail. Palette switches between
                               blue-bubble-friendly and peer-bubble-
                               friendly depending on bubble side.

  Message pipeline
    lib/types.ts           — Message.postRef optional field added.
                             text stays "" when the message is a
                             post-ref (nothing to render as plain text).
    hooks/useMessages.ts   + hooks/useGlobalInbox.ts
                           — post decryption of every inbound envelope
                             runs through tryParsePostRef; matching
                             messages get the postRef attached instead
                             of the raw JSON in .text.
    components/chat/MessageBubble.tsx
                           — renders PostRefCard inside the bubble when
                             msg.postRef is set. Other bubble features
                             (reply quote, attachment preview, text)
                             still work around it.

  PostCard
    - share icon now opens <ShareSheet>; the full-URL placeholder is
      gone. ShareSheet is embedded at the PostCard level so each card
      owns its own sheet state (avoids modal-stacking issues).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:26:43 +03:00
vsecoder
50aacced0b fix(feed): header on one line, avatar padding explicit, icon/label aligned
Three related layout polish issues.

1. Avatar + name + time splitting to two lines

   Fixed by collapsing the "·" separator + time into a single Text
   component (was three sibling Texts). RN occasionally lays out
   three-Text-sibling rows oddly when the name is long because each
   Text measures independently. With the separator glued to the time,
   there's one indivisible chip for "·  2h" that flexShrink:0 keeps on
   the row; the name takes the rest and truncates with "…" if needed.
   Also wrapped the name + time in a flex:1 Pressable (author tap
   target) so the row width is authoritative.

2. Avatar flush against the left edge

   The outer Pressable's style-function paddingLeft was being applied
   but the visual offset felt wrong because avatar has no explicit
   width. Added width:44 to the avatar's own Pressable wrapper so the
   flex-row layout reserves exactly the expected space and the 16-px
   paddingLeft on the outer Pressable is visible around the avatar's
   left edge.

3. Like / view count text visually below icon's centre

   RN Text includes extra top padding (Android) and baseline-anchors
   (iOS) that push the number glyph a pixel or two below the icon
   centre in a flex-row with alignItems:'center'. Added explicit
   lineHeight:16 (matching icon size) + includeFontPadding:false +
   textAlignVertical:'center' on both the heart button's Text and the
   shared ActionButton Text → numbers now sit on the same optical
   baseline as their icons on both platforms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:18:29 +03:00
vsecoder
38ae80f57a fix(feed): long post body no longer overflows the card
The body Text inherited the content column's width only implicitly
via flex:1 on the parent, which on some Android RN builds was
computed one or two pixels wider than the true column width — enough
to make a long line visually escape the right side of the card.

Belt-and-braces fix:
  - content column gets overflow:'hidden' so anything that escapes
    the computed width gets clipped instead of drawn outside.
  - body Text explicitly sets width:'100%' + flexShrink:1 +
    paddingRight:4 so the wrapping algorithm always knows the
    authoritative maximum and leaves a 4-px visual buffer from the
    column edge.

Together these guarantee long single-line posts wrap correctly on
both iOS and Android, and short-but-wide tokens (URLs, long
hashtags) ellipsize instead of poking out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:14:22 +03:00
vsecoder
c5ca7a0612 feat(feed): image previews + inline header + 5-line truncation + drop comments
Server
  node/api_feed.go: new GET /feed/post/{id}/attachment route. Returns
  raw attachment bytes with the correct Content-Type so React Native's
  <Image source={uri}> can stream them directly without the client
  fetching + decoding base64 from the main /feed/post/{id} JSON (would
  blow up memory on a 40-post timeline). Respects on-chain soft-delete
  (410 when tombstoned). Cache-Control: public, max-age=3600, immutable
  — attachments are content-addressed so aggressive caching is safe.

PostCard — rewritten header row
  - Avatar + name + time collapsed into a single Pressable row with
    flexDirection:'row'. Name gets flexShrink:1 + numberOfLines:1 so
    long handles truncate with "…" mid-row instead of pushing the time
    onto a second line. Time and separator dot both numberOfLines:1
    with no flex — they never shrink, so "2h" stays readable.
  - Whole header is one tap target → navigates to the author's profile.

PostCard — body truncation
  - Timeline view (compact=false): numberOfLines={5} + ellipsizeMode:
    'tail'. Long posts collapse to 5 lines with "…"; tapping the card
    opens the detail view where the full body is shown.
  - Detail view (compact=true): no line cap — full text, then full-
    size attachment below.

PostCard — real image previews
  - <Image source={{ uri: `${node}/feed/post/${id}/attachment` }}>
    (feed layout).
  - Timeline: aspectRatio: 4/5 + resizeMode:'cover' — portrait photos
    get cropped so one tall image can't eat the whole feed.
  - Detail: aspectRatio: 1 + resizeMode:'contain' so the full image
    fits in its original proportions (crop-free).

PostCard — comments button removed
  v2.0.0 doesn't implement replies; a dead button with label "0" was
  noise. Action row now has 3 cells: heart (with live like count),
  eye (views), share (pinned right). Spacing stays balanced because
  each of the first two cells is still flex:1.

Post detail screen
  - Passes compact prop so the PostCard above renders in full-body /
    full-attachment mode.
  - Dropped the old AttachmentPreview placeholder — PostCard now
    handles images in both modes.

Tests
  - go test ./... — all 7 packages green (blockchain / consensus /
    identity / media / node / relay / vm).
  - tsc --noEmit on client-app — 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:38:15 +03:00
vsecoder
7bfd8c7dea fix(feed): side padding on action row + stable FAB right-anchor
- Post action row (chat / ❤ / eye / share) had only a small paddingRight
  and no left padding. First icon sat flush under the avatar and share
  iron against the card edge. Replaced with paddingHorizontal: 12 so
  both sides get equal breathing room; each of the four cells still
  flex:1 so the icons distribute evenly.
- FAB kept appearing at the LEFT edge instead of the right on user's
  device despite position:absolute + right:12. Pressable's dynamic-
  function style can drop absolute-positioning fields between renders
  on some RN versions. Wrapping the Pressable in a plain absolute-
  positioned View fixes this: positioning lives on the View (never
  re-evaluated mid-render), the Pressable inside only declares size
  and visuals. pointerEvents="box-none" on the wrapper keeps taps
  outside the button passing through to the feed list below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:31:03 +03:00
vsecoder
f688f45739 fix(feed): breathing room around divider + FAB back to right/bottom corner
- PostSeparator was a bare 1px line flush against both neighbouring
  cards, so the seam looked like card contact instead of a real
  break. Wrapped it in a 12px vertical padding — now there's 12px
  blank above the line, 1px line, 12px blank below. Trimmed the
  card's own paddingVertical back down since the separator now owns
  the inter-post breathing room.
- FAB was lifted to bottom: max(insets.bottom, 8) + 70 in the previous
  commit which put it far too high (a leftover from the original
  experiment with the absoluteFill wrapper). User wants 12px above
  the NavBar and 12px from the right edge. The Feed screen's
  container ends at the NavBar top (enforced by (app)/_layout.tsx's
  outer <View flex:1> + NavBar sibling), so a simple `right: 12,
  bottom: 12` on position:absolute lands the button exactly there on
  every device. Removed the now-unnecessary absoluteFill wrapper too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:28:52 +03:00
vsecoder
a248c540d5 fix(feed): visible post divider + reliable FAB positioning
- Post divider was on each PostCard's outer Pressable as borderBottom
  (#222), which was barely visible on OLED black and disappeared
  entirely in pressed state (the pressed bg ate the line). Moved the
  seam to a dedicated PostSeparator component (1px, #2a2a2a) wired as
  FlatList's ItemSeparatorComponent on both /feed (timeline / for-you
  / trending) and /feed/tag/[tag]. Also bumped inter-card vertical
  padding (14-16 top / 16-20 bottom) so cards have real breathing room
  even before the divider.
- FAB position was flaky: with <Stack> at the (app) level the overlay
  could end up positioned against the Stack's card view instead of the
  tab container, which made the button drift around and stick against
  unexpected edges. Wrapped it in an absoluteFill container with
  pointerEvents="box-none" — the wrapper owns positioning against the
  tab screen, the button inside just declares right: 14 / bottom: N.
  Bumped bottom offset to `max(insets.bottom, 8) + 70` so the FAB
  always clears the 5-icon NavBar with ~14px visual gap on every
  device. Shadow switched from blue-cast to standard dark for better
  depth perception on dark backgrounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:23:52 +03:00
vsecoder
51bc0a1850 fix(feed): card spacing, action-row distribution, tab strip, detail inset
- PostCard rows got cramped paddings and a near-invisible divider.
  Increased paddingTop 12→16, paddingBottom 12→18, paddingHorizontal
  14→16; divider colour #141414→#222222 so the seam between posts is
  legible on OLED blacks.
- Action row (chat / ❤ / view / share) used a fixed gap:32 + spacer.
  Reworked to four flex:1 cells with justifyContent: space-between,
  so the first three icons distribute evenly across the row and share
  pins to the right edge. Matches Twitter's layout where each action
  occupies a quarter of the row regardless of label width.
- Feed tab strip (Подписки / Для вас / В тренде) used flex:1 +
  gap:10 which bunched the three labels together visually. Switched
  to justifyContent: space-between + paddingHorizontal:20 so each
  tab hugs its label and the three labels spread to the edges with
  full horizontal breathing room.
- Post detail screen (/feed/[id]) and hashtag feed (/feed/tag/[tag])
  were missing the safe-area top inset — their headers butted right
  against the status bar / notch. Added useSafeAreaInsets().top as
  paddingTop on the outer View, matching the rest of the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:20:18 +03:00
vsecoder
93040a0684 fix(client): DM-only info, seed on API error, proper cross-group back stack
Three related UX fixes on the client.

1. Participants count on profile

   DMs always have exactly two participants (you and the contact) so a
   "Участников: 1" row was confusing — either it's obviously the other
   person or it's wrong depending on how you count. Removed for direct
   conversations; the row still appears for group chats (and shows an
   em-dash until v2.1.0 gives groups a real member list).

2. Dev feed seed now activates on network / 404 errors

   The seed was only surfaced when the real API returned an EMPTY
   array. If the node was down (Network request failed) or the endpoint
   replied 404, the catch block quietly set posts to [] and the list
   stayed blank — defeating the point of the seed. Now both the empty-
   response path AND the network-error path fall back to getDevSeedFeed(),
   so scrolling / like-toggling works even without a running node.

   Also made the __DEV__ lookup more defensive: use `globalThis.__DEV__`
   at runtime instead of the typed global. Some bundler configurations
   have the TS type but not the runtime binding, or vice-versa — the
   runtime lookup always agrees with Metro.

3. Back from profile → previous screen instead of tab root

   Root cause: AnimatedSlot rendered <Slot>, which is stack-less. When
   /chats/xyz pushed /profile/abc (cross-group), the chats group
   unmounted. Hitting Back then re-entered chats at its root (/chats
   list) rather than /chats/xyz.

   Replaced <Slot> with <Stack> in AnimatedSlot. Tab switching still
   stays flat because NavBar uses router.replace (which maps to
   navigation.replace on the Stack — no history accumulation).
   Cross-group pushes (post author tap from feed, avatar tap from chat
   header, compose modal) now live in the outer Stack's history, so
   Back pops correctly to the caller.

   The nested Stacks (chats/_layout.tsx, feed/_layout.tsx,
   profile/_layout.tsx) still handle intra-group navigation as before.
   The PanResponder-based swipe-right-to-back was removed since the
   native Stack now provides iOS edge-swipe natively; Android uses the
   system back button.

   animation: 'none' keeps the visual swap instant — matches the prior
   Slot look so nothing flashes slide-animations that weren't there
   before. Sub-group layouts can opt into slide_from_right themselves
   (profile/_layout.tsx and feed/_layout.tsx already do).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:16:38 +03:00
vsecoder
98a0a4b8ba fix(nav): move feed.tsx → feed/index.tsx to unblock duplicate-route error
expo-router error: "A navigator cannot contain multiple 'Screen'
components with the same name (found duplicate screen named 'feed')".

Cause: having both app/(app)/feed.tsx (a file-route) and
app/(app)/feed/ (a folder with _layout.tsx) at the same level makes
expo-router try to register two routes called "feed".

Fix: the folder's _layout.tsx wraps the tab + its sub-routes in a
single Stack, so the tab screen becomes feed/index.tsx — the initial
screen of that Stack. Back navigation from /feed/[id] and
/feed/tag/[tag] now correctly pops to the Feed tab root.

No other route references changed (paths like /(app)/feed,
/(app)/feed/<id>, /(app)/feed/tag/<tag> still resolve identically).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:10:33 +03:00
vsecoder
5728cfc85a fix(client): profile polish + proper back stack + dev feed seed
Profile screen
  - Missing safe-area top inset on the header — fixed by wrapping the
    outer View in paddingTop: insets.top (matches the rest of the tab
    screens).
  - "Чат" button icon + text sat on two lines because the button used
    column layout by default. Rewritten as a full-width CTA pill with
    flexDirection: row and alignItems: center → chat-bubble icon and
    label sit on one line.
  - Dedicated "Копировать адрес" button removed. The address row in
    the info card is now the tap target: pressing it copies to clipboard
    and flips the row to "Скопировано" with a green check for 1.8s.
  - Posts tab removed entirely. User's right — a "chat profile" has no
    posts concept, just participant count + date + encryption. The
    profile screen is now a single-view info card (address, encryption
    status, added date, participants). Posts are discoverable via the
    Feed tab; a dedicated /feed/author/{pub} screen is on the roadmap
    for browsing a specific user's timeline.

Back-stack navigation
  - app/(app)/profile/_layout.tsx + app/(app)/feed/_layout.tsx added,
    each with a native <Stack>. The outer AnimatedSlot is stack-less
    (intentional — it animates tab switches), so without these nested
    stacks `router.back()` from a profile screen had no history to pop
    and fell through to root. Now tapping an author in the feed → back
    returns to feed; opening a profile from chat header → back returns
    to the chat.

Dev feed seed
  - lib/devSeedFeed.ts: 12 synthetic posts (text-only, mix of hashtags,
    one with has_attachment, varying likes/views, timestamps from "1m
    ago" to "36h ago"). Exposed via getDevSeedFeed() — stripped from
    production via __DEV__ gate.
  - Feed screen falls back to the dev seed only when the real API
    returned zero posts. Gives something to scroll / tap / like-toggle
    before the backend has real content; real data takes over as soon
    as it arrives.

Note: tapping a mock post into detail will 404 against the real node
(the post_ids don't exist on-chain). Intentional — the seed is for
scroll + interaction feel, not deep linking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:08:48 +03:00
vsecoder
0ff2760a11 fix(feed): compose footer above keyboard, tab spacing, FAB position
- Composer footer (attach / counter / fee bar) now rides with the
  keyboard on both platforms: KeyboardAvoidingView behavior is
  "padding" on iOS and "height" on Android. Previously behavior was
  undefined on Android, and the global softwareKeyboardLayoutMode:"pan"
  setting lifted the whole screen — leaving the footer hidden below
  the keyboard.
- Feed tab strip (Подписки / Для вас / В тренде) had zero horizontal
  breathing room — three equal-width Pressables flush to the edges.
  Added 12px outer paddingHorizontal + 10px gap + slightly bigger
  paddingVertical. The active indicator is now 32px wide (was 48) so
  it sits under the label instead of underlining two words.
- Floating compose FAB pinned to the right edge with a 14px inset
  (was 18). Vertical offset tightened from +70 to +62 above the
  safe-area inset — closer to the NavBar top edge while still clear
  of the icons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:03:15 +03:00
vsecoder
5b64ef2560 feat(client): Twitter-style social feed UI (Phase C of v2.0.0)
Ships the client side of the v2.0.0 feed feature. Folds client-app/
into the monorepo (was previously .gitignored as "tracked separately"
but no separate repo ever existed — for v2.0.0 the client is
first-class).

Feed screens

  app/(app)/feed.tsx — Feed tab
    - Three-way tab strip: Подписки / Для вас / В тренде backed by
      /feed/timeline, /feed/foryou, /feed/trending respectively
    - Default landing tab is "Для вас" — surfaces discovery without
      requiring the user to follow anyone first
    - FlatList with pull-to-refresh + viewability-driven view counter
      bump (posts visible ≥ 60% for ≥ 1s trigger POST /feed/post/…/view)
    - Floating blue compose button → /compose
    - Per-post liked_by_me fetched in batches of 6 after list load

  app/(app)/compose.tsx — post composer modal
    - Fullscreen, Twitter-like header (✕ left, Опубликовать right)
    - Auto-focused multiline TextInput, 4000 char cap
    - Hashtag preview chips that auto-update as you type
    - expo-image-picker + expo-image-manipulator pipeline: resize to
      1080px max-dim, JPEG Q=50 (client-side first-pass compression
      before the mandatory server-side scrub)
    - Live fee estimate + balance guard with a confirmation modal
      ("Опубликовать пост? Цена: 0.00X T · Размер: N KB")
    - Exif: false passed to ImagePicker as an extra privacy layer

  app/(app)/feed/[id].tsx — post detail
    - Full PostCard rendering + detailed info panel (views, likes,
      size, fee, hosting relay, hashtags as tappable chips)
    - Triggers bumpView on mount
    - 410 (on-chain soft-delete) routes back to the feed

  app/(app)/feed/tag/[tag].tsx — hashtag feed

  app/(app)/profile/[address].tsx — rebuilt
    - Twitter-ish profile: avatar, name, address short-form, post count
    - Posts | Инфо tab strip
    - Follow / Unfollow button for non-self profiles (optimistic UI)
    - Edit button on self profile → settings
    - Secondary actions (chat, copy address) when viewing a known contact

Supporting library

  lib/feed.ts — HTTP wrappers + tx builders for every /feed/* endpoint:
    - publishPost (POST /feed/publish, signed)
    - publishAndCommit (publish → on-chain CREATE_POST)
    - fetchPost / fetchStats / bumpView
    - fetchAuthorPosts / fetchTimeline / fetchForYou / fetchTrending /
      fetchHashtag
    - buildCreatePostTx / buildDeletePostTx
    - buildFollowTx / buildUnfollowTx
    - buildLikePostTx / buildUnlikePostTx
    - likePost / unlikePost / followUser / unfollowUser / deletePost
      (high-level helpers that bundle build + submitTx)
    - formatFee, formatRelativeTime, formatCount — Twitter-like display
      helpers

  components/feed/PostCard.tsx — core card component
    - Memoised for performance (N-row re-render on every like elsewhere
      would cost a lot otherwise)
    - Optimistic like toggle with heart-bounce spring animation
    - Hashtag highlighting in body text (tappable → hashtag feed)
    - Long-press context menu (Delete, owner-only)
    - Views / likes / share-link / reply icons in footer row

Navigation cleanup

  - NavBar: removed the SOON pill on the Feed tab (it's shipped now)
  - (app)/_layout: hide NavBar on /compose and /feed/* sub-routes
  - AnimatedSlot: treat /feed/<id>, /feed/tag/<t>, /compose as
    sub-routes so back-swipe-right closes them

Channel removal (client side)

  - lib/types.ts: ContactKind stripped to 'direct' | 'group'; legacy
    'channel' flag removed. `kind` field kept for backward compat with
    existing AsyncStorage records.
  - lib/devSeed.ts: dropped the 5 channel seed contacts.
  - components/ChatTile.tsx: removed channel kindIcon branch.

Dependencies

  - expo-image-manipulator added for client-side image compression.
  - expo-file-system/legacy used for readAsStringAsync (SDK 54 moved
    that API to the legacy sub-path; the new streaming API isn't yet
    stable).

Type check

  - npx tsc --noEmit — clean, 0 errors.

Next (not in this commit)

  - Direct attachment-bytes endpoint on the server so post-detail can
    actually render the image (currently shows placeholder with URL)
  - Cross-relay body fetch via /api/relays + hosting_relay pubkey
  - Mentions (@username) with notifications
  - Full-text search

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:43:55 +03:00
vsecoder
9e86c93fda test(feed): end-to-end integration + two-node propagation (Phase B hardening)
Adds two integration-test files that exercise the full feed stack over
real HTTP requests, plus a fix to the publish signature model that the
EXIF scrubbing test surfaced.

Bug fix — api_feed.go publish signature flow
  Previously: server scrubbed the attachment → computed content_hash
  over the SCRUBBED bytes → verified the author's signature against
  that hash. But the client, not owning the scrubber, signs over the
  RAW upload. The two hashes differ whenever scrub touches the bytes
  (which it always does for images), so every signed upload with an
  image was rejected as "signature invalid".

  Fixed order:
    1. decode attachment from base64
    2. compute raw_content_hash over Content + raw attachment
    3. verify author's signature against raw_content_hash
    4. scrub attachment (strips EXIF / re-encodes)
    5. compute final_content_hash over Content + scrubbed attachment
    6. return final hash in response for the on-chain CREATE_POST tx

  The signature proves the upload is authentic; the final hash binds
  the on-chain record to what readers actually download.

node/feed_e2e_test.go
  In-process harness: real BadgerDB chain + feed mailbox + media
  scrubber + httptest.Server with RegisterFeedRoutes. Tests drive
  it via real http.Post / http.Get so rate limiters, auth, scrubber,
  and handler code all run on the happy path.

  Tests:
  - TestE2EFullFlow — publish → CREATE_POST tx → body fetch → view
    bump → stats → author list → soft-delete → 410 Gone on re-fetch
  - TestE2ELikeUnlikeAffectsStats — on-chain LIKE_POST bumps /stats,
    liked_by_me reflects the caller
  - TestE2ETimeline — follow graph, merged timeline newest-first
  - TestE2ETrendingRanking — likes × 3 + views puts hot post at [0]
  - TestE2EForYouFilters — excludes own posts + followed authors +
    already-liked posts; surfaces strangers
  - TestE2EHashtagSearch — tag returns only tagged posts
  - TestE2EScrubberStripsEXIF — injects SUPERSECRETGPS canary into a
    JPEG APP1 segment, uploads via /feed/publish, reads back — asserts
    canary is GONE from stored attachment. This is the privacy-critical
    regression gate: if it ever breaks, GPS coordinates leak.
  - TestE2ERejectsMIMEMismatch — PNG labelled as JPEG → 400
  - TestE2ERejectsBadSignature — wrong signer → 403
  - TestE2ERejectsStaleTimestamp — 1-hour-old ts → 400 (anti-replay)

node/feed_twonode_test.go
  Simulates two independent nodes sharing block history (gossip via
  same-block AddBlock on both chains). Verifies the v2.0.0 design
  contract: chain state replicates, but post BODIES live only on the
  hosting relay.

  Tests:
  - TestTwoNodePostPropagation — Alice publishes on A; B's chain sees
    the record; B's HTTP /feed/post/{id} returns 404 (body is A's);
    fetch from A succeeds using hosting_relay field from B's chain
    lookup. Documents the client-side routing contract.
  - TestTwoNodeLikeCounterSharedAcrossNodes — Bob likes from Node B;
    both A's and B's /stats show likes=1. Proves engagement aggregates
    are chain-authoritative, not per-relay.
  - TestTwoNodeFollowGraphReplicates — FOLLOW tx propagates, /timeline
    on B returns A-hosted posts with metadata (no body, as designed).

Coverage summary
  Publish flow (sign → scrub → hash → store):          ✓
  CREATE_POST on-chain fee accounting:                 ✓
  Like / Unlike counter consistency:                   ✓
  Follow graph → timeline merge:                       ✓
  Trending ranking by likes × 3 + views:               ✓
  For You exclusion rules (self, followed, liked):     ✓
  Hashtag inverted index:                              ✓
  View counter increment + stats aggregate:            ✓
  Soft-delete → 410 Gone:                              ✓
  Metadata scrubbing (EXIF canary):                    ✓
  MIME mismatch rejection:                             ✓
  Signature authenticity:                              ✓
  Timestamp anti-replay (±5 min window):               ✓
  Two-node block propagation:                          ✓
  Cross-node body fetch via hosting_relay:             ✓
  Likes aggregation across nodes:                      ✓

All 7 test packages green: blockchain consensus identity media node
relay vm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:27:00 +03:00
vsecoder
f885264d23 feat(media): mandatory metadata scrubbing on /feed/publish + FFmpeg sidecar
Every photo from a phone camera ships with an EXIF block that leaks:
GPS coordinates, camera model + serial, original timestamp, software
name, author/copyright fields, sometimes an embedded thumbnail that
survives cropping. For a social feed positioned as privacy-friendly
we can't trust the client alone to scrub — a compromised build,
a future plugin, or a hostile fork would simply skip the step and
leak authorship data.

So: server-side scrub is mandatory for every /feed/publish upload.

New package: media

  media/scrub.go
    - Scrubber type with Scrub(ctx, bytes, claimedMIME) → (clean, actualMIME)
    - ScrubImage handles JPEG/PNG/GIF/WebP in-process: decodes, optionally
      downscales to 1080px max-dim, re-encodes as JPEG Q=75. Stdlib
      jpeg.Encode emits ZERO metadata → scrub is complete by construction.
    - Sidecar client (HTTP): posts video/audio bytes to an external
      FFmpeg worker at DCHAIN_MEDIA_SIDECAR_URL
    - Magic-byte MIME detection: rejects uploads where declared MIME
      doesn't match actual bytes (prevents a PDF dressed as image/jpeg
      from bypassing the scrubber)
    - ErrSidecarUnavailable: explicit error when video arrives but no
      sidecar is wired; operator opts in to fallback via
      --allow-unscrubbed-video (default: reject)

  media/scrub_test.go
    - Crafted EXIF segment with "SECRETGPS-…Canon-EOS-R5" canary —
      verifies the string is gone after ScrubImage
    - Downscale test (2000×1000 → 1080×540, aspect preserved)
    - MIME-mismatch rejection
    - Magic-byte detector sanity table

FFmpeg sidecar — new docker/media-sidecar/

  Tiny Go HTTP service (~180 LOC, no non-stdlib deps) that shells out
  to ffmpeg with -map_metadata -1 + -map 0:v -map 0:a? to guarantee
  only video + audio streams survive (no subtitles, attached pictures,
  or data channels that could carry hidden info).

  Re-encode profile:
    video → H.264 CRF 28 preset=fast, Opus 64k, MP4 faststart
    audio → Opus 64k, Ogg container

  Dockerfile: two-stage build (Go → alpine+ffmpeg), ~90 MB image, non-
  root user, /healthz endpoint for compose probes.

  Node reaches it via DCHAIN_MEDIA_SIDECAR_URL. Without it, video uploads
  are rejected with 503 unless operator sets DCHAIN_ALLOW_UNSCRUBBED_VIDEO.

/feed/publish wiring

  - cfg.Scrubber is a required dependency
  - Before storing post body we call scrubber.Scrub(); attachment bytes
    + MIME are replaced with the cleaned version
  - content_hash is computed over the SCRUBBED bytes — so the on-chain
    CREATE_POST tx references exactly what readers will fetch
  - EstimatedFeeUT uses the scrubbed size, so author's fee reflects
    actual on-disk cost
  - Content-type mismatches → 400; sidecar unavailable for video → 503

Flags / env vars

  --feed-db / DCHAIN_FEED_DB            (existing)
  --feed-ttl-days / DCHAIN_FEED_TTL_DAYS (existing)
  --media-sidecar-url / DCHAIN_MEDIA_SIDECAR_URL   (NEW)
  --allow-unscrubbed-video / DCHAIN_ALLOW_UNSCRUBBED_VIDEO (NEW; default false)

Client responsibilities (for reference — client work lands in Phase C)

  Even with server-side scrub, the client should still compress aggressively
  BEFORE upload, because:
    - upload time is ~N× larger for unscrubbed media (mobile networks)
    - the server's 256 KiB MaxPostSize is a HARD cap — oversized uploads
      are rejected, not silently truncated
    - the on-chain fee is size-based, so users pay for every byte the
      client didn't bother to shrink

  Recommended client pipeline:
    images → expo-image-manipulator: resize max-dim 1080px, WebP or
             JPEG quality 50-60
    videos → react-native-compressor: H.264 CRF 28, 720p max, 64k audio
    audio  → expo-audio's default Opus 32k (already compressed)

  Documented in docs/media-sidecar.md (added later with Phase C PR).

Tests
  - go test ./... green across 6 packages (blockchain consensus identity
    media relay vm)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:15:14 +03:00
vsecoder
126658f294 feat(feed): relay body storage + HTTP endpoints (Phase B of v2.0.0)
Phase A (the previous commit) added the on-chain foundations. Phase B
is the off-chain layer: post bodies live in a BadgerDB-backed feed
mailbox, and a full HTTP surface makes the feed usable from clients.

New components

  relay/feed_mailbox.go (+ tests)
    - FeedPost: body + content-type + attachment + hashtags + thread refs
    - Store / Get / Delete with TTL-bounded eviction (30 days default)
    - View counter (IncrementView / ViewCount) — off-chain because one
      tx per view would be nonsense
    - Hashtag inverted index: auto-extracts #tokens from content on
      Store, lowercased + deduped + capped at 8/post
    - Author chrono index: PostsByAuthor returns newest-first IDs
    - RecentPostIDs: scan-by-age helper used by trending/foryou

  node/api_feed.go
    POST /feed/publish           — author-signed body upload, returns
                                   post_id + content_hash + size +
                                   hashtags + estimated fee for the
                                   follow-up on-chain CREATE_POST tx
    GET  /feed/post/{id}         — fetch body (respects on-chain soft
                                   delete, returns 410 when deleted)
    GET  /feed/post/{id}/stats   — {views, likes, liked_by_me?}
    POST /feed/post/{id}/view    — bump the counter
    GET  /feed/author/{pub}      — chain-authoritative post list
                                   enriched with body + stats
    GET  /feed/timeline          — merged feed from people the user
                                   follows (reads chain.Following,
                                   fetches each author's recent posts)
    GET  /feed/trending          — top-scored posts in last 24h
                                   (score = likes × 3 + views)
    GET  /feed/foryou            — simple recommendations: recent posts
                                   minus authors the user already
                                   follows, already-liked posts, and
                                   own posts; ranked by engagement
    GET  /feed/hashtag/{tag}     — posts tagged with the given #tag

  cmd/node/main.go wiring
    - --feed-db flag (DCHAIN_FEED_DB) + --feed-ttl-days (DCHAIN_FEED_TTL_DAYS)
    - Opens FeedMailbox + registers FeedRoutes alongside RelayRoutes
    - Threads chain.Post / LikeCount / HasLiked / PostsByAuthor / Following
      into FeedConfig so HTTP handlers can merge on-chain metadata with
      off-chain body+stats.

Auth & safety
  - POST /feed/publish: Ed25519 signature over "publish:<post_id>:
    <content_sha256_hex>:<ts>"; ±5-minute skew window for anti-replay.
  - content_hash binds body to the on-chain tx — you can't publish
    body-A off-chain and commit hash-of-body-B on-chain.
  - Writes wrapped in withSubmitTxGuards (rate-limit + size cap), reads
    in withReadLimit — same guards as /relay.

Trending / recommendations
  - V1 heuristic (likes × 3 + views) + time window. Documented as
    v2.2.0 "Feed algorithm" candidate for a proper ranking layer
    (half-life decay, follow-of-follow boost, hashtag collaborative).

Tests
  - Store round-trip, size enforcement, hashtag indexing (case-insensitive
    + dedup), view counter increments, author chrono order, delete
    cleans all indices, RecentPostIDs time-window filter.
  - Full go test ./... is green (blockchain + consensus + identity +
    relay + vm all pass).

Next (Phase C): client Feed tab — composer, timeline, post detail,
profile follow, For You + Trending screens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:52:22 +03:00
vsecoder
88848efa63 feat(chain): remove channels, add social feed (Phase A of v2.0.0)
Replaces the channel/membership model with a VK/Twitter-style feed:
public posts, follow graph, likes. Views are deliberately off-chain
(counted by the hosting relay, Phase B).

Removed
  - EventCreateChannel, EventAddMember
  - CreateChannelPayload, AddMemberPayload, ChannelMember
  - prefixChannel, prefixChanMember
  - chain.Channel(), chain.ChannelMembers()
  - node/api_channels.go
  - GetChannel, GetChannelMembers on ExplorerQuery

Added
  - Events: CREATE_POST, DELETE_POST, FOLLOW, UNFOLLOW, LIKE_POST, UNLIKE_POST
  - Payloads: CreatePostPayload, DeletePostPayload, FollowPayload,
    UnfollowPayload, LikePostPayload, UnlikePostPayload
  - Stored shape: PostRecord (author, size, hash, hosting relay, timestamp,
    reply/quote refs, soft-delete flag, fee paid)
  - State prefixes: post:, postbyauthor:, follow:, followin:, like:, likecount:
  - Queries: Post(), PostsByAuthor(), Following(), Followers(),
    LikeCount(), HasLiked()
  - Cached like counter via bumpLikeCount helper

Pricing
  - BasePostFee = 1000 µT (aligned with MinFee block-validation floor)
  - PostByteFee = 1 µT/byte of compressed content
  - Total fee credited in full to HostingRelay pub (storage compensation)
  - MaxPostSize = 256 KiB

Integrity
  - CREATE_POST validates content_hash length (32 B) and size range
  - DELETE_POST restricted to post.Author
  - Duplicate FOLLOW / LIKE rejected
  - reply_to and quote_of mutually exclusive

Tests
  - TestFeedCreatePost: post stored, indexed, host credited
  - TestFeedInsufficientFee: underpaid post is skipped
  - TestFeedFollowUnfollow: follow graph round-trips via forward + inbound indices
  - TestFeedLikeUnlike: like toggles with dedup, counter stays accurate
  - TestFeedDeletePostByOther: non-author deletion rejected

This is Phase A (chain-layer). Phase B adds the relay feed-mailbox
(post bodies + gossipsub) and HTTP endpoints. Phase C adds the client
Feed tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:36:00 +03:00
vsecoder
f2cb5586ca fix(relay): require signed Ed25519 auth on DELETE /relay/inbox/{id}
Previously the endpoint accepted an unauthenticated DELETE with just
?pub=X — anyone who knew (or enumerated) a pub could wipe that pub's
entire inbox, a trivial griefing vector. Now the handler requires a
JSON body with {ed25519_pub, sig, ts} where sig signs
"inbox-delete:<envID>:<pub>:<ts>" under the Ed25519 privkey. The
server then looks up the identity on-chain and verifies that the
registered X25519 public key matches the ?pub= query — closing the
gap between "I can sign" and "my identity owns this mailbox."

Timestamp window: ±300s to prevent replay of captured DELETEs.

Wires RelayConfig.ResolveX25519 via chain.Identity() in cmd/node/main.go.
When ResolveX25519 is nil the endpoint returns 503 (feature unavailable)
rather than silently allowing anonymous deletes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:57:24 +03:00
vsecoder
15d0ed306b fix(ws): hard-deny inbox:* / typing:* when authX is empty
The WS topic-auth check had a soft-fail fallback: if the authenticated
identity had no registered X25519 public key (authX == ""), the
topic-ownership check was skipped and the client could subscribe to
any inbox:* or typing:* topic. Exploit: register an Ed25519 identity
without an X25519 key, subscribe to the victim's inbox topic, receive
their envelope notifications.

Now both topics hard-require a registered X25519. Clients must call
REGISTER_KEY (publishing X25519) before subscribing. The scope is
narrow — only identities that haven't completed REGISTER_KEY yet could
have exploited this — but a hard fail is still correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:55:11 +03:00
vsecoder
8082dd0bf7 fix(node): rate-limit relay HTTP endpoints
Relay routes were not wrapped in any guards — /relay/broadcast accepted
unlimited writes from any IP, and /relay/inbox could be scraped at line
rate. Combined with the per-recipient FIFO eviction (MailboxPerRecipientCap=500),
an unauthenticated attacker could wipe a victim's real messages by
spamming 500 garbage envelopes. This commit wraps writes in
withSubmitTxGuards (10/s per IP + 256 KiB body cap) and reads in
withReadLimit (20/s per IP) — the same limits already used for
/api/tx and /api/address.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:54:08 +03:00
vsecoder
32eec62ba4 fix(chain): RELAY_PROOF dedup by envelopeID + sticky BlockContact
RELAY_PROOF previously had no per-envelope dedup — every relay that
saw the gossipsub re-broadcast could extract the sender's FeeSig from
the envelope and submit its own RELAY_PROOF claim with its own
RelayPubKey. The tx-ID uniqueness check didn't help because tx.ID =
sha256(relayPubKey||envelopeID)[:16], which is unique per (relay,
envelope) pair. A malicious mesh of N relays could drain N× the fee
from the sender's balance for a single message.

Fix: record prefixRelayProof:<envelopeID> on first successful apply
and reject subsequent claims for the same envelope.

CONTACT_REQUEST previously overwrote any prior record (including a
blocked one) back to pending, letting spammers unblock themselves by
paying another MinContactFee. Now the handler reads the existing
record first and rejects the tx with "recipient has blocked sender"
when prev.Status == ContactBlocked. Block becomes sticky.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:51:14 +03:00
105 changed files with 30008 additions and 275 deletions

5
.gitignore vendored
View File

@@ -60,4 +60,7 @@ Thumbs.db
# Not part of the release bundle — tracked separately # Not part of the release bundle — tracked separately
CONTEXT.md CONTEXT.md
CHANGELOG.md CHANGELOG.md
client-app/
# Client app sources are tracked from v2.0.0 onwards (feed feature made
# the client a first-class part of the release). Local state (node_modules,
# build artifacts, Expo cache) is ignored via client-app/.gitignore.

View File

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

193
README.md
View File

@@ -1,6 +1,6 @@
# DChain # DChain
Блокчейн-стек для децентрализованного мессенджера: Блокчейн-стек для децентрализованного мессенджера + социальной ленты:
- **PBFT** консенсус с multi-sig validator governance и equivocation slashing - **PBFT** консенсус с multi-sig validator governance и equivocation slashing
- **Native Go контракты** рядом с WASM (wazero) — нулевая задержка для - **Native Go контракты** рядом с WASM (wazero) — нулевая задержка для
@@ -8,6 +8,12 @@
- **WebSocket push API** — клиент не опрашивает, все события прилетают - **WebSocket push API** — клиент не опрашивает, все события прилетают
на соединение на соединение
- **E2E-шифрованный relay mailbox** на libp2p gossipsub с TTL live-detection - **E2E-шифрованный relay mailbox** на libp2p gossipsub с TTL live-detection
(1:1 чаты через NaCl box; посты в ленте — plaintext-публичные)
- **Социальная лента v2.0.0** (заменила каналы): публичные посты
с оплатой за размер (автор платит, хостящая релей-нода получает);
on-chain граф подписок + лайки; off-chain просмотры + хэштеги;
мандаторный server-side scrubber метаданных (EXIF/GPS-стрип + FFmpeg
sidecar для видео); share-to-chat c embedded post-карточкой
- **Система обновлений:** build-time версия → `/api/well-known-version`, - **Система обновлений:** build-time версия → `/api/well-known-version`,
peer-version gossip, `/api/update-check` против Gitea releases, peer-version gossip, `/api/update-check` против Gitea releases,
`update.sh` с semver guard `update.sh` с semver guard
@@ -16,6 +22,7 @@
## Содержание ## Содержание
- [Быстрый старт](#быстрый-старт) - [Быстрый старт](#быстрый-старт)
- [Поднятие ноды — пошагово](#поднятие-ноды--пошагово)
- [Продакшен деплой](#продакшен-деплой) - [Продакшен деплой](#продакшен-деплой)
- [Архитектура](#архитектура) - [Архитектура](#архитектура)
- [REST / WebSocket API](#rest--websocket-api) - [REST / WebSocket API](#rest--websocket-api)
@@ -60,6 +67,160 @@ curl -s http://localhost:8080/api/well-known-version | jq .
3-node dev-кластер (для тестов PBFT кворума, slashing, federation): `docker compose up --build -d` — см. [`docs/quickstart.md`](docs/quickstart.md). 3-node dev-кластер (для тестов PBFT кворума, slashing, federation): `docker compose up --build -d` — см. [`docs/quickstart.md`](docs/quickstart.md).
## Поднятие ноды — пошагово
Ниже — полный минимум для двух сценариев, которые покрывают 99% случаев:
**первая нода сети** (genesis) и **присоединение к существующей сети**.
Все флаги читаются также из соответствующего `DCHAIN_*` env-var (CLI > env > default).
### Шаг 1. Ключи
```bash
# Ключ identity ноды (Ed25519 — подпись блоков + tx)
./client keygen --out keys/node.json
# relay-ключ (X25519 — E2E-mailbox) создаётся нодой сам при первом старте,
# но можно задать путь заранее через --relay-key.
```
### Шаг 2a. Первая нода (genesis)
Поднимает новую сеть с одним валидатором. `--genesis=true` **только** для самой первой ноды и **только один раз** — если блок 0 уже есть в `--db`, флаг игнорируется.
```bash
./node \
--genesis=true \
--key=keys/node.json \
--db=./chaindata \
--mailbox-db=./mailboxdata \
--feed-db=./feeddata \
--listen=/ip4/0.0.0.0/tcp/4001 \
--announce=/ip4/<ВАШ-ПУБЛИЧНЫЙ-IP>/tcp/4001 \
--stats-addr=:8080 \
--register-relay=true \
--relay-fee=1000
```
`--announce` **обязателен** для любой ноды смотрящей в интернет (VPS / внешний IP / Docker с проброшенным портом). Без него libp2p пытается UPnP/NAT-PMP и чаще всего промахивается.
### Шаг 2b. Вторая и последующие ноды
Нужен **один** из двух способов узнать первую ноду. Второй удобнее.
**Через HTTP URL живой ноды** (рекомендуется — нода сама заберёт multiaddr через `/api/network-info`, проверит genesis_hash и синхронизирует цепь):
```bash
./node \
--join=https://first-node.example.com \
--key=keys/node.json \
--db=./chaindata \
--mailbox-db=./mailboxdata \
--feed-db=./feeddata \
--listen=/ip4/0.0.0.0/tcp/4001 \
--announce=/ip4/<ВАШ-ПУБЛИЧНЫЙ-IP>/tcp/4001 \
--stats-addr=:8080 \
--register-relay=true \
--relay-fee=1000
```
**Через libp2p multiaddr** (если есть прямой мульти-адрес):
```bash
./node \
--peers=/ip4/1.2.3.4/tcp/4001/p2p/12D3KooW... \
# остальные флаги как выше
```
**Автоприсоединение к validator set** происходит не само: после того как нода синхронизируется, действующий validator должен вызвать `client add-validator --target <your-pub> --cosigs ...` (multi-sig admit). До этого новая нода живёт как **observer** — читает и гоняет tx, но не голосует. Запустить ноду **явно** как observer (никогда не проситься в validator set): `--observer=true`.
### Все флаги `node`
CLI / env / default. Группы:
**Storage**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--db` | `DCHAIN_DB` | `./chaindata` | BadgerDB блокчейна |
| `--mailbox-db` | `DCHAIN_MAILBOX_DB` | `./mailboxdata` | E2E-конверты 1:1 чатов |
| `--feed-db` | `DCHAIN_FEED_DB` | `./feeddata` | Тела постов ленты (off-chain) |
| `--feed-ttl-days` | `DCHAIN_FEED_TTL_DAYS` | `30` | Через сколько дней тела постов auto-evict'ятся (метаданные on-chain остаются вечно) |
**Identity**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--key` | `DCHAIN_KEY` | `./node.json` | Ed25519 ключ ноды |
| `--relay-key` | `DCHAIN_RELAY_KEY` | `./relay.json` | X25519 ключ для relay-mailbox (создастся сам) |
| `--wallet` | `DCHAIN_WALLET` | — | Отдельный payout-кошелёк (опционально) |
| `--wallet-pass` | `DCHAIN_WALLET_PASS` | — | Парольная фраза для wallet-файла |
**Network**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--listen` | `DCHAIN_LISTEN` | `/ip4/0.0.0.0/tcp/4001` | libp2p listen multiaddr |
| `--announce` | `DCHAIN_ANNOUNCE` | — | Multiaddr который нода рассказывает пирам (обязателен на VPS/внешнем IP) |
| `--peers` | `DCHAIN_PEERS` | — | Bootstrap multiaddrs, comma-separated |
| `--join` | `DCHAIN_JOIN` | — | HTTP URL живой ноды для авто-дискавери — получает peers и genesis_hash |
| `--allow-genesis-mismatch` | — | `false` | Отключить защиту, падающую при расхождении локального и seed'ового genesis (только для явной миграции) |
**Consensus & role**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--genesis` | `DCHAIN_GENESIS` | `false` | Создать блок 0 (только для самой первой ноды сети) |
| `--validators` | `DCHAIN_VALIDATORS` | — | Исходный validator set (CSV pub-keys) — применяется только при genesis |
| `--observer` | `DCHAIN_OBSERVER` | `false` | Observer-режим: синхронизируется и отдаёт API, но не голосует и не предлагает блоки |
| `--heartbeat` | `DCHAIN_HEARTBEAT` | `true` | Периодический HEARTBEAT-tx (нужен для liveness-детекции валидаторов) |
**Relay / mailbox**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--register-relay` | `DCHAIN_REGISTER_RELAY` | `false` | Отправить `REGISTER_RELAY` tx на старте (объявить ноду публичным relay'ем) |
| `--relay-fee` | `DCHAIN_RELAY_FEE` | `1000` | Плата за доставку одного сообщения в µT (1000 = 0.001 T). `0` = бесплатный relay |
**Media scrubber (feed)**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--media-sidecar-url` | `DCHAIN_MEDIA_SIDECAR_URL` | — | URL FFmpeg-сайдкара для видео-скраба. Пустой = только картинки |
| `--allow-unscrubbed-video` | `DCHAIN_ALLOW_UNSCRUBBED_VIDEO` | `false` | Принимать видео **без** серверного скраба (опасно — EXIF/GPS/автор-теги останутся) |
**HTTP API**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--stats-addr` | `DCHAIN_STATS_ADDR` | `:8080` | Адрес HTTP/WS сервера |
| `--api-token` | `DCHAIN_API_TOKEN` | — | Bearer-токен для submit tx. Пустой = публичная нода |
| `--api-private` | `DCHAIN_API_PRIVATE` | `false` | Требовать токен также на чтение |
| `--disable-ui` | `DCHAIN_DISABLE_UI` | `false` | Отключить HTML-explorer (JSON API остаётся) |
| `--disable-swagger` | `DCHAIN_DISABLE_SWAGGER` | `false` | Отключить `/swagger*` |
**Resource caps** (новое в v2.1.0)
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--max-cpu` | `DCHAIN_MAX_CPU` | `0` | Сколько CPU-ядер Go-runtime'у (`GOMAXPROCS`). `0` = все |
| `--max-ram-mb` | `DCHAIN_MAX_RAM_MB` | `0` | Soft-лимит Go-хипа в MiB (`GOMEMLIMIT`). `0` = без лимита. *Не OOM-kill'ит — усиливает GC при приближении* |
| `--feed-disk-limit-mb` | `DCHAIN_FEED_DISK_LIMIT_MB` | `0` | Жёсткая квота на feed-БД. При превышении `/feed/publish` отвечает 507. Существующие посты продолжают отдаваться |
| `--chain-disk-limit-mb` | `DCHAIN_CHAIN_DISK_LIMIT_MB` | `0` | Advisory-квота на блокчейн-БД. Превышение → `WARN` в лог раз в минуту (жёстко не отказываем — сломали бы консенсус) |
Для реального sandboxing (hard-kill при OOM, hard CPU throttling) используйте `docker run --cpus --memory` или systemd `CPUQuota` / `MemoryMax` поверх этих флагов.
**Update / versioning**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--update-source-url` | `DCHAIN_UPDATE_SOURCE_URL` | — | Gitea `/api/v1/repos/{owner}/{repo}/releases/latest` для `/api/update-check` |
| `--update-source-token` | `DCHAIN_UPDATE_SOURCE_TOKEN` | — | PAT для приватного репо |
| `--log-format` | `DCHAIN_LOG_FORMAT` | `text` | `text` (human) или `json` (Loki/ELK) |
| `--governance-contract` | `DCHAIN_GOVERNANCE_CONTRACT` | — | ID governance-контракта для динамических параметров |
| `--version` | — | — | Печатает версию и выходит |
### Минимальные чек-листы
**Первая нода (открытая):** `--genesis=true` + `--key` + `--announce` на внешний IP + `--stats-addr` + опционально `--register-relay=true --relay-fee=...` чтобы сразу монетизировать relay-трафик.
**Joiner:** `--join=<url-любой-живой-ноды>` + `--key` + `--announce` + `--stats-addr`. После синка попросите действующего валидатора поднять `add-validator` (иначе остаётесь observer'ом до принятия — это нормально и безопасно).
**Приватная/домашняя нода** без публичного эксплорера: добавьте `--api-token=<random>`, `--api-private=true`, `--disable-ui=true`, `--disable-swagger=true`. Clients передают `Authorization: Bearer <token>`.
**Слабое железо:** `--max-cpu=2 --max-ram-mb=1024 --feed-disk-limit-mb=2048 --chain-disk-limit-mb=10240`.
Docker-обёртка с теми же флагами — в [`deploy/single/README.md`](deploy/single/README.md).
## Продакшен деплой ## Продакшен деплой
Два варианта, по масштабу. Два варианта, по масштабу.
@@ -142,7 +303,8 @@ sudo systemctl enable --now dchain-update.timer
| `node/` | HTTP + WS API, SSE, metrics, access control | | `node/` | HTTP + WS API, SSE, metrics, access control |
| `node/version/` | Build-time version metadata (ldflags-инжектимый) | | `node/version/` | Build-time version metadata (ldflags-инжектимый) |
| `vm/` | wazero runtime для WASM-контрактов + gas model | | `vm/` | wazero runtime для WASM-контрактов + gas model |
| `relay/` | E2E mailbox с NaCl-envelopes | | `relay/` | E2E mailbox (1:1 envelopes) + public feed-mailbox (post bodies, view counter, hashtag index) |
| `media/` | Server-side metadata scrubber (EXIF strip + FFmpeg sidecar client) |
| `identity/` | Ed25519 + X25519 keypair, tx signing | | `identity/` | Ed25519 + X25519 keypair, tx signing |
| `economy/` | Fee model, rewards | | `economy/` | Fee model, rewards |
| `wallet/` | Optional payout wallet (отдельный ключ) | | `wallet/` | Optional payout wallet (отдельный ключ) |
@@ -179,6 +341,33 @@ sudo systemctl enable --now dchain-update.timer
Scoped WS-топики (`addr:`, `inbox:`, `typing:`) требуют auth через Scoped WS-топики (`addr:`, `inbox:`, `typing:`) требуют auth через
Ed25519-nonce; публичные (`blocks`, `tx`, `contract_log`) — без. Ed25519-nonce; публичные (`blocks`, `tx`, `contract_log`) — без.
### Relay (E2E messaging)
| Endpoint | Описание |
|----------|----------|
| `POST /relay/broadcast` | Опубликовать pre-sealed envelope (E2E-путь, рекомендован) |
| `GET /relay/inbox?pub=<x25519>` | Прочитать входящие конверты |
| `DELETE /relay/inbox/{id}` | Удалить envelope (требует Ed25519-подписи владельца) |
Детали — [`docs/api/relay.md`](docs/api/relay.md). `/relay/send` оставлен
для backward-compat, но ломает E2E (nod-релей запечатывает своим ключом)
и помечен как non-recommended.
### Social feed (v2.0.0)
| Endpoint | Описание |
|----------|----------|
| `POST /feed/publish` | Загрузить тело поста + EXIF-скраб + вернуть fee |
| `GET /feed/post/{id}` | Тело поста |
| `GET /feed/post/{id}/attachment` | Сырые байты картинки/видео (cache'able) |
| `GET /feed/post/{id}/stats?me=<pub>` | `{views, likes, liked_by_me?}` |
| `POST /feed/post/{id}/view` | Бамп off-chain счётчика просмотров |
| `GET /feed/author/{pub}?before=<ts>&limit=N` | Посты автора (пагинация `before`) |
| `GET /feed/timeline?follower=<pub>&before=<ts>&limit=N` | Merged лента подписок |
| `GET /feed/trending?window=24&limit=N` | Топ по `likes × 3 + views` за окно |
| `GET /feed/foryou?pub=<pub>&limit=N` | Рекомендации (неподписанные авторы) |
| `GET /feed/hashtag/{tag}?limit=N` | Посты по хэштегу |
Детали + спецификация — [`docs/api/feed.md`](docs/api/feed.md).
### Docs / UI ### Docs / UI
- `GET /swagger`**Swagger UI** (рендерится через swagger-ui-dist). - `GET /swagger`**Swagger UI** (рендерится через swagger-ui-dist).
- `GET /swagger/openapi.json` — сырая OpenAPI 3.0 спека. - `GET /swagger/openapi.json` — сырая OpenAPI 3.0 спека.

View File

@@ -40,13 +40,20 @@ const (
prefixHeight = "height" // height → uint64 prefixHeight = "height" // height → uint64
prefixBalance = "balance:" // balance:<pubkey> → uint64 prefixBalance = "balance:" // balance:<pubkey> → uint64
prefixIdentity = "id:" // id:<pubkey> → RegisterKeyPayload JSON prefixIdentity = "id:" // id:<pubkey> → RegisterKeyPayload JSON
prefixChannel = "chan:" // chan:<channelID> → CreateChannelPayload JSON
prefixChanMember = "chan-member:" // chan-member:<channelID>:<memberPubKey> → "" (presence = member) // Social feed (v2.0.0). Replaced the old channel keys (chan:, chan-member:).
prefixPost = "post:" // post:<postID> → PostRecord JSON
prefixPostByAuthor = "postbyauthor:" // postbyauthor:<author>:<ts_20d>:<postID> → "" (chrono index)
prefixFollow = "follow:" // follow:<follower>:<target> → "" (presence = follows)
prefixFollowInbound = "followin:" // followin:<target>:<follower> → "" (reverse index — counts followers)
prefixLike = "like:" // like:<postID>:<liker> → "" (presence = liked)
prefixLikeCount = "likecount:" // likecount:<postID> → uint64 (cached count)
prefixWalletBind = "walletbind:" // walletbind:<node_pubkey> → wallet_pubkey (string) prefixWalletBind = "walletbind:" // walletbind:<node_pubkey> → wallet_pubkey (string)
prefixReputation = "rep:" // rep:<pubkey> → RepStats JSON prefixReputation = "rep:" // rep:<pubkey> → RepStats JSON
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active) prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
@@ -537,11 +544,13 @@ func (c *Chain) Identity(pubKeyHex string) (*RegisterKeyPayload, error) {
return &p, err return &p, err
} }
// Channel returns the CreateChannelPayload for a channel ID, or nil. // ── Feed queries (v2.0.0) ──────────────────────────────────────────────────
func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) {
var p CreateChannelPayload // Post returns the PostRecord for a post ID, or nil if not found.
func (c *Chain) Post(postID string) (*PostRecord, error) {
var p PostRecord
err := c.db.View(func(txn *badger.Txn) error { err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixChannel + channelID)) item, err := txn.Get([]byte(prefixPost + postID))
if err != nil { if err != nil {
return err return err
} }
@@ -552,13 +561,69 @@ func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) {
if errors.Is(err, badger.ErrKeyNotFound) { if errors.Is(err, badger.ErrKeyNotFound) {
return nil, nil return nil, nil
} }
return &p, err if err != nil {
return nil, err
}
return &p, nil
} }
// ChannelMembers returns the public keys of all members added to channelID. // PostsByAuthor returns the last `limit` posts by the given author, newest
func (c *Chain) ChannelMembers(channelID string) ([]string, error) { // first. Iterates `postbyauthor:<author>:...` in reverse order. If limit
prefix := []byte(fmt.Sprintf("%s%s:", prefixChanMember, channelID)) // ≤ 0, defaults to 50; capped at 200.
var members []string //
// If beforeTs > 0, skip posts with CreatedAt >= beforeTs — used by the
// timeline/author endpoints to paginate older results. Pass 0 for the
// first page (everything, newest first).
func (c *Chain) PostsByAuthor(authorPub string, beforeTs int64, limit int) ([]*PostRecord, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
prefix := []byte(prefixPostByAuthor + authorPub + ":")
var out []*PostRecord
err := c.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
opts.Reverse = true // newest (higher ts) first — reverse iteration
opts.PrefetchValues = false
// For reverse iteration Badger requires seeking past the prefix range.
seek := append([]byte{}, prefix...)
seek = append(seek, 0xff)
it := txn.NewIterator(opts)
defer it.Close()
for it.Seek(seek); it.ValidForPrefix(prefix) && len(out) < limit; it.Next() {
key := string(it.Item().Key())
// key = "postbyauthor:<author>:<ts_20d>:<postID>"
parts := strings.Split(key, ":")
if len(parts) < 4 {
continue
}
postID := parts[len(parts)-1]
rec, err := c.postInTxn(txn, postID)
if err != nil || rec == nil {
continue
}
if rec.Deleted {
continue
}
if beforeTs > 0 && rec.CreatedAt >= beforeTs {
continue
}
out = append(out, rec)
}
return nil
})
return out, err
}
// Following returns the Ed25519 pubkeys that `follower` subscribes to.
func (c *Chain) Following(followerPub string) ([]string, error) {
prefix := []byte(prefixFollow + followerPub + ":")
var out []string
err := c.db.View(func(txn *badger.Txn) error { err := c.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false opts.PrefetchValues = false
@@ -567,15 +632,95 @@ func (c *Chain) ChannelMembers(channelID string) ([]string, error) {
defer it.Close() defer it.Close()
for it.Rewind(); it.Valid(); it.Next() { for it.Rewind(); it.Valid(); it.Next() {
key := string(it.Item().Key()) key := string(it.Item().Key())
// key = "chan-member:<channelID>:<memberPubKey>" // key = "follow:<follower>:<target>"
parts := strings.SplitN(key, ":", 3) parts := strings.SplitN(key, ":", 3)
if len(parts) == 3 { if len(parts) == 3 {
members = append(members, parts[2]) out = append(out, parts[2])
} }
} }
return nil return nil
}) })
return members, err return out, err
}
// Followers returns the Ed25519 pubkeys that follow `target`.
func (c *Chain) Followers(targetPub string) ([]string, error) {
prefix := []byte(prefixFollowInbound + targetPub + ":")
var out []string
err := c.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
key := string(it.Item().Key())
parts := strings.SplitN(key, ":", 3)
if len(parts) == 3 {
out = append(out, parts[2])
}
}
return nil
})
return out, err
}
// LikeCount returns the cached count of likes for a post (O(1)).
func (c *Chain) LikeCount(postID string) (uint64, error) {
var count uint64
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixLikeCount + postID))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if len(val) == 8 {
count = binary.BigEndian.Uint64(val)
}
return nil
})
})
return count, err
}
// HasLiked reports whether `liker` has liked the given post.
func (c *Chain) HasLiked(postID, likerPub string) (bool, error) {
key := []byte(prefixLike + postID + ":" + likerPub)
var ok bool
err := c.db.View(func(txn *badger.Txn) error {
_, err := txn.Get(key)
if err == nil {
ok = true
return nil
}
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
return err
})
return ok, err
}
// postInTxn is the internal helper used by iteration paths to fetch a full
// PostRecord without opening a new View transaction.
func (c *Chain) postInTxn(txn *badger.Txn, postID string) (*PostRecord, error) {
item, err := txn.Get([]byte(prefixPost + postID))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
var p PostRecord
if err := item.Value(func(val []byte) error {
return json.Unmarshal(val, &p)
}); err != nil {
return nil, err
}
return &p, nil
} }
// WalletBinding returns the payout wallet pub key bound to a node, or "" if none. // WalletBinding returns the payout wallet pub key bound to a node, or "" if none.
@@ -740,41 +885,197 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
return 0, err return 0, err
} }
case EventCreateChannel: // ── Feed events (v2.0.0) ──────────────────────────────────────────
var p CreateChannelPayload case EventCreatePost:
var p CreatePostPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil { if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: CREATE_CHANNEL bad payload: %v", ErrTxFailed, err) return 0, fmt.Errorf("%w: CREATE_POST bad payload: %v", ErrTxFailed, err)
}
if p.PostID == "" {
return 0, fmt.Errorf("%w: CREATE_POST: post_id required", ErrTxFailed)
}
if len(p.ContentHash) != 32 {
return 0, fmt.Errorf("%w: CREATE_POST: content_hash must be 32 bytes", ErrTxFailed)
}
if p.HostingRelay == "" {
return 0, fmt.Errorf("%w: CREATE_POST: hosting_relay required", ErrTxFailed)
}
if p.Size == 0 || p.Size > MaxPostSize {
return 0, fmt.Errorf("%w: CREATE_POST: size %d out of range (0, %d]",
ErrTxFailed, p.Size, MaxPostSize)
}
if p.ReplyTo != "" && p.QuoteOf != "" {
return 0, fmt.Errorf("%w: CREATE_POST: reply_to and quote_of are mutually exclusive", ErrTxFailed)
}
// Duplicate check — same post_id may only commit once.
if _, err := txn.Get([]byte(prefixPost + p.PostID)); err == nil {
return 0, fmt.Errorf("%w: CREATE_POST: post %s already exists", ErrTxFailed, p.PostID)
}
// Fee formula: BasePostFee + size × PostByteFee. tx.Fee carries the
// full amount; we validate it matches and the sender can afford it.
expectedFee := BasePostFee + p.Size*PostByteFee
if tx.Fee < expectedFee {
return 0, fmt.Errorf("%w: CREATE_POST: fee %d < required %d (base %d + %d × %d bytes)",
ErrTxFailed, tx.Fee, expectedFee, BasePostFee, PostByteFee, p.Size)
} }
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("CREATE_CHANNEL debit: %w", err) return 0, fmt.Errorf("CREATE_POST debit: %w", err)
} }
val, _ := json.Marshal(p) // Full fee goes to the hosting relay (storage compensation). No
if err := txn.Set([]byte(prefixChannel+p.ChannelID), val); err != nil { // validator cut on posts — validators earn from other tx types. This
// incentivises nodes to actually host posts.
relayTarget, err := c.resolveRewardTarget(txn, p.HostingRelay)
if err != nil {
return 0, err
}
if err := c.creditBalance(txn, relayTarget, tx.Fee); err != nil {
return 0, fmt.Errorf("credit hosting relay: %w", err)
}
rec := PostRecord{
PostID: p.PostID,
Author: tx.From,
ContentHash: p.ContentHash,
Size: p.Size,
HostingRelay: p.HostingRelay,
ReplyTo: p.ReplyTo,
QuoteOf: p.QuoteOf,
CreatedAt: tx.Timestamp.Unix(),
FeeUT: tx.Fee,
}
recBytes, _ := json.Marshal(rec)
if err := txn.Set([]byte(prefixPost+p.PostID), recBytes); err != nil {
return 0, err
}
// Chrono index — allows PostsByAuthor to list newest-first in O(N).
idxKey := fmt.Sprintf("%s%s:%020d:%s", prefixPostByAuthor, tx.From, rec.CreatedAt, p.PostID)
if err := txn.Set([]byte(idxKey), []byte{}); err != nil {
return 0, err return 0, err
} }
case EventAddMember: case EventDeletePost:
var p AddMemberPayload var p DeletePostPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil { if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: ADD_MEMBER bad payload: %v", ErrTxFailed, err) return 0, fmt.Errorf("%w: DELETE_POST bad payload: %v", ErrTxFailed, err)
} }
if p.ChannelID == "" { if p.PostID == "" {
return 0, fmt.Errorf("%w: ADD_MEMBER: channel_id required", ErrTxFailed) return 0, fmt.Errorf("%w: DELETE_POST: post_id required", ErrTxFailed)
} }
if _, err := txn.Get([]byte(prefixChannel + p.ChannelID)); err != nil { item, err := txn.Get([]byte(prefixPost + p.PostID))
if errors.Is(err, badger.ErrKeyNotFound) { if errors.Is(err, badger.ErrKeyNotFound) {
return 0, fmt.Errorf("%w: ADD_MEMBER: channel %q not found", ErrTxFailed, p.ChannelID) return 0, fmt.Errorf("%w: DELETE_POST: post %s not found", ErrTxFailed, p.PostID)
}
if err != nil {
return 0, err
}
var rec PostRecord
if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err != nil {
return 0, err
}
if rec.Author != tx.From {
return 0, fmt.Errorf("%w: DELETE_POST: only author can delete", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("DELETE_POST debit: %w", err)
}
rec.Deleted = true
val, _ := json.Marshal(rec)
if err := txn.Set([]byte(prefixPost+p.PostID), val); err != nil {
return 0, err
}
case EventFollow:
if tx.To == "" {
return 0, fmt.Errorf("%w: FOLLOW: target (to) is required", ErrTxFailed)
}
if tx.To == tx.From {
return 0, fmt.Errorf("%w: FOLLOW: cannot follow yourself", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("FOLLOW debit: %w", err)
}
// follow:<follower>:<target> + reverse index followin:<target>:<follower>
fKey := []byte(prefixFollow + tx.From + ":" + tx.To)
if _, err := txn.Get(fKey); err == nil {
return 0, fmt.Errorf("%w: FOLLOW: already following", ErrTxFailed)
}
if err := txn.Set(fKey, []byte{}); err != nil {
return 0, err
}
if err := txn.Set([]byte(prefixFollowInbound+tx.To+":"+tx.From), []byte{}); err != nil {
return 0, err
}
case EventUnfollow:
if tx.To == "" {
return 0, fmt.Errorf("%w: UNFOLLOW: target (to) is required", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("UNFOLLOW debit: %w", err)
}
fKey := []byte(prefixFollow + tx.From + ":" + tx.To)
if _, err := txn.Get(fKey); err != nil {
if errors.Is(err, badger.ErrKeyNotFound) {
return 0, fmt.Errorf("%w: UNFOLLOW: not following", ErrTxFailed)
}
return 0, err
}
if err := txn.Delete(fKey); err != nil {
return 0, err
}
if err := txn.Delete([]byte(prefixFollowInbound + tx.To + ":" + tx.From)); err != nil {
return 0, err
}
case EventLikePost:
var p LikePostPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: LIKE_POST bad payload: %v", ErrTxFailed, err)
}
if p.PostID == "" {
return 0, fmt.Errorf("%w: LIKE_POST: post_id required", ErrTxFailed)
}
if _, err := txn.Get([]byte(prefixPost + p.PostID)); err != nil {
if errors.Is(err, badger.ErrKeyNotFound) {
return 0, fmt.Errorf("%w: LIKE_POST: post %s not found", ErrTxFailed, p.PostID)
}
return 0, err
}
lKey := []byte(prefixLike + p.PostID + ":" + tx.From)
if _, err := txn.Get(lKey); err == nil {
return 0, fmt.Errorf("%w: LIKE_POST: already liked", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("LIKE_POST debit: %w", err)
}
if err := txn.Set(lKey, []byte{}); err != nil {
return 0, err
}
if err := bumpLikeCount(txn, p.PostID, +1); err != nil {
return 0, err
}
case EventUnlikePost:
var p UnlikePostPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: UNLIKE_POST bad payload: %v", ErrTxFailed, err)
}
if p.PostID == "" {
return 0, fmt.Errorf("%w: UNLIKE_POST: post_id required", ErrTxFailed)
}
lKey := []byte(prefixLike + p.PostID + ":" + tx.From)
if _, err := txn.Get(lKey); err != nil {
if errors.Is(err, badger.ErrKeyNotFound) {
return 0, fmt.Errorf("%w: UNLIKE_POST: not liked", ErrTxFailed)
} }
return 0, err return 0, err
} }
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("ADD_MEMBER debit: %w", err) return 0, fmt.Errorf("UNLIKE_POST debit: %w", err)
} }
member := tx.To if err := txn.Delete(lKey); err != nil {
if member == "" { return 0, err
member = tx.From
} }
if err := txn.Set([]byte(fmt.Sprintf("%s%s:%s", prefixChanMember, p.ChannelID, member)), []byte{}); err != nil { if err := bumpLikeCount(txn, p.PostID, -1); err != nil {
return 0, err return 0, err
} }
@@ -795,9 +1096,21 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
if err := json.Unmarshal(tx.Payload, &p); err != nil { if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: RELAY_PROOF bad payload: %v", ErrTxFailed, err) return 0, fmt.Errorf("%w: RELAY_PROOF bad payload: %v", ErrTxFailed, err)
} }
if p.EnvelopeID == "" {
return 0, fmt.Errorf("%w: RELAY_PROOF: envelope_id is required", ErrTxFailed)
}
if p.SenderPubKey == "" || p.FeeUT == 0 || len(p.FeeSig) == 0 { if p.SenderPubKey == "" || p.FeeUT == 0 || len(p.FeeSig) == 0 {
return 0, fmt.Errorf("%w: relay proof missing fee authorization fields", ErrTxFailed) return 0, fmt.Errorf("%w: relay proof missing fee authorization fields", ErrTxFailed)
} }
// Per-envelope dedup — only one relay may claim the fee for a given
// envelope. Without this check, every relay that saw the gossipsub
// re-broadcast could extract the sender's FeeSig and submit its own
// RELAY_PROOF, draining the sender's balance by N× for one message.
proofKey := []byte(prefixRelayProof + p.EnvelopeID)
if _, err := txn.Get(proofKey); err == nil {
return 0, fmt.Errorf("%w: RELAY_PROOF: envelope %s already claimed",
ErrTxFailed, p.EnvelopeID)
}
authBytes := FeeAuthBytes(p.EnvelopeID, p.FeeUT) authBytes := FeeAuthBytes(p.EnvelopeID, p.FeeUT)
ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig) ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig)
if err != nil || !ok { if err != nil || !ok {
@@ -818,6 +1131,10 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
}); err != nil { }); err != nil {
return 0, err return 0, err
} }
// Mark envelope as claimed — prevents replay by other relays.
if err := txn.Set(proofKey, []byte(p.RelayPubKey)); err != nil {
return 0, fmt.Errorf("mark relay proof: %w", err)
}
case EventBindWallet: case EventBindWallet:
var p BindWalletPayload var p BindWalletPayload
@@ -956,6 +1273,19 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
return 0, fmt.Errorf("%w: CONTACT_REQUEST: amount %d < MinContactFee %d", return 0, fmt.Errorf("%w: CONTACT_REQUEST: amount %d < MinContactFee %d",
ErrTxFailed, tx.Amount, MinContactFee) ErrTxFailed, tx.Amount, MinContactFee)
} }
// Sticky block — if recipient previously blocked this sender, refuse
// the new request instead of silently overwriting the blocked status
// back to pending. Prevents unblock-via-respam.
key := prefixContactIn + tx.To + ":" + tx.From
if item, err := txn.Get([]byte(key)); err == nil {
var prev contactRecord
if verr := item.Value(func(val []byte) error {
return json.Unmarshal(val, &prev)
}); verr == nil && prev.Status == string(ContactBlocked) {
return 0, fmt.Errorf("%w: CONTACT_REQUEST: recipient has blocked sender",
ErrTxFailed)
}
}
if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil { if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil {
return 0, fmt.Errorf("CONTACT_REQUEST debit: %w", err) return 0, fmt.Errorf("CONTACT_REQUEST debit: %w", err)
} }
@@ -970,7 +1300,6 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
CreatedAt: tx.Timestamp.Unix(), CreatedAt: tx.Timestamp.Unix(),
} }
val, _ := json.Marshal(rec) val, _ := json.Marshal(rec)
key := prefixContactIn + tx.To + ":" + tx.From
if err := txn.Set([]byte(key), val); err != nil { if err := txn.Set([]byte(key), val); err != nil {
return 0, fmt.Errorf("store contact record: %w", err) return 0, fmt.Errorf("store contact record: %w", err)
} }
@@ -2307,6 +2636,35 @@ func (c *Chain) isValidatorTxn(txn *badger.Txn, pubKey string) (bool, error) {
// verifyEd25519 verifies an Ed25519 signature without importing the identity package // verifyEd25519 verifies an Ed25519 signature without importing the identity package
// (which would create a circular dependency). // (which would create a circular dependency).
// bumpLikeCount adjusts the cached like counter for a post. delta = ±1.
// Clamps at zero so a corrupt unlike without prior like can't underflow.
func bumpLikeCount(txn *badger.Txn, postID string, delta int64) error {
key := []byte(prefixLikeCount + postID)
var cur uint64
item, err := txn.Get(key)
if err == nil {
if verr := item.Value(func(val []byte) error {
if len(val) == 8 {
cur = binary.BigEndian.Uint64(val)
}
return nil
}); verr != nil {
return verr
}
} else if !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
switch {
case delta < 0 && cur > 0:
cur--
case delta > 0:
cur++
}
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], cur)
return txn.Set(key, buf[:])
}
func verifyEd25519(pubKeyHex string, msg, sig []byte) (bool, error) { func verifyEd25519(pubKeyHex string, msg, sig []byte) (bool, error) {
pubBytes, err := hex.DecodeString(pubKeyHex) pubBytes, err := hex.DecodeString(pubKeyHex)
if err != nil { if err != nil {

View File

@@ -794,3 +794,248 @@ var _ = identity.Generate
// Ensure ed25519 and hex are used directly (they may be used via helpers). // Ensure ed25519 and hex are used directly (they may be used via helpers).
var _ = ed25519.PublicKey(nil) var _ = ed25519.PublicKey(nil)
var _ = hex.EncodeToString var _ = hex.EncodeToString
// ── Feed (v2.0.0) ──────────────────────────────────────────────────────────
// TestFeedCreatePost: post commits, indexes, credits the hosting relay.
func TestFeedCreatePost(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
alice := newIdentity(t) // post author
host := newIdentity(t) // hosting relay pubkey
genesis := addGenesis(t, c, val)
// Fund alice + host.
const postSize = uint64(200)
expectedFee := blockchain.BasePostFee + postSize*blockchain.PostByteFee
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
expectedFee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
time.Sleep(2 * time.Millisecond) // ensure distinct txID (nanosec clock)
fundHost := makeTx(blockchain.EventTransfer, val.PubKeyHex(), host.PubKeyHex(),
blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundHost})
mustAddBlock(t, c, b1)
hostBalBefore, _ := c.Balance(host.PubKeyHex())
h := sha256.Sum256([]byte("hello world post body"))
postPayload := blockchain.CreatePostPayload{
PostID: "post1",
ContentHash: h[:],
Size: postSize,
HostingRelay: host.PubKeyHex(),
}
postTx := makeTx(
blockchain.EventCreatePost,
alice.PubKeyHex(), "",
0, expectedFee, // Fee = base + size*byte_fee; amount = 0
mustJSON(postPayload),
)
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
mustAddBlock(t, c, b2)
rec, err := c.Post("post1")
if err != nil || rec == nil {
t.Fatalf("Post(\"post1\") = %v, %v; want record", rec, err)
}
if rec.Author != alice.PubKeyHex() {
t.Errorf("author: got %q want %q", rec.Author, alice.PubKeyHex())
}
if rec.Size != postSize {
t.Errorf("size: got %d want %d", rec.Size, postSize)
}
// Host should have been credited the full fee.
hostBalAfter, _ := c.Balance(host.PubKeyHex())
if hostBalAfter != hostBalBefore+expectedFee {
t.Errorf("host balance: got %d, want %d (delta %d)",
hostBalAfter, hostBalBefore, expectedFee)
}
// PostsByAuthor should list it.
posts, err := c.PostsByAuthor(alice.PubKeyHex(), 0, 10)
if err != nil {
t.Fatalf("PostsByAuthor: %v", err)
}
if len(posts) != 1 || posts[0].PostID != "post1" {
t.Errorf("PostsByAuthor: got %v, want [post1]", posts)
}
}
// TestFeedInsufficientFee: size-based fee is enforced.
func TestFeedInsufficientFee(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
alice := newIdentity(t)
host := newIdentity(t)
genesis := addGenesis(t, c, val)
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
10*blockchain.Token, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice})
mustAddBlock(t, c, b1)
const postSize = uint64(1000)
h := sha256.Sum256([]byte("body"))
postPayload := blockchain.CreatePostPayload{
PostID: "underpaid",
ContentHash: h[:],
Size: postSize,
HostingRelay: host.PubKeyHex(),
}
// Fee too low — base alone without the size component.
// (Must still be ≥ MinFee so the chain-level block validation passes;
// the per-event CREATE_POST check is what should reject it.)
postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "",
0, blockchain.MinFee, mustJSON(postPayload))
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
mustAddBlock(t, c, b2) // block commits, the tx is skipped (logged)
if rec, _ := c.Post("underpaid"); rec != nil {
t.Fatalf("post was stored despite insufficient fee: %+v", rec)
}
}
// TestFeedFollowUnfollow: follow graph round-trips via indices.
func TestFeedFollowUnfollow(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
alice := newIdentity(t)
bob := newIdentity(t)
genesis := addGenesis(t, c, val)
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice})
mustAddBlock(t, c, b1)
followTx := makeTx(blockchain.EventFollow, alice.PubKeyHex(), bob.PubKeyHex(),
0, blockchain.MinFee, mustJSON(blockchain.FollowPayload{}))
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{followTx})
mustAddBlock(t, c, b2)
following, _ := c.Following(alice.PubKeyHex())
if len(following) != 1 || following[0] != bob.PubKeyHex() {
t.Errorf("Following: got %v, want [%s]", following, bob.PubKeyHex())
}
followers, _ := c.Followers(bob.PubKeyHex())
if len(followers) != 1 || followers[0] != alice.PubKeyHex() {
t.Errorf("Followers: got %v, want [%s]", followers, alice.PubKeyHex())
}
// Unfollow.
unfollowTx := makeTx(blockchain.EventUnfollow, alice.PubKeyHex(), bob.PubKeyHex(),
0, blockchain.MinFee, mustJSON(blockchain.UnfollowPayload{}))
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{unfollowTx})
mustAddBlock(t, c, b3)
following, _ = c.Following(alice.PubKeyHex())
if len(following) != 0 {
t.Errorf("Following after unfollow: got %v, want []", following)
}
}
// TestFeedLikeUnlike: like toggles + cached count stays consistent.
func TestFeedLikeUnlike(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
alice := newIdentity(t) // author
bob := newIdentity(t) // liker
host := newIdentity(t)
genesis := addGenesis(t, c, val)
const postSize = uint64(100)
expectedPostFee := blockchain.BasePostFee + postSize*blockchain.PostByteFee
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
expectedPostFee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
time.Sleep(2 * time.Millisecond)
fundBob := makeTx(blockchain.EventTransfer, val.PubKeyHex(), bob.PubKeyHex(),
5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundBob})
mustAddBlock(t, c, b1)
h := sha256.Sum256([]byte("likeable"))
postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "",
0, expectedPostFee,
mustJSON(blockchain.CreatePostPayload{
PostID: "p1", ContentHash: h[:], Size: postSize, HostingRelay: host.PubKeyHex(),
}))
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
mustAddBlock(t, c, b2)
likeTx := makeTx(blockchain.EventLikePost, bob.PubKeyHex(), "",
0, blockchain.MinFee, mustJSON(blockchain.LikePostPayload{PostID: "p1"}))
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{likeTx})
mustAddBlock(t, c, b3)
n, _ := c.LikeCount("p1")
if n != 1 {
t.Errorf("LikeCount after like: got %d, want 1", n)
}
liked, _ := c.HasLiked("p1", bob.PubKeyHex())
if !liked {
t.Errorf("HasLiked after like: got false")
}
// Duplicate like — tx is skipped; counter stays at 1.
dupTx := makeTx(blockchain.EventLikePost, bob.PubKeyHex(), "",
0, blockchain.MinFee, mustJSON(blockchain.LikePostPayload{PostID: "p1"}))
b4 := buildBlock(t, b3, val, []*blockchain.Transaction{dupTx})
mustAddBlock(t, c, b4)
if n2, _ := c.LikeCount("p1"); n2 != 1 {
t.Errorf("LikeCount after duplicate: got %d, want 1 (tx should have been skipped)", n2)
}
unlikeTx := makeTx(blockchain.EventUnlikePost, bob.PubKeyHex(), "",
0, blockchain.MinFee, mustJSON(blockchain.UnlikePostPayload{PostID: "p1"}))
b5 := buildBlock(t, b4, val, []*blockchain.Transaction{unlikeTx})
mustAddBlock(t, c, b5)
n, _ = c.LikeCount("p1")
if n != 0 {
t.Errorf("LikeCount after unlike: got %d, want 0", n)
}
}
// TestFeedDeletePostByOther: only the author may delete their post.
func TestFeedDeletePostByOther(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
alice := newIdentity(t)
mallory := newIdentity(t) // tries to delete alice's post
host := newIdentity(t)
genesis := addGenesis(t, c, val)
const postSize = uint64(100)
fee := blockchain.BasePostFee + postSize*blockchain.PostByteFee
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
fee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
time.Sleep(2 * time.Millisecond)
fundMallory := makeTx(blockchain.EventTransfer, val.PubKeyHex(), mallory.PubKeyHex(),
5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundMallory})
mustAddBlock(t, c, b1)
h := sha256.Sum256([]byte("body"))
postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "", 0, fee,
mustJSON(blockchain.CreatePostPayload{
PostID: "p1", ContentHash: h[:], Size: postSize, HostingRelay: host.PubKeyHex(),
}))
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
mustAddBlock(t, c, b2)
// Mallory tries to delete alice's post — block commits, tx is skipped.
delTx := makeTx(blockchain.EventDeletePost, mallory.PubKeyHex(), "", 0, blockchain.MinFee,
mustJSON(blockchain.DeletePostPayload{PostID: "p1"}))
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{delTx})
mustAddBlock(t, c, b3)
rec, _ := c.Post("p1")
if rec == nil || rec.Deleted {
t.Fatalf("post was deleted by non-author: %+v", rec)
}
}
// silence unused-import lint if fmt ever gets trimmed from the feed tests.
var _ = fmt.Sprintf

View File

@@ -11,8 +11,6 @@ type EventType string
const ( const (
EventRegisterKey EventType = "REGISTER_KEY" EventRegisterKey EventType = "REGISTER_KEY"
EventCreateChannel EventType = "CREATE_CHANNEL"
EventAddMember EventType = "ADD_MEMBER"
EventOpenPayChan EventType = "OPEN_PAY_CHAN" EventOpenPayChan EventType = "OPEN_PAY_CHAN"
EventClosePayChan EventType = "CLOSE_PAY_CHAN" EventClosePayChan EventType = "CLOSE_PAY_CHAN"
EventTransfer EventType = "TRANSFER" EventTransfer EventType = "TRANSFER"
@@ -37,6 +35,17 @@ const (
EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token
EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership
EventBurnNFT EventType = "BURN_NFT" // burn (destroy) an NFT EventBurnNFT EventType = "BURN_NFT" // burn (destroy) an NFT
// ── Social feed (v2.0.0) ──────────────────────────────────────────────
// Replaces the old channel model with a VK/Twitter-style timeline.
// Posts are plaintext, publicly readable, size-priced. Bodies live in
// the relay feed-mailbox; on-chain we only keep metadata + author.
EventCreatePost EventType = "CREATE_POST" // author publishes a post
EventDeletePost EventType = "DELETE_POST" // author soft-deletes their post
EventFollow EventType = "FOLLOW" // follow another author's feed
EventUnfollow EventType = "UNFOLLOW" // unfollow an author
EventLikePost EventType = "LIKE_POST" // like a post
EventUnlikePost EventType = "UNLIKE_POST" // remove a previous like
) )
// Token amounts are stored in micro-tokens (µT). // Token amounts are stored in micro-tokens (µT).
@@ -64,6 +73,31 @@ const (
// MinContactFee is the minimum amount a sender must pay the recipient when // MinContactFee is the minimum amount a sender must pay the recipient when
// submitting an EventContactRequest (anti-spam; goes directly to recipient). // submitting an EventContactRequest (anti-spam; goes directly to recipient).
MinContactFee uint64 = 5_000 // 0.005 T MinContactFee uint64 = 5_000 // 0.005 T
// ── Feed pricing (v2.0.0) ─────────────────────────────────────────────
// A post's on-chain fee is BasePostFee + bytes_on_disk × PostByteFee.
// The fee is paid by the author and credited in full to the hosting
// relay (the node that received POST /feed/publish and stored the body).
// Size-based pricing is what aligns incentives: a 200-byte tweet is
// cheap, a 256 KB video costs ~0.26 T — node operators' storage cost
// is covered.
//
// Note: BasePostFee is set to MinFee (1000 µT) because chain-level block
// validation requires every tx's Fee ≥ MinFee. So the true minimum a
// post can cost is MinFee + size × PostByteFee. A 0-byte post is
// rejected (Size must be > 0), so in practice a ~50-byte text post
// costs ~1050 µT (~$0.001 depending on token price).
BasePostFee uint64 = 1_000 // 0.001 T flat per post — aligned with MinFee floor
PostByteFee uint64 = 1 // 1 µT per byte of stored content
// MaxPostSize caps a single post's on-wire size (text + attachment, post
// compression). Hard limit — node refuses larger envelopes to protect
// storage and bandwidth.
MaxPostSize uint64 = 256 * 1024 // 256 KiB
// LikeFee / FollowFee / UnlikeFee / UnfollowFee / DeletePostFee all use
// MinFee (1000 µT) — standard tx fee paid to the validator. No extra
// cost; these events carry no body.
) )
// Transaction is the atomic unit recorded in a block. // Transaction is the atomic unit recorded in a block.
@@ -90,11 +124,66 @@ type RegisterKeyPayload struct {
X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging
} }
// CreateChannelPayload is embedded in EventCreateChannel transactions. // ── Feed payloads (v2.0.0) ─────────────────────────────────────────────────
type CreateChannelPayload struct {
ChannelID string `json:"channel_id"` // CreatePostPayload is embedded in EventCreatePost transactions. The body
Title string `json:"title"` // itself is NOT stored on-chain — it lives in the relay feed-mailbox keyed
IsPublic bool `json:"is_public"` // by PostID. On-chain we only keep author, size, hash, timestamp and any
// reply/quote reference for ordering and proof of authorship.
//
// PostID is computed client-side as hex(sha256(author || content_hash || ts)[:16])
// — same scheme as envelope IDs. Clients include it so the relay can store
// the body under a stable key before the chain commit lands.
//
// HostingRelay is the node pubkey (Ed25519 hex) that accepted the POST
// /feed/publish call and holds the body. Readers resolve it via the chain
// and fetch the body directly from that relay (or via gossipsub replicas).
// The fee is credited to this pub.
//
// QuoteOf / ReplyTo are mutually exclusive; set at most one. ReplyTo makes
// the post a reply in a thread; QuoteOf creates a link/reference block.
type CreatePostPayload struct {
PostID string `json:"post_id"`
ContentHash []byte `json:"content_hash"` // sha256 of body-bytes, 32 B
Size uint64 `json:"size"` // bytes on disk (compressed)
HostingRelay string `json:"hosting_relay"` // hex Ed25519 of storing node
ReplyTo string `json:"reply_to,omitempty"` // parent post ID
QuoteOf string `json:"quote_of,omitempty"` // referenced post ID
}
// DeletePostPayload — author soft-deletes their own post. Stored marker
// lets clients hide the post; relay can GC the body on the next sweep.
type DeletePostPayload struct {
PostID string `json:"post_id"`
}
// FollowPayload / UnfollowPayload — follow graph. tx.From = follower,
// tx.To = target. No body.
type FollowPayload struct{}
type UnfollowPayload struct{}
// LikePostPayload / UnlikePostPayload — per-post like indicator. tx.From
// = liker. The counter is derived on read.
type LikePostPayload struct {
PostID string `json:"post_id"`
}
type UnlikePostPayload struct {
PostID string `json:"post_id"`
}
// PostRecord is what we store on-chain under post:<postID>. Consumers of
// PostsByAuthor / query endpoints decode this.
type PostRecord struct {
PostID string `json:"post_id"`
Author string `json:"author"` // hex Ed25519
ContentHash []byte `json:"content_hash"`
Size uint64 `json:"size"`
HostingRelay string `json:"hosting_relay"`
ReplyTo string `json:"reply_to,omitempty"`
QuoteOf string `json:"quote_of,omitempty"`
CreatedAt int64 `json:"created_at"` // unix seconds (tx timestamp)
Deleted bool `json:"deleted,omitempty"`
FeeUT uint64 `json:"fee_ut"` // total fee paid
} }
// RegisterRelayPayload is embedded in EventRegisterRelay transactions. // RegisterRelayPayload is embedded in EventRegisterRelay transactions.
@@ -241,24 +330,6 @@ type BlockContactPayload struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
} }
// ChannelMember records a participant in a channel together with their
// X25519 public key. The key is cached on-chain (written during ADD_MEMBER)
// so channel senders don't have to fan out a separate /api/identity lookup
// per recipient on every message — they GET /api/channels/:id/members
// once and seal N envelopes in a loop.
type ChannelMember struct {
PubKey string `json:"pub_key"` // Ed25519 hex
X25519PubKey string `json:"x25519_pub_key"` // optional; empty if member hasn't registered
Address string `json:"address"`
}
// AddMemberPayload is embedded in EventAddMember transactions.
// tx.From adds tx.To as a member of the specified channel.
// If tx.To is empty, tx.From is added (self-join for public channels).
type AddMemberPayload struct {
ChannelID string `json:"channel_id"`
}
// AddValidatorPayload is embedded in EventAddValidator transactions. // AddValidatorPayload is embedded in EventAddValidator transactions.
// tx.From must already be a validator; tx.To is the new validator's pub key. // tx.From must already be a validator; tx.To is the new validator's pub key.
// //

42
client-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# ── Client-app local state ─────────────────────────────────────────────
# Dependencies (install via npm ci)
node_modules/
# Expo / Metro caches
.expo/
.expo-shared/
# Build outputs
dist/
web-build/
*.apk
*.aab
*.ipa
# TypeScript incremental build
*.tsbuildinfo
# Env files
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
# Native prebuild output (Expo managed)
/android
/ios

93
client-app/README.md Normal file
View File

@@ -0,0 +1,93 @@
# DChain Messenger — React Native Client
E2E-encrypted mobile/desktop messenger built on the DChain blockchain stack.
**Stack:** React Native · Expo · NativeWind (Tailwind) · TweetNaCl · Zustand
## Quick Start
```bash
cd client-app
npm install
npx expo start # opens Expo Dev Tools
# Press 'i' for iOS simulator, 'a' for Android, 'w' for web
```
## Requirements
- Node.js 18+
- [Expo Go](https://expo.dev/client) on your phone (for Expo tunnel), or iOS/Android emulator
- A running DChain node (see root README for `docker compose up --build -d`)
## Project Structure
```
client-app/
├── app/
│ ├── _layout.tsx # Root layout — loads keys, sets up nav
│ ├── index.tsx # Welcome / onboarding
│ ├── (auth)/
│ │ ├── create.tsx # Generate new Ed25519 + X25519 keys
│ │ ├── created.tsx # Key created — export reminder
│ │ └── import.tsx # Import existing key.json
│ └── (app)/
│ ├── _layout.tsx # Tab bar — Chats · Wallet · Settings
│ ├── chats/
│ │ ├── index.tsx # Chat list with contacts
│ │ └── [id].tsx # Individual chat with E2E encryption
│ ├── requests.tsx # Incoming contact requests
│ ├── new-contact.tsx # Add contact by @username or address
│ ├── wallet.tsx # Balance + TX history + send
│ └── settings.tsx # Node URL, key export, profile
├── components/ui/ # shadcn-style components (Button, Card, Input…)
├── hooks/
│ ├── useMessages.ts # Poll relay inbox, decrypt messages
│ ├── useBalance.ts # Poll token balance
│ └── useContacts.ts # Load contacts + poll contact requests
└── lib/
├── api.ts # REST client for all DChain endpoints
├── crypto.ts # NaCl box encrypt/decrypt, Ed25519 sign
├── storage.ts # SecureStore (keys) + AsyncStorage (data)
├── store.ts # Zustand global state
├── types.ts # TypeScript interfaces
└── utils.ts # cn(), formatAmount(), relativeTime()
```
## Cryptography
| Operation | Algorithm | Library |
|-----------|-----------|---------|
| Transaction signing | Ed25519 | TweetNaCl `sign` |
| Key exchange | X25519 (Curve25519) | TweetNaCl `box` |
| Message encryption | NaCl box (XSalsa20-Poly1305) | TweetNaCl `box` |
| Key storage | Device secure enclave | expo-secure-store |
Messages are encrypted as:
```
Envelope {
sender_pub: <X25519 hex> // sender's public key
recipient_pub: <X25519 hex> // recipient's public key
nonce: <24-byte hex> // random per message
ciphertext: <hex> // NaCl box(plaintext, nonce, sender_priv, recipient_pub)
}
```
## Connect to your node
1. Start the DChain node: `docker compose up --build -d`
2. Open the app → Settings → Node URL → `http://YOUR_IP:8081`
3. If using Expo Go on physical device: your PC and phone must be on the same network, or use `npx expo start --tunnel`
## Key File Format
The `key.json` exported/imported by the app:
```json
{
"pub_key": "26018d40...", // Ed25519 public key (64 hex chars)
"priv_key": "...", // Ed25519 private key (128 hex chars)
"x25519_pub": "...", // X25519 public key (64 hex chars)
"x25519_priv": "..." // X25519 private key (64 hex chars)
}
```
This is the same format as the Go node's `--key` flag.

69
client-app/app.json Normal file
View File

@@ -0,0 +1,69 @@
{
"expo": {
"name": "DChain Messenger",
"slug": "dchain-messenger",
"version": "1.0.0",
"orientation": "portrait",
"userInterfaceStyle": "dark",
"backgroundColor": "#000000",
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.dchain.messenger",
"infoPlist": {
"NSMicrophoneUsageDescription": "Allow DChain to record voice messages and video.",
"NSCameraUsageDescription": "Allow DChain to record video messages and scan QR codes.",
"NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library."
}
},
"android": {
"package": "com.dchain.messenger",
"softwareKeyboardLayoutMode": "pan",
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.CAMERA",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.MODIFY_AUDIO_SETTINGS"
]
},
"web": {
"bundler": "metro",
"output": "static"
},
"plugins": [
"expo-router",
"expo-secure-store",
[
"expo-camera",
{
"cameraPermission": "Allow DChain to record video messages and scan QR codes.",
"microphonePermission": "Allow DChain to record audio with video."
}
],
[
"expo-image-picker",
{
"photosPermission": "Allow DChain to attach photos and videos.",
"cameraPermission": "Allow DChain to take photos."
}
],
[
"expo-audio",
{
"microphonePermission": "Allow DChain to record voice messages."
}
],
"expo-video"
],
"experiments": {
"typedRoutes": false
},
"scheme": "dchain",
"extra": {
"router": {},
"eas": {
"projectId": "28d7743e-6745-460f-8ce5-c971c5c297b6"
}
}
}
}

View File

@@ -0,0 +1,99 @@
/**
* Main app layout — кастомный `<AnimatedSlot>` + `<NavBar>`.
*
* AnimatedSlot — обёртка над Slot'ом, анимирующая переход при смене
* pathname'а. Направление анимации вычисляется по TAB_ORDER: если
* целевой tab "справа" — слайд из правой стороны, "слева" — из левой.
*
* Intra-tab навигация (chats/index → chats/[id]) обслуживается вложенным
* Stack'ом в chats/_layout.tsx — там остаётся нативная slide-from-right
* анимация, чтобы chat detail "выезжал" поверх списка.
*
* Side-effects (balance, contacts, WS auth, dev seed) — монтируются здесь
* один раз; переходы между tab'ами их не перезапускают.
*/
import React, { useEffect } from 'react';
import { View } from 'react-native';
import { router, usePathname } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { useBalance } from '@/hooks/useBalance';
import { useContacts } from '@/hooks/useContacts';
import { useWellKnownContracts } from '@/hooks/useWellKnownContracts';
import { useNotifications } from '@/hooks/useNotifications';
import { useGlobalInbox } from '@/hooks/useGlobalInbox';
import { getWSClient } from '@/lib/ws';
import { NavBar } from '@/components/NavBar';
import { AnimatedSlot } from '@/components/AnimatedSlot';
import { saveContact } from '@/lib/storage';
export default function AppLayout() {
const keyFile = useStore(s => s.keyFile);
const requests = useStore(s => s.requests);
const insets = useSafeAreaInsets();
const pathname = usePathname();
// NavBar прячется на full-screen экранах:
// - chat detail
// - compose (new post modal)
// - feed sub-routes (post detail, hashtag search)
// - tx detail
const hideNav =
/^\/chats\/[^/]+/.test(pathname) ||
pathname === '/compose' ||
/^\/feed\/.+/.test(pathname) ||
/^\/tx\/.+/.test(pathname);
useBalance();
useContacts();
useWellKnownContracts();
useNotifications(); // permission + tap-handler
useGlobalInbox(); // global inbox listener → notifications on new peer msg
// Ensure the Saved Messages (self-chat) contact exists as soon as the user
// is signed in, so it shows up in the chat list without any prior action.
const contacts = useStore(s => s.contacts);
const upsertContact = useStore(s => s.upsertContact);
useEffect(() => {
if (!keyFile) return;
if (contacts.some(c => c.address === keyFile.pub_key)) return;
const saved = {
address: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
alias: 'Saved Messages',
addedAt: Date.now(),
};
upsertContact(saved);
saveContact(saved).catch(() => { /* best-effort — re-added next boot anyway */ });
}, [keyFile, contacts, upsertContact]);
useEffect(() => {
const ws = getWSClient();
if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });
else ws.setAuthCreds(null);
}, [keyFile]);
useEffect(() => {
if (keyFile === null) {
const t = setTimeout(() => {
if (!useStore.getState().keyFile) router.replace('/');
}, 300);
return () => clearTimeout(t);
}
}, [keyFile]);
return (
<View style={{ flex: 1, backgroundColor: '#000000' }}>
<View style={{ flex: 1 }}>
<AnimatedSlot />
</View>
{!hideNav && (
<NavBar
bottomInset={insets.bottom}
requestCount={requests.length}
notifCount={0}
/>
)}
</View>
);
}

View File

@@ -0,0 +1,554 @@
/**
* Chat detail screen — верстка по референсу (X-style Messages).
*
* Структура:
* [Header: back + avatar + name + typing-status | ⋯]
* [FlatList: MessageBubble + DaySeparator, group-aware]
* [Composer: floating, supports edit/reply banner]
*
* Весь presentational код вынесен в components/chat/*:
* - MessageBubble (own/peer rendering)
* - DaySeparator (day label между группами)
* - buildRows (чистая функция группировки)
* Date-форматирование — lib/dates.ts.
*/
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import {
View, Text, FlatList, KeyboardAvoidingView, Platform, Alert, Pressable,
} from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Clipboard from 'expo-clipboard';
import { useStore } from '@/lib/store';
import { useMessages } from '@/hooks/useMessages';
import { encryptMessage } from '@/lib/crypto';
import { sendEnvelope } from '@/lib/api';
import { getWSClient } from '@/lib/ws';
import { appendMessage, loadMessages } from '@/lib/storage';
import { randomId, safeBack } from '@/lib/utils';
import type { Message } from '@/lib/types';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { Composer, ComposerMode } from '@/components/Composer';
import { AttachmentMenu } from '@/components/chat/AttachmentMenu';
import { VideoCircleRecorder } from '@/components/chat/VideoCircleRecorder';
import { clearContactNotifications } from '@/hooks/useNotifications';
import { MessageBubble } from '@/components/chat/MessageBubble';
import { DaySeparator } from '@/components/chat/DaySeparator';
import { buildRows, Row } from '@/components/chat/rows';
import type { Attachment } from '@/lib/types';
function shortAddr(a: string, n = 6) {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
export default function ChatScreen() {
const { id: contactAddress } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const contacts = useStore(s => s.contacts);
const messages = useStore(s => s.messages);
const setMsgs = useStore(s => s.setMessages);
const appendMsg = useStore(s => s.appendMessage);
const clearUnread = useStore(s => s.clearUnread);
// При открытии чата: сбрасываем unread-счётчик и dismiss'им банер.
useEffect(() => {
if (!contactAddress) return;
clearUnread(contactAddress);
clearContactNotifications(contactAddress);
}, [contactAddress, clearUnread]);
const upsertContact = useStore(s => s.upsertContact);
const isSavedMessages = !!keyFile && contactAddress === keyFile.pub_key;
// Auto-materialise the Saved Messages contact the first time the user
// opens chat-with-self. The contact is stored locally only — no on-chain
// CONTACT_REQUEST needed, since both ends are the same key pair.
useEffect(() => {
if (!isSavedMessages || !keyFile) return;
const existing = contacts.find(c => c.address === keyFile.pub_key);
if (existing) return;
upsertContact({
address: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
alias: 'Saved Messages',
addedAt: Date.now(),
});
}, [isSavedMessages, keyFile, contacts, upsertContact]);
const contact = contacts.find(c => c.address === contactAddress);
const chatMsgs = messages[contactAddress ?? ''] ?? [];
const listRef = useRef<FlatList>(null);
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [peerTyping, setPeerTyping] = useState(false);
const [composeMode, setComposeMode] = useState<ComposerMode>({ kind: 'new' });
const [pendingAttach, setPendingAttach] = useState<Attachment | null>(null);
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
const [videoCircleOpen, setVideoCircleOpen] = useState(false);
/**
* ID сообщения, которое сейчас подсвечено (после jump-to-reply). На
* ~2 секунды backgroundColor bubble'а мерцает accent-цветом.
* `null` — ничего не подсвечено.
*/
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const highlightClearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── Selection mode ───────────────────────────────────────────────────
// Активируется первым long-press'ом на bubble'е. Header меняется на
// toolbar с Forward/Delete/Cancel. Tap по bubble'у в selection mode
// toggle'ит принадлежность к выборке. Cancel сбрасывает всё.
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0;
useMessages(contact?.x25519Pub ?? '');
// ── Typing indicator от peer'а ─────────────────────────────────────────
useEffect(() => {
if (!keyFile?.x25519_pub) return;
const ws = getWSClient();
let timer: ReturnType<typeof setTimeout> | null = null;
const off = ws.subscribe('typing:' + keyFile.x25519_pub, (frame) => {
if (frame.event !== 'typing') return;
const d = frame.data as { from?: string } | undefined;
if (!contact?.x25519Pub || d?.from !== contact.x25519Pub) return;
setPeerTyping(true);
if (timer) clearTimeout(timer);
timer = setTimeout(() => setPeerTyping(false), 3_000);
});
return () => { off(); if (timer) clearTimeout(timer); };
}, [keyFile?.x25519_pub, contact?.x25519Pub]);
// Throttled типinginisi-ping собеседнику.
const lastTypingSent = useRef(0);
const onChange = useCallback((t: string) => {
setText(t);
if (!contact?.x25519Pub || !t.trim()) return;
const now = Date.now();
if (now - lastTypingSent.current < 2_000) return;
lastTypingSent.current = now;
getWSClient().sendTyping(contact.x25519Pub);
}, [contact?.x25519Pub]);
// Восстановить сообщения из persistent-storage при первом заходе в чат.
//
// Важно: НЕ перезаписываем store пустым массивом — это стёрло бы
// содержимое, которое уже лежит в zustand (только что полученные по
// WS сообщения пока монтировались). Если в кэше что-то есть — мержим:
// берём max(cached, in-store) по id.
useEffect(() => {
if (!contactAddress) return;
loadMessages(contactAddress).then(cached => {
if (!cached || cached.length === 0) return; // кэш пуст → оставляем store
const existing = useStore.getState().messages[contactAddress] ?? [];
const byId = new Map<string, Message>();
for (const m of cached as Message[]) byId.set(m.id, m);
for (const m of existing) byId.set(m.id, m); // store-версия свежее
const merged = Array.from(byId.values()).sort((a, b) => a.timestamp - b.timestamp);
setMsgs(contactAddress, merged);
});
}, [contactAddress, setMsgs]);
const name = isSavedMessages
? 'Saved Messages'
: contact?.username
? `@${contact.username}`
: contact?.alias ?? shortAddr(contactAddress ?? '');
// ── Compose actions ────────────────────────────────────────────────────
const cancelCompose = useCallback(() => {
setComposeMode({ kind: 'new' });
setText('');
setPendingAttach(null);
}, []);
// buildRows выдаёт chronological [old → new]. FlatList работает
// inverted, поэтому reverse'им: newest = data[0] = снизу экрана.
// Определено тут (не позже) чтобы handlers типа onJumpToReply могли
// искать индексы по id без forward-declaration.
const rows = useMemo(() => {
const chrono = buildRows(chatMsgs);
return [...chrono].reverse();
}, [chatMsgs]);
/**
* Core send logic. Принимает явные text + attachment чтобы избегать
* race'а со state updates при моментальной отправке голоса/видео.
* Если передано null/undefined — берём из текущего state.
*/
const sendCore = useCallback(async (
textArg: string | null = null,
attachArg: Attachment | null | undefined = undefined,
) => {
if (!keyFile || !contact) return;
const actualText = textArg !== null ? textArg : text;
const actualAttach = attachArg !== undefined ? attachArg : pendingAttach;
const hasText = !!actualText.trim();
const hasAttach = !!actualAttach;
if (!hasText && !hasAttach) return;
if (!isSavedMessages && !contact.x25519Pub) {
Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.');
return;
}
if (composeMode.kind === 'edit') {
const target = chatMsgs.find(m => m.text === composeMode.text && m.mine);
if (!target) { cancelCompose(); return; }
const updated: Message = { ...target, text: actualText.trim(), edited: true };
setMsgs(contact.address, chatMsgs.map(m => m.id === target.id ? updated : m));
cancelCompose();
return;
}
setSending(true);
try {
// Saved Messages short-circuits the relay entirely — the message never
// leaves the device, so no encryption/fee/network round-trip is needed.
// Regular chats still go through the NaCl + relay pipeline below.
if (hasText && !isSavedMessages) {
const { nonce, ciphertext } = encryptMessage(
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
);
await sendEnvelope({
senderPub: keyFile.x25519_pub,
recipientPub: contact.x25519Pub,
senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext,
});
}
const msg: Message = {
id: randomId(),
from: keyFile.x25519_pub,
text: actualText.trim(),
timestamp: Math.floor(Date.now() / 1000),
mine: true,
read: false,
edited: false,
attachment: actualAttach ?? undefined,
replyTo: composeMode.kind === 'reply'
? { id: composeMode.msgId, text: composeMode.preview, author: composeMode.author }
: undefined,
};
appendMsg(contact.address, msg);
await appendMessage(contact.address, msg);
setText('');
setPendingAttach(null);
setComposeMode({ kind: 'new' });
} catch (e: any) {
Alert.alert('Send failed', e?.message ?? 'Unknown error');
} finally {
setSending(false);
}
}, [
text, keyFile, contact, composeMode, chatMsgs, isSavedMessages,
setMsgs, cancelCompose, appendMsg, pendingAttach,
]);
// UI send button
const send = useCallback(() => sendCore(), [sendCore]);
// ── Selection handlers ───────────────────────────────────────────────
// Long-press — входим в selection mode и сразу отмечаем это сообщение.
const onMessageLongPress = useCallback((m: Message) => {
setSelectedIds(prev => {
const next = new Set(prev);
next.add(m.id);
return next;
});
}, []);
// Tap в selection mode — toggle принадлежности.
const onMessageTap = useCallback((m: Message) => {
if (!selectionMode) return;
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(m.id)) next.delete(m.id); else next.add(m.id);
return next;
});
}, [selectionMode]);
const cancelSelection = useCallback(() => setSelectedIds(new Set()), []);
// ── Swipe-to-reply ──────────────────────────────────────────────────
const onMessageReply = useCallback((m: Message) => {
if (selectionMode) return;
setComposeMode({
kind: 'reply',
msgId: m.id,
author: m.mine ? 'You' : name,
preview: m.text || (m.attachment ? `(${m.attachment.kind})` : ''),
});
}, [name, selectionMode]);
// ── Profile navigation (tap на аватарке / имени peer'а) ──────────────
const onOpenPeerProfile = useCallback(() => {
if (!contactAddress) return;
router.push(`/(app)/profile/${contactAddress}` as never);
}, [contactAddress]);
// ── Jump to reply: tap по quoted-блоку в bubble'е ────────────────────
// Скроллим FlatList к оригинальному сообщению и зажигаем highlight
// на ~2 секунды (highlightedId state + useEffect-driven анимация в
// MessageBubble.highlightAnim).
const onJumpToReply = useCallback((originalId: string) => {
const idx = rows.findIndex(r => r.kind === 'msg' && r.msg.id === originalId);
if (idx < 0) {
// Сообщение не найдено (возможно удалено или ушло за пагинацию).
// Silently no-op.
return;
}
try {
listRef.current?.scrollToIndex({
index: idx,
animated: true,
viewPosition: 0.3, // оригинал — чуть выше середины экрана, не прямо в центре
});
} catch {
// scrollToIndex может throw'нуть если индекс за пределами рендера;
// fallback: scrollToOffset на приблизительную позицию.
}
setHighlightedId(originalId);
if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current);
highlightClearTimer.current = setTimeout(() => {
setHighlightedId(null);
highlightClearTimer.current = null;
}, 2000);
}, [rows]);
useEffect(() => {
return () => {
if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current);
};
}, []);
// ── Selection actions ────────────────────────────────────────────────
const deleteSelected = useCallback(() => {
if (selectedIds.size === 0 || !contact) return;
Alert.alert(
`Delete ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}?`,
'This removes them from your device. Other participants keep their copies.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
setMsgs(contact.address, chatMsgs.filter(m => !selectedIds.has(m.id)));
setSelectedIds(new Set());
},
},
],
);
}, [selectedIds, contact, chatMsgs, setMsgs]);
const forwardSelected = useCallback(() => {
// Forward UI ещё не реализован — показываем stub. Пример потока:
// 1. открыть "Forward to…" screen со списком контактов
// 2. для каждого выбранного контакта — sendEnvelope с оригинальным
// текстом, timestamp=now
Alert.alert(
`Forward ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}`,
'Contact-picker screen is coming in the next iteration. For now, copy the text and paste.',
[{ text: 'OK' }],
);
}, [selectedIds]);
// Copy доступен только когда выделено ровно одно сообщение.
const copySelected = useCallback(async () => {
if (selectedIds.size !== 1) return;
const id = [...selectedIds][0];
const msg = chatMsgs.find(m => m.id === id);
if (!msg) return;
await Clipboard.setStringAsync(msg.text);
setSelectedIds(new Set());
}, [selectedIds, chatMsgs]);
// В group-чатах над peer-сообщениями рисуется имя отправителя и его
// аватар (group = несколько участников). В DM (direct) и каналах
// отправитель ровно один, поэтому имя/аватар не нужны — убираем.
const withSenderMeta = contact?.kind === 'group';
const renderRow = ({ item }: { item: Row }) => {
if (item.kind === 'sep') return <DaySeparator label={item.label} />;
return (
<MessageBubble
msg={item.msg}
peerName={name}
peerAddress={contactAddress}
withSenderMeta={withSenderMeta}
showName={item.showName}
showAvatar={item.showAvatar}
onReply={onMessageReply}
onLongPress={onMessageLongPress}
onTap={onMessageTap}
onOpenProfile={onOpenPeerProfile}
onJumpToReply={onJumpToReply}
selectionMode={selectionMode}
selected={selectedIds.has(item.msg.id)}
highlighted={highlightedId === item.msg.id}
/>
);
};
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: '#000000' }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
// Увеличенный offset: composer поднимается выше клавиатуры с заметным
// зазором (20px на iOS, 10px на Android) — пользователь не видит
// прилипания к верхнему краю клавиатуры.
keyboardVerticalOffset={Platform.OS === 'ios' ? 20 : 10}
>
{/* Header — использует общий компонент <Header>, чтобы соблюдать
правила шапки приложения (left slot / centered title / right slot). */}
<View style={{ paddingTop: insets.top, backgroundColor: '#000000' }}>
{selectionMode ? (
<Header
divider
left={<IconButton icon="close" size={36} onPress={cancelSelection} />}
title={`${selectedIds.size} selected`}
right={
<>
{selectedIds.size === 1 && (
<IconButton icon="copy-outline" size={36} onPress={copySelected} />
)}
<IconButton icon="arrow-redo-outline" size={36} onPress={forwardSelected} />
<IconButton icon="trash-outline" size={36} onPress={deleteSelected} />
</>
}
/>
) : (
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={
<Pressable
onPress={onOpenPeerProfile}
hitSlop={4}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
>
<Avatar name={name} address={contactAddress} size={28} saved={isSavedMessages} />
<View style={{ minWidth: 0, flexShrink: 1 }}>
<Text
numberOfLines={1}
style={{
color: '#ffffff',
fontSize: 15,
fontWeight: '700',
letterSpacing: -0.2,
}}
>
{name}
</Text>
{peerTyping && (
<Text style={{ color: '#1d9bf0', fontSize: 11, fontWeight: '500' }}>
typing
</Text>
)}
{!peerTyping && !isSavedMessages && !contact?.x25519Pub && (
<Text style={{ color: '#f0b35a', fontSize: 11 }}>
waiting for key
</Text>
)}
</View>
</Pressable>
}
right={<IconButton icon="ellipsis-horizontal" size={36} />}
/>
)}
</View>
{/* Messages — inverted: data[0] рендерится снизу, последующее —
выше. Это стандартный chat-паттерн: FlatList сразу монтируется
с "scroll position at bottom" без ручного scrollToEnd, и новые
сообщения (добавляемые в начало reversed-массива) появляются
внизу естественно. Никаких jerk'ов при открытии. */}
{rows.length === 0 ? (
// Empty state is rendered as a plain View instead of
// ListEmptyComponent on an inverted FlatList — the previous
// `transform: [{ scaleY: -1 }]` un-flip trick was rendering
// text mirrored on some Android builds (RTL-aware layout),
// giving us the "say hi…" backwards bug.
<View style={{
flex: 1, alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, gap: 10,
}}>
<Avatar
name={name}
address={contactAddress}
size={72}
saved={isSavedMessages}
/>
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
{isSavedMessages ? 'Notes to self' : `Say hi to ${name}`}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
{isSavedMessages
? 'Anything you send here stays on your device — use it as a scratchpad for links, drafts, or files.'
: 'Your messages are end-to-end encrypted.'}
</Text>
</View>
) : (
<FlatList
ref={listRef}
data={rows}
inverted
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
renderItem={renderRow}
contentContainerStyle={{ paddingVertical: 10 }}
showsVerticalScrollIndicator={false}
// Lazy render: only mount ~1.5 screens of bubbles initially,
// render further batches as the user scrolls older. Keeps
// initial paint fast on chats with thousands of messages.
initialNumToRender={25}
maxToRenderPerBatch={12}
windowSize={10}
removeClippedSubviews
/>
)}
{/* Composer — floating, прибит к низу. */}
<View style={{ paddingBottom: Math.max(insets.bottom, 4) + 6, backgroundColor: '#000000' }}>
<Composer
mode={composeMode}
onCancelMode={cancelCompose}
text={text}
onChangeText={onChange}
onSend={send}
sending={sending}
onAttach={() => setAttachMenuOpen(true)}
attachment={pendingAttach}
onClearAttach={() => setPendingAttach(null)}
onFinishVoice={(att) => {
// Voice отправляется сразу — sendCore получает attachment
// явным аргументом, минуя state-задержку.
sendCore('', att);
}}
onStartVideoCircle={() => setVideoCircleOpen(true)}
/>
</View>
<AttachmentMenu
visible={attachMenuOpen}
onClose={() => setAttachMenuOpen(false)}
onPick={(att) => setPendingAttach(att)}
/>
<VideoCircleRecorder
visible={videoCircleOpen}
onClose={() => setVideoCircleOpen(false)}
onFinish={(att) => {
// Video-circle тоже отправляется сразу.
sendCore('', att);
}}
/>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,28 @@
/**
* chats/_layout — вложенный Stack для chats/index и chats/[id].
*
* animation: 'none' — переходы между index и [id] анимирует родительский
* AnimatedSlot (140ms, Easing.out cubic), обеспечивая единую скорость и
* кривую между:
* - chat open/close (index ↔ [id])
* - tab switches (chats ↔ wallet и т.д.)
* - sub-route open/close (settings, profile)
*
* gestureEnabled: true оставлен на случай если пользователь использует
* нативный iOS edge-swipe — он вызовет router.back(), анимация пройдёт
* через AnimatedSlot.
*/
import { Stack } from 'expo-router';
export default function ChatsLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: 'none',
contentStyle: { backgroundColor: '#000000' },
gestureEnabled: true,
}}
/>
);
}

View File

@@ -0,0 +1,113 @@
/**
* Messages screen — список чатов в стиле референса.
*
* ┌ safe-area top
* │ TabHeader (title зависит от connection state)
* │ ─ FlatList (chat tiles) ─
* └ NavBar (external)
*
* Фильтры и search убраны — лист один поток; requests доступны через
* NavBar → notifications tab. FAB composer'а тоже убран (чат-лист
* просто отражает существующие беседы, создание новых — через tab
* "New chat" в NavBar'е).
*/
import React, { useMemo } from 'react';
import { View, Text, FlatList } from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
import type { Contact, Message } from '@/lib/types';
import { TabHeader } from '@/components/TabHeader';
import { ChatTile } from '@/components/ChatTile';
export default function ChatsScreen() {
const insets = useSafeAreaInsets();
const contacts = useStore(s => s.contacts);
const messages = useStore(s => s.messages);
const keyFile = useStore(s => s.keyFile);
// Статус подключения: online / connecting / offline.
// Название шапки и цвет pip'а на аватаре зависят от него.
const connStatus = useConnectionStatus();
const headerTitle =
connStatus === 'online' ? 'Messages' :
connStatus === 'connecting' ? 'Connecting…' :
'Waiting for internet';
const dotColor =
connStatus === 'online' ? '#3ba55d' : // green
connStatus === 'connecting' ? '#f0b35a' : // amber
'#f4212e'; // red
const lastOf = (c: Contact): Message | null => {
const msgs = messages[c.address];
return msgs && msgs.length ? msgs[msgs.length - 1] : null;
};
// Сортировка по последней активности. Saved Messages (self-chat) всегда
// закреплён сверху — это "Избранное", бессмысленно конкурировать с ним
// по recency'и обычным чатам.
const selfAddr = keyFile?.pub_key;
const sorted = useMemo(() => {
const saved = selfAddr ? contacts.find(c => c.address === selfAddr) : undefined;
const rest = contacts
.filter(c => c.address !== selfAddr)
.map(c => ({ c, last: lastOf(c) }))
.sort((a, b) => {
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
const kb = b.last ? b.last.timestamp : b.c.addedAt / 1000;
return kb - ka;
})
.map(x => x.c);
return saved ? [saved, ...rest] : rest;
}, [contacts, messages, selfAddr]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title={headerTitle} profileDotColor={dotColor} />
<View style={{ flex: 1 }}>
<FlatList
data={sorted}
keyExtractor={c => c.address}
renderItem={({ item }) => (
<ChatTile
contact={item}
lastMessage={lastOf(item)}
saved={item.address === selfAddr}
onPress={() => router.push(`/(app)/chats/${item.address}` as never)}
/>
)}
contentContainerStyle={{ paddingBottom: 40, flexGrow: 1 }}
showsVerticalScrollIndicator={false}
/>
{sorted.length === 0 && (
<View
pointerEvents="box-none"
style={{
position: 'absolute',
left: 0, right: 0, top: 0, bottom: 0,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
}}
>
<Ionicons name="chatbubbles-outline" size={42} color="#3a3a3a" style={{ marginBottom: 10 }} />
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginBottom: 6 }}>
No chats yet
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
Use the search tab in the navbar to add your first contact.
</Text>
</View>
)}
</View>
</View>
);
}

View File

@@ -0,0 +1,402 @@
/**
* Post composer — full-screen modal for writing a new post.
*
* Twitter-style layout:
* Header: [✕] (draft-ish) [Опубликовать button]
* Body: [avatar] [multiline TextInput autogrow]
* [hashtags preview chips]
* [attachment preview + remove button]
* Footer: [📷 attach] ··· [<count / 4000>] [~fee estimate]
*
* The flow:
* 1. User types content; hashtags auto-parse for preview
* 2. (Optional) pick image — client-side compression (expo-image-manipulator)
* → resize to 1080px max, JPEG quality 50
* 3. Tap "Опубликовать" → confirmation modal with fee
* 4. Confirm → publishAndCommit() → navigate to post detail
*
* Failure modes:
* - Size overflow (>256 KiB): blocked client-side with hint to compress
* further or drop attachment
* - Insufficient balance: show humanised error from submitTx
* - Network down: toast "нет связи, попробуйте снова"
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
View, Text, TextInput, Pressable, Alert, Image, KeyboardAvoidingView,
Platform, ActivityIndicator, ScrollView, Linking,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as ImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator';
import * as FileSystem from 'expo-file-system/legacy';
import { useStore } from '@/lib/store';
import { Avatar } from '@/components/Avatar';
import { publishAndCommit, formatFee } from '@/lib/feed';
import { humanizeTxError, getBalance } from '@/lib/api';
import { safeBack } from '@/lib/utils';
const MAX_CONTENT_LENGTH = 4000;
const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize
const IMAGE_MAX_DIM = 1080;
// Match the server scrubber's JPEG quality (media/scrub.go:ImageJPEGQuality
// = 75). If the client re-encodes at a LOWER quality the server re-encode
// at 75 inflates the bytes, often 2-3× — so a 60 KiB upload silently blows
// past MaxPostSize = 256 KiB mid-flight and `/feed/publish` rejects with
// "post body exceeds maximum allowed size". Using the same Q for both
// passes keeps the final footprint ~the same as what the user sees in
// the composer.
const IMAGE_QUALITY = 0.75;
// Safety margin on the pre-upload check: the server pass is near-idempotent
// at matching Q but not exactly — reserve ~8 KiB for JPEG header / metadata
// scaffolding differences so we don't flirt with the hard cap.
const IMAGE_BUDGET_BYTES = MAX_POST_BYTES - 8 * 1024;
interface Attachment {
uri: string;
mime: string;
size: number;
bytes: Uint8Array;
width?: number;
height?: number;
}
export default function ComposeScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const username = useStore(s => s.username);
const [content, setContent] = useState('');
const [attach, setAttach] = useState<Attachment | null>(null);
const [busy, setBusy] = useState(false);
const [picking, setPicking] = useState(false);
const [balance, setBalance] = useState<number | null>(null);
// Fetch balance once so we can warn before publishing.
useEffect(() => {
if (!keyFile) return;
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
}, [keyFile]);
// Estimated fee mirrors server's formula exactly. Displayed to the user
// so they aren't surprised by a debit.
const estimatedFee = useMemo(() => {
const size = (new TextEncoder().encode(content)).length + (attach?.size ?? 0) + 128;
return 1000 + size; // base 1000 + 1 µT/byte (matches blockchain constants)
}, [content, attach]);
const totalBytes = useMemo(() => {
return (new TextEncoder().encode(content)).length + (attach?.size ?? 0) + 128;
}, [content, attach]);
const hashtags = useMemo(() => {
const matches = content.match(/#[A-Za-z0-9_\u0400-\u04FF]{1,40}/g) || [];
const seen = new Set<string>();
return matches
.map(m => m.slice(1).toLowerCase())
.filter(t => !seen.has(t) && seen.add(t));
}, [content]);
const canPublish = !busy && (content.trim().length > 0 || attach !== null)
&& totalBytes <= MAX_POST_BYTES;
const onPickImage = async () => {
if (picking) return;
setPicking(true);
try {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert(
'Photo access required',
'Please enable photo library access in Settings.',
[
{ text: 'Cancel' },
{ text: 'Settings', onPress: () => Linking.openSettings() },
],
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 1,
exif: false, // privacy: ask picker not to return EXIF
});
if (result.canceled || !result.assets[0]) return;
const asset = result.assets[0];
// Client-side compression: resize + re-encode. This is the FIRST
// scrub pass — server will do another one (mandatory) before storing.
const manipulated = await ImageManipulator.manipulateAsync(
asset.uri,
[{ resize: { width: IMAGE_MAX_DIM } }],
{ compress: IMAGE_QUALITY, format: ImageManipulator.SaveFormat.JPEG },
);
// Read the compressed bytes.
const b64 = await FileSystem.readAsStringAsync(manipulated.uri, {
encoding: FileSystem.EncodingType.Base64,
});
const bytes = base64ToBytes(b64);
if (bytes.length > IMAGE_BUDGET_BYTES) {
Alert.alert(
'Image too large',
`Image is ${Math.round(bytes.length / 1024)} KB but the post limit is ${MAX_POST_BYTES / 1024} KB (after server re-encode). Try a smaller picture.`,
);
return;
}
setAttach({
uri: manipulated.uri,
mime: 'image/jpeg',
size: bytes.length,
bytes,
width: manipulated.width,
height: manipulated.height,
});
} catch (e: any) {
Alert.alert('Failed', String(e?.message ?? e));
} finally {
setPicking(false);
}
};
const onPublish = async () => {
if (!keyFile || !canPublish) return;
// Balance guard.
if (balance !== null && balance < estimatedFee) {
Alert.alert(
'Insufficient balance',
`Need ${formatFee(estimatedFee)}, have ${formatFee(balance)}.`,
);
return;
}
Alert.alert(
'Publish post?',
`Cost: ${formatFee(estimatedFee)}\nSize: ${Math.round(totalBytes / 1024 * 10) / 10} KB`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Publish',
onPress: async () => {
setBusy(true);
try {
const postID = await publishAndCommit({
author: keyFile.pub_key,
privKey: keyFile.priv_key,
content: content.trim(),
attachment: attach?.bytes,
attachmentMIME: attach?.mime,
});
// Close composer and open the new post.
router.replace(`/(app)/feed/${postID}` as never);
} catch (e: any) {
Alert.alert('Failed to publish', humanizeTxError(e));
} finally {
setBusy(false);
}
},
},
],
);
};
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: '#000000' }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
{/* Header */}
<View
style={{
paddingTop: insets.top + 8,
paddingBottom: 12,
paddingHorizontal: 14,
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: '#141414',
}}
>
<Pressable onPress={() => safeBack()} hitSlop={8}>
<Ionicons name="close" size={26} color="#ffffff" />
</Pressable>
<View style={{ flex: 1 }} />
<Pressable
onPress={onPublish}
disabled={!canPublish}
style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 9,
borderRadius: 999,
backgroundColor: canPublish ? (pressed ? '#1a8cd8' : '#1d9bf0') : '#1f1f1f',
})}
>
{busy ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text
style={{
color: canPublish ? '#ffffff' : '#5a5a5a',
fontWeight: '700',
fontSize: 14,
}}
>
Publish
</Text>
)}
</Pressable>
</View>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 80 }}
>
{/* Avatar + TextInput row */}
<View style={{ flexDirection: 'row' }}>
<Avatar name={username ?? '?'} address={keyFile?.pub_key} size={40} />
<TextInput
value={content}
onChangeText={setContent}
placeholder="What's happening?"
placeholderTextColor="#5a5a5a"
multiline
maxLength={MAX_CONTENT_LENGTH}
autoFocus
style={{
flex: 1,
marginLeft: 10,
color: '#ffffff',
fontSize: 17,
lineHeight: 22,
minHeight: 120,
paddingTop: 4,
textAlignVertical: 'top',
}}
/>
</View>
{/* Hashtag preview */}
{hashtags.length > 0 && (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 14, marginLeft: 50 }}>
{hashtags.map(tag => (
<View
key={tag}
style={{
paddingHorizontal: 10, paddingVertical: 4,
borderRadius: 999,
backgroundColor: '#081a2a',
borderWidth: 1, borderColor: '#11385a',
}}
>
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
#{tag}
</Text>
</View>
))}
</View>
)}
{/* Attachment preview */}
{attach && (
<View style={{ marginTop: 14, marginLeft: 50 }}>
<View
style={{
position: 'relative',
borderRadius: 16,
overflow: 'hidden',
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
<Image
source={{ uri: attach.uri }}
style={{
width: '100%',
aspectRatio: attach.width && attach.height
? attach.width / attach.height
: 4 / 3,
backgroundColor: '#0a0a0a',
}}
resizeMode="cover"
/>
<Pressable
onPress={() => setAttach(null)}
hitSlop={8}
style={({ pressed }) => ({
position: 'absolute',
top: 8, right: 8,
width: 28, height: 28, borderRadius: 14,
backgroundColor: pressed ? 'rgba(0,0,0,0.9)' : 'rgba(0,0,0,0.75)',
alignItems: 'center', justifyContent: 'center',
})}
>
<Ionicons name="close" size={16} color="#ffffff" />
</Pressable>
</View>
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6 }}>
{Math.round(attach.size / 1024)} KB · metadata stripped on server
</Text>
</View>
)}
</ScrollView>
{/* Footer: attach / counter / fee */}
<View
style={{
paddingHorizontal: 14,
paddingVertical: 10,
paddingBottom: Math.max(insets.bottom, 10),
borderTopWidth: 1,
borderTopColor: '#141414',
flexDirection: 'row',
alignItems: 'center',
gap: 14,
}}
>
<Pressable
onPress={onPickImage}
disabled={picking || !!attach}
hitSlop={8}
style={({ pressed }) => ({
opacity: pressed || picking || attach ? 0.5 : 1,
})}
>
{picking
? <ActivityIndicator color="#1d9bf0" size="small" />
: <Ionicons name="image-outline" size={22} color="#1d9bf0" />}
</Pressable>
<View style={{ flex: 1 }} />
<Text
style={{
color: totalBytes > MAX_POST_BYTES ? '#f4212e'
: totalBytes > MAX_POST_BYTES * 0.85 ? '#f0b35a'
: '#6a6a6a',
fontSize: 12,
fontWeight: '600',
}}
>
{Math.round(totalBytes / 1024 * 10) / 10} / {MAX_POST_BYTES / 1024} KB
</Text>
<View style={{ width: 1, height: 14, backgroundColor: '#1f1f1f' }} />
<Text style={{ color: '#6a6a6a', fontSize: 12 }}>
{formatFee(estimatedFee)}
</Text>
</View>
</KeyboardAvoidingView>
);
}
// ── Helpers ────────────────────────────────────────────────────────────
function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}

View File

@@ -0,0 +1,207 @@
/**
* Post detail — full view of one post with stats, thread context, and a
* lazy-rendered image attachment.
*
* Why a dedicated screen?
* - PostCard in the timeline intentionally doesn't render attachments
* (would explode initial render time with N images).
* - Per-post stats (views, likes, liked_by_me) want a fresh refresh
* on open; timeline batches but not at the per-second cadence a
* reader expects when they just tapped in.
*
* Layout:
* [← back · Пост]
* [PostCard (full — with attachment)]
* [stats bar: views · likes · fee]
* [— reply affordance below (future)]
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
View, Text, ScrollView, ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { PostCard } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import {
fetchPost, fetchStats, bumpView, formatCount, formatFee,
type FeedPostItem, type PostStats,
} from '@/lib/feed';
import { safeBack } from '@/lib/utils';
export default function PostDetailScreen() {
const insets = useSafeAreaInsets();
const { id: postID } = useLocalSearchParams<{ id: string }>();
const keyFile = useStore(s => s.keyFile);
const [post, setPost] = useState<FeedPostItem | null>(null);
const [stats, setStats] = useState<PostStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!postID) return;
setLoading(true);
setError(null);
try {
const [p, s] = await Promise.all([
fetchPost(postID),
fetchStats(postID, keyFile?.pub_key),
]);
setPost(p);
setStats(s);
if (p) bumpView(postID); // fire-and-forget
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setLoading(false);
}
}, [postID, keyFile]);
useEffect(() => { load(); }, [load]);
const onStatsChanged = useCallback(async () => {
if (!postID) return;
const s = await fetchStats(postID, keyFile?.pub_key);
if (s) setStats(s);
}, [postID, keyFile]);
const onDeleted = useCallback(() => {
// Go back to feed — the post is gone.
safeBack();
}, []);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title="Post"
/>
{loading ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : error ? (
<View style={{ padding: 24 }}>
<Text style={{ color: '#f4212e' }}>{error}</Text>
</View>
) : !post ? (
<View style={{ padding: 24, alignItems: 'center' }}>
<Ionicons name="trash-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#8b8b8b', marginTop: 10 }}>
Post deleted or no longer available
</Text>
</View>
) : (
<ScrollView>
{/* `compact` tells PostCard to drop the 5-line body cap and
render the attachment at its natural aspect ratio instead
of the portrait-cropped timeline preview. */}
<PostCard
compact
post={{ ...post, likes: stats?.likes ?? post.likes, views: stats?.views ?? post.views }}
likedByMe={stats?.liked_by_me ?? false}
onStatsChanged={onStatsChanged}
onDeleted={onDeleted}
/>
{/* Detailed stats block */}
<View
style={{
marginHorizontal: 14,
marginTop: 12,
paddingVertical: 14,
paddingHorizontal: 14,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
<Text style={{
color: '#5a5a5a',
fontSize: 11,
fontWeight: '700',
letterSpacing: 1.2,
textTransform: 'uppercase',
marginBottom: 10,
}}>
Post details
</Text>
<DetailRow label="Views" value={formatCount(stats?.views ?? post.views)} />
<DetailRow label="Likes" value={formatCount(stats?.likes ?? post.likes)} />
<DetailRow label="Size" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} />
<DetailRow
label="Paid to publish"
value={formatFee(1000 + post.size)}
/>
<DetailRow
label="Hosted on"
value={shortAddr(post.hosting_relay)}
mono
/>
{post.hashtags && post.hashtags.length > 0 && (
<>
<View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 10 }} />
<Text style={{ color: '#5a5a5a', fontSize: 11, marginBottom: 6 }}>
Hashtags
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
{post.hashtags.map(tag => (
<Text
key={tag}
onPress={() => router.push(`/(app)/feed/tag/${encodeURIComponent(tag)}` as never)}
style={{
color: '#1d9bf0',
fontSize: 13,
paddingHorizontal: 8,
paddingVertical: 3,
backgroundColor: '#081a2a',
borderRadius: 999,
}}
>
#{tag}
</Text>
))}
</View>
</>
)}
</View>
<View style={{ height: 80 }} />
</ScrollView>
)}
</View>
);
}
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<View style={{ flexDirection: 'row', paddingVertical: 3 }}>
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
<Text
style={{
color: '#ffffff',
fontSize: 13,
fontWeight: '600',
fontFamily: mono ? 'monospace' : undefined,
}}
>
{value}
</Text>
</View>
);
}
function shortAddr(a: string, n = 6): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}

View File

@@ -0,0 +1,23 @@
/**
* Feed sub-routes layout — native Stack for /(app)/feed/[id] and
* /(app)/feed/tag/[tag]. The tab root itself (app/(app)/feed.tsx) lives
* OUTSIDE this folder so it keeps the outer Slot-level navigation.
*
* Why a Stack here? AnimatedSlot in the parent is stack-less; without
* this nested Stack, `router.back()` from a post detail / hashtag feed
* couldn't find its caller.
*/
import React from 'react';
import { Stack } from 'expo-router';
export default function FeedLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#000000' },
animation: 'slide_from_right',
}}
/>
);
}

View File

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

View File

@@ -0,0 +1,424 @@
/**
* Feed tab — Twitter-style timeline with three sources:
*
* Подписки → /feed/timeline?follower=me (posts from people I follow)
* Для вас → /feed/foryou?pub=me (recommendations)
* В тренде → /feed/trending?window=24 (most-engaged in last 24h)
*
* Floating compose button (bottom-right) → /(app)/compose modal.
*
* Uses a single FlatList per tab with pull-to-refresh + optimistic
* local updates. Stats (likes, likedByMe) are fetched once per refresh
* and piggy-backed onto each PostCard via props; the card does the
* optimistic toggle locally until the next refresh reconciles.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
View, Text, FlatList, Pressable, RefreshControl, ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { TabHeader } from '@/components/TabHeader';
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import {
fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView,
type FeedPostItem,
} from '@/lib/feed';
type TabKey = 'following' | 'foryou' | 'trending';
const TAB_LABELS: Record<TabKey, string> = {
following: 'Following',
foryou: 'For you',
trending: 'Trending',
};
export default function FeedScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const [tab, setTab] = useState<TabKey>('foryou'); // default: discovery
const [posts, setPosts] = useState<FeedPostItem[]>([]);
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [exhausted, setExhausted] = useState(false);
const [error, setError] = useState<string | null>(null);
const PAGE_SIZE = 20;
// Guard against rapid tab switches overwriting each other's results.
const requestRef = useRef(0);
const loadPosts = useCallback(async (isRefresh = false) => {
if (!keyFile) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
setError(null);
setExhausted(false);
const seq = ++requestRef.current;
try {
let items: FeedPostItem[] = [];
switch (tab) {
case 'following':
items = await fetchTimeline(keyFile.pub_key, { limit: PAGE_SIZE });
break;
case 'foryou':
items = await fetchForYou(keyFile.pub_key, PAGE_SIZE);
break;
case 'trending':
items = await fetchTrending(24, PAGE_SIZE);
break;
}
if (seq !== requestRef.current) return; // stale response
setPosts(items);
// If the server returned fewer than PAGE_SIZE, we already have
// everything — disable further paginated fetches.
if (items.length < PAGE_SIZE) setExhausted(true);
// Batch-fetch liked_by_me (bounded concurrency — 6 at a time).
const liked = new Set<string>();
const chunks = chunk(items, 6);
for (const group of chunks) {
const results = await Promise.all(
group.map(p => fetchStats(p.post_id, keyFile.pub_key)),
);
results.forEach((s, i) => {
if (s?.liked_by_me) liked.add(group[i].post_id);
});
}
if (seq !== requestRef.current) return;
setLikedSet(liked);
} catch (e: any) {
if (seq !== requestRef.current) return;
const msg = String(e?.message ?? e);
// Network / 404 is benign — node just unreachable or empty. Show
// the empty-state; the catch block above already cleared error
// on benign messages. Production treats this identically.
if (/Network request failed|→\s*404/.test(msg)) {
setPosts([]);
setExhausted(true);
} else {
setError(msg);
}
} finally {
if (seq !== requestRef.current) return;
setLoading(false);
setRefreshing(false);
}
}, [keyFile, tab]);
/**
* loadMore — paginate older posts when the user scrolls to the end
* of the list. Only the "following" and "foryou"/trending-less-useful
* paths actually support server-side pagination via the `before`
* cursor; foryou/trending return their ranked top-N which is by
* design not paginated (users very rarely scroll past 20 hot posts).
*
* We key the next page off the oldest post currently in state. If
* the server returns less than PAGE_SIZE items, we mark the list as
* exhausted to stop further fetches.
*/
const loadMore = useCallback(async () => {
if (!keyFile || loadingMore || exhausted || refreshing || loading) return;
if (posts.length === 0) return;
// foryou / trending are ranked, not ordered — no stable cursor to
// paginate against in v2.0.0. Skip.
if (tab === 'foryou' || tab === 'trending') return;
const oldest = posts[posts.length - 1];
const before = oldest?.created_at;
if (!before) return;
setLoadingMore(true);
const seq = requestRef.current; // don't bump — this is additive
try {
const next = await fetchTimeline(keyFile.pub_key, {
limit: PAGE_SIZE, before,
});
if (seq !== requestRef.current) return;
if (next.length === 0) {
setExhausted(true);
return;
}
// Dedup by post_id (could overlap on the boundary ts).
setPosts(prev => {
const have = new Set(prev.map(p => p.post_id));
const merged = [...prev];
for (const p of next) {
if (!have.has(p.post_id)) merged.push(p);
}
return merged;
});
if (next.length < PAGE_SIZE) setExhausted(true);
} catch {
// Don't escalate to error UI for pagination failures — just stop.
setExhausted(true);
} finally {
setLoadingMore(false);
}
}, [keyFile, loadingMore, exhausted, refreshing, loading, posts, tab]);
useEffect(() => { loadPosts(false); }, [loadPosts]);
const onStatsChanged = useCallback(async (postID: string) => {
if (!keyFile) return;
const stats = await fetchStats(postID, keyFile.pub_key);
if (!stats) return;
setPosts(ps => ps.map(p => p.post_id === postID
? { ...p, likes: stats.likes, views: stats.views }
: p));
setLikedSet(s => {
const next = new Set(s);
if (stats.liked_by_me) next.add(postID);
else next.delete(postID);
return next;
});
}, [keyFile]);
const onDeleted = useCallback((postID: string) => {
setPosts(ps => ps.filter(p => p.post_id !== postID));
}, []);
// View counter: fire bumpView once when a card scrolls into view.
const viewedRef = useRef<Set<string>>(new Set());
const onViewableItemsChanged = useRef(({ viewableItems }: { viewableItems: Array<{ item: FeedPostItem; isViewable: boolean }> }) => {
for (const { item, isViewable } of viewableItems) {
if (isViewable && !viewedRef.current.has(item.post_id)) {
viewedRef.current.add(item.post_id);
bumpView(item.post_id);
}
}
}).current;
const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 60, minimumViewTime: 1000 }).current;
const emptyHint = useMemo(() => {
switch (tab) {
case 'following': return 'Follow someone to see their posts here.';
case 'foryou': return 'No recommendations yet — come back later.';
case 'trending': return 'Nothing trending yet.';
}
}, [tab]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Feed" />
{/* Tab strip — три таба, равномерно распределены по ширине
(justifyContent: space-between). Каждый Pressable hug'ает
свой контент — табы НЕ тянутся на 1/3 ширины, а жмутся к
своему лейблу, что даёт воздух между ними. Индикатор активной
вкладки — тонкая полоска под лейблом. */}
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 20,
borderBottomWidth: 1,
borderBottomColor: '#1f1f1f',
}}
>
{(Object.keys(TAB_LABELS) as TabKey[]).map(key => (
<Pressable
key={key}
onPress={() => setTab(key)}
style={({ pressed }) => ({
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 6,
opacity: pressed ? 0.6 : 1,
})}
>
<Text
style={{
color: tab === key ? '#ffffff' : '#6a6a6a',
fontWeight: tab === key ? '700' : '500',
fontSize: 15,
letterSpacing: -0.1,
}}
>
{TAB_LABELS[key]}
</Text>
<View
style={{
marginTop: 10,
width: tab === key ? 28 : 0,
height: 3,
borderRadius: 1.5,
backgroundColor: '#1d9bf0',
}}
/>
</Pressable>
))}
</View>
{/* Feed list */}
<FlatList
data={posts}
keyExtractor={p => p.post_id}
renderItem={({ item }) => (
<PostCard
post={item}
likedByMe={likedSet.has(item.post_id)}
onStatsChanged={onStatsChanged}
onDeleted={onDeleted}
/>
)}
ItemSeparatorComponent={PostSeparator}
onEndReached={loadMore}
onEndReachedThreshold={0.6}
ListFooterComponent={
loadingMore ? (
<View style={{ paddingVertical: 20, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" size="small" />
</View>
) : null
}
// Lazy-render tuning: start with one viewport's worth of posts,
// keep a small window around the visible area. Works together
// with onEndReached pagination for smooth long-feed scroll.
initialNumToRender={10}
maxToRenderPerBatch={8}
windowSize={7}
removeClippedSubviews
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => loadPosts(true)}
tintColor="#1d9bf0"
/>
}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
ListEmptyComponent={
loading ? (
<View style={{ paddingTop: 80, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : error ? (
<EmptyState
icon="alert-circle-outline"
title="Couldn't load feed"
subtitle={error}
onRetry={() => loadPosts(false)}
/>
) : (
<EmptyState
icon="newspaper-outline"
title="Nothing to show yet"
subtitle={emptyHint}
/>
)
}
contentContainerStyle={
posts.length === 0
? { flexGrow: 1 }
: { paddingTop: 8, paddingBottom: 24 }
}
/>
{/* Floating compose button.
*
* Pressable's dynamic-function style sometimes drops absolute
* positioning on re-render on some RN versions — we've seen the
* button slide to the left edge after the first render. Wrap it
* in a plain absolute-positioned View so positioning lives on a
* stable element; the Pressable inside only declares its size
* and visuals. The parent Feed screen's container ends at the
* NavBar top (see (app)/_layout.tsx), so bottom: 12 means 12px
* above the NavBar on every device. */}
<View
pointerEvents="box-none"
style={{
position: 'absolute',
right: 12,
bottom: 12,
}}
>
<Pressable
onPress={() => router.push('/(app)/compose' as never)}
style={({ pressed }) => ({
width: 56, height: 56,
borderRadius: 28,
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 6,
elevation: 8,
})}
>
<Ionicons name="create-outline" size={24} color="#ffffff" />
</Pressable>
</View>
</View>
);
}
// ── Empty state ─────────────────────────────────────────────────────────
function EmptyState({
icon, title, subtitle, onRetry,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
title: string;
subtitle?: string;
onRetry?: () => void;
}) {
return (
<View style={{
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, paddingVertical: 80,
}}>
<View
style={{
width: 64, height: 64, borderRadius: 16,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginBottom: 14,
}}
>
<Ionicons name={icon} size={28} color="#6a6a6a" />
</View>
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginBottom: 6 }}>
{title}
</Text>
{subtitle && (
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
{subtitle}
</Text>
)}
{onRetry && (
<Pressable
onPress={onRetry}
style={({ pressed }) => ({
marginTop: 16,
paddingHorizontal: 20, paddingVertical: 10,
borderRadius: 999,
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Try again
</Text>
</Pressable>
)}
</View>
);
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}

View File

@@ -0,0 +1,139 @@
/**
* Hashtag feed — all posts tagged with #tag, newest first.
*
* Route: /(app)/feed/tag/[tag]
* Triggered by tapping a hashtag inside any PostCard's body.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
View, Text, FlatList, RefreshControl, ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed';
import { safeBack } from '@/lib/utils';
export default function HashtagScreen() {
const insets = useSafeAreaInsets();
const { tag: rawTag } = useLocalSearchParams<{ tag: string }>();
const tag = (rawTag ?? '').replace(/^#/, '').toLowerCase();
const keyFile = useStore(s => s.keyFile);
const [posts, setPosts] = useState<FeedPostItem[]>([]);
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const seq = useRef(0);
const load = useCallback(async (isRefresh = false) => {
if (!tag) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
const id = ++seq.current;
try {
const items = await fetchHashtag(tag, 60);
if (id !== seq.current) return;
setPosts(items);
const liked = new Set<string>();
if (keyFile) {
for (const p of items) {
const s = await fetchStats(p.post_id, keyFile.pub_key);
if (s?.liked_by_me) liked.add(p.post_id);
}
}
if (id !== seq.current) return;
setLikedSet(liked);
} catch {
if (id !== seq.current) return;
setPosts([]);
} finally {
if (id !== seq.current) return;
setLoading(false);
setRefreshing(false);
}
}, [tag, keyFile]);
useEffect(() => { load(false); }, [load]);
const onStatsChanged = useCallback(async (postID: string) => {
if (!keyFile) return;
const s = await fetchStats(postID, keyFile.pub_key);
if (!s) return;
setPosts(ps => ps.map(p => p.post_id === postID
? { ...p, likes: s.likes, views: s.views } : p));
setLikedSet(set => {
const next = new Set(set);
if (s.liked_by_me) next.add(postID); else next.delete(postID);
return next;
});
}, [keyFile]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={`#${tag}`}
/>
<FlatList
data={posts}
keyExtractor={p => p.post_id}
renderItem={({ item }) => (
<PostCard
post={item}
likedByMe={likedSet.has(item.post_id)}
onStatsChanged={onStatsChanged}
/>
)}
ItemSeparatorComponent={PostSeparator}
initialNumToRender={10}
maxToRenderPerBatch={8}
windowSize={7}
removeClippedSubviews
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => load(true)}
tintColor="#1d9bf0"
/>
}
ListEmptyComponent={
loading ? (
<View style={{ paddingTop: 80, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : (
<View style={{
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, paddingVertical: 80,
}}>
<Ionicons name="pricetag-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
No posts with this tag yet
</Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}>
Be the first write a post with #{tag}
</Text>
</View>
)
}
contentContainerStyle={
posts.length === 0
? { flexGrow: 1 }
: { paddingTop: 8, paddingBottom: 24 }
}
/>
</View>
);
}

View File

@@ -0,0 +1,342 @@
/**
* Add new contact — dark minimalist, inspired by the reference.
*
* Flow:
* 1. Пользователь вводит @username или hex pubkey / DC-address.
* 2. Жмёт Search → resolveUsername → getIdentity.
* 3. Показываем preview (avatar + имя + address + наличие x25519).
* 4. Выбирает fee (chip-selector) + вводит intro.
* 5. Submit → CONTACT_REQUEST tx.
*/
import React, { useState } from 'react';
import {
View, Text, ScrollView, Alert, Pressable, TextInput, ActivityIndicator,
} from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { getIdentity, buildContactRequestTx, submitTx, resolveUsername, humanizeTxError } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount, safeBack } from '@/lib/utils';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { SearchBar } from '@/components/SearchBar';
const MIN_CONTACT_FEE = 5000;
const FEE_TIERS = [
{ value: 5_000, label: 'Min' },
{ value: 10_000, label: 'Standard' },
{ value: 50_000, label: 'Priority' },
];
interface Resolved {
address: string;
nickname?: string;
x25519?: string;
}
export default function NewContactScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const settings = useStore(s => s.settings);
const balance = useStore(s => s.balance);
const [query, setQuery] = useState('');
const [intro, setIntro] = useState('');
const [fee, setFee] = useState(MIN_CONTACT_FEE);
const [resolved, setResolved] = useState<Resolved | null>(null);
const [searching, setSearching] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function search() {
const q = query.trim();
if (!q) return;
setSearching(true); setResolved(null); setError(null);
try {
let address = q;
if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) {
const name = q.replace('@', '');
const addr = await resolveUsername(settings.contractId, name);
if (!addr) { setError(`@${name} is not registered on this chain`); return; }
address = addr;
}
// Self-lookup: skip the contact-request dance entirely and jump straight
// to Saved Messages (self-chat). No CONTACT_REQUEST tx is needed — the
// chat-with-self flow is purely local storage.
if (keyFile && address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
router.replace(`/(app)/chats/${keyFile.pub_key}` as never);
return;
}
const identity = await getIdentity(address);
const resolvedAddr = identity?.pub_key ?? address;
if (keyFile && resolvedAddr.toLowerCase() === keyFile.pub_key.toLowerCase()) {
router.replace(`/(app)/chats/${keyFile.pub_key}` as never);
return;
}
setResolved({
address: resolvedAddr,
nickname: identity?.nickname || undefined,
x25519: identity?.x25519_pub || undefined,
});
} catch (e: any) {
setError(e?.message ?? 'Lookup failed');
} finally {
setSearching(false);
}
}
async function sendRequest() {
if (!resolved || !keyFile) return;
if (resolved.address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
Alert.alert('Can\'t message yourself', "This is your own address.");
return;
}
if (balance < fee + 1000) {
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (request fee + network).`);
return;
}
setSending(true); setError(null);
try {
const tx = buildContactRequestTx({
from: keyFile.pub_key,
to: resolved.address,
contactFee: fee,
intro: intro.trim() || undefined,
privKey: keyFile.priv_key,
});
await submitTx(tx);
Alert.alert(
'Request sent',
`A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
[{ text: 'OK', onPress: () => safeBack() }],
);
} catch (e: any) {
setError(humanizeTxError(e));
} finally {
setSending(false);
}
}
const displayName = resolved
? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address))
: '';
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Search"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: 120 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<SearchBar
value={query}
onChangeText={setQuery}
placeholder="@alice, hex pubkey or DC address"
onSubmitEditing={search}
autoFocus
onClear={() => { setResolved(null); setError(null); }}
/>
{query.trim().length > 0 && (
<Pressable
onPress={search}
disabled={searching}
style={({ pressed }) => ({
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
paddingVertical: 11, borderRadius: 999, marginTop: 12,
backgroundColor: searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{searching ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text>
)}
</Pressable>
)}
{/* Empty-state hint — показываем когда ничего не введено и нет результата */}
{query.trim().length === 0 && !resolved && (
<View style={{ marginTop: 28, alignItems: 'center', paddingHorizontal: 16 }}>
<View
style={{
width: 56, height: 56, borderRadius: 16,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginBottom: 12,
}}
>
<Ionicons name="person-add-outline" size={24} color="#6a6a6a" />
</View>
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700', marginBottom: 6 }}>
Find someone to message
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
Enter an <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text> if
the person registered one, or paste a full hex pubkey or <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> address.
</Text>
</View>
)}
{error && (
<View style={{
marginTop: 14,
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(244,33,46,0.08)',
borderWidth: 1, borderColor: 'rgba(244,33,46,0.25)',
}}>
<Text style={{ color: '#f4212e', fontSize: 13 }}>{error}</Text>
</View>
)}
{/* Resolved profile card */}
{resolved && (
<>
<View style={{
marginTop: 18,
padding: 14,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
}}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<Avatar
name={displayName}
address={resolved.address}
size={52}
dotColor={resolved.x25519 ? '#3ba55d' : '#f0b35a'}
/>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 16 }}>
{displayName}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace', marginTop: 2 }} numberOfLines={1}>
{shortAddr(resolved.address, 10)}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 5, gap: 4 }}>
<Ionicons
name={resolved.x25519 ? 'lock-closed' : 'time-outline'}
size={11}
color={resolved.x25519 ? '#3ba55d' : '#f0b35a'}
/>
<Text style={{
color: resolved.x25519 ? '#3ba55d' : '#f0b35a',
fontSize: 11, fontWeight: '500',
}}>
{resolved.x25519 ? 'E2E ready' : 'Key not published yet'}
</Text>
</View>
</View>
</View>
</View>
{/* Intro */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 18, marginBottom: 6 }}>
Intro (optional, plaintext on-chain)
</Text>
<TextInput
value={intro}
onChangeText={setIntro}
placeholder="Hey, it's Jordan from the conference"
placeholderTextColor="#5a5a5a"
multiline
maxLength={140}
style={{
color: '#ffffff', fontSize: 14,
backgroundColor: '#0a0a0a', borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 10,
borderWidth: 1, borderColor: '#1f1f1f',
minHeight: 80, textAlignVertical: 'top',
}}
/>
<Text style={{ color: '#5a5a5a', fontSize: 11, textAlign: 'right', marginTop: 4 }}>
{intro.length}/140
</Text>
{/* Fee tier */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 14, marginBottom: 6 }}>
Anti-spam fee (goes to the recipient)
</Text>
{/* Fee-tier pills.
Layout (background, border, padding) lives on a static
inner View — Pressable's dynamic style-function has been
observed to drop backgroundColor between renders on
some RN/Android versions, which is what made these
chips look like three bare text labels on black
instead of distinct pills. Press feedback via opacity
on the Pressable itself, which is stable. */}
<View style={{ flexDirection: 'row', gap: 8 }}>
{FEE_TIERS.map(t => {
const active = fee === t.value;
return (
<Pressable
key={t.value}
onPress={() => setFee(t.value)}
style={({ pressed }) => ({
flex: 1,
opacity: pressed ? 0.7 : 1,
})}
>
<View
style={{
alignItems: 'center',
paddingVertical: 10,
borderRadius: 10,
backgroundColor: active ? '#ffffff' : '#111111',
borderWidth: 1,
borderColor: active ? '#ffffff' : '#1f1f1f',
}}
>
<Text style={{
color: active ? '#000' : '#ffffff',
fontWeight: '700', fontSize: 13,
}}>
{t.label}
</Text>
<Text style={{
color: active ? '#333' : '#8b8b8b',
fontSize: 11, marginTop: 2,
}}>
{formatAmount(t.value)}
</Text>
</View>
</Pressable>
);
})}
</View>
{/* Submit */}
<Pressable
onPress={sendRequest}
disabled={sending}
style={({ pressed }) => ({
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
paddingVertical: 13, borderRadius: 999, marginTop: 20,
backgroundColor: sending ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{sending ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
Send request · {formatAmount(fee + 1000)}
</Text>
)}
</Pressable>
</>
)}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,434 @@
/**
* Profile screen — info card about any address (yours or someone else's),
* plus a Follow/Unfollow button. Posts are intentionally NOT shown here
* — this screen is chat-oriented ("who is on the other side of this
* conversation"); the feed tab + /feed/author/{pub} is where you go to
* browse someone's timeline.
*
* Route:
* /(app)/profile/<ed25519-hex>
*
* Back behaviour:
* Nested Stack layout in app/(app)/profile/_layout.tsx preserves the
* push stack, so tapping Back returns the user to whatever screen
* pushed them here (feed card tap, chat header tap, etc.).
*/
import React, { useEffect, useState } from 'react';
import {
View, Text, ScrollView, Pressable, ActivityIndicator,
} from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { followUser, unfollowUser } from '@/lib/feed';
import {
humanizeTxError, getBalance, getIdentity, getRelayFor,
type IdentityInfo, type RegisteredRelayInfo,
} from '@/lib/api';
import { safeBack, formatAmount } from '@/lib/utils';
function shortAddr(a: string, n = 10): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
export default function ProfileScreen() {
const insets = useSafeAreaInsets();
const { address } = useLocalSearchParams<{ address: string }>();
const contacts = useStore(s => s.contacts);
const keyFile = useStore(s => s.keyFile);
const contact = contacts.find(c => c.address === address);
const [following, setFollowing] = useState(false);
const [followingBusy, setFollowingBusy] = useState(false);
const [copied, setCopied] = useState(false);
// On-chain enrichment — fetched once per address mount.
const [balanceUT, setBalanceUT] = useState<number | null>(null);
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
const [relay, setRelay] = useState<RegisteredRelayInfo | null>(null);
const [loadingChain, setLoadingChain] = useState(true);
const isMe = !!keyFile && keyFile.pub_key === address;
useEffect(() => {
if (!address) return;
let cancelled = false;
setLoadingChain(true);
Promise.all([
getBalance(address).catch(() => 0),
getIdentity(address).catch(() => null),
getRelayFor(address).catch(() => null),
]).then(([bal, id, rel]) => {
if (cancelled) return;
setBalanceUT(bal);
setIdentity(id);
setRelay(rel);
}).finally(() => { if (!cancelled) setLoadingChain(false); });
return () => { cancelled = true; };
}, [address]);
const displayName = isMe
? 'Saved Messages'
: contact?.username
? `@${contact.username}`
: contact?.alias ?? shortAddr(address ?? '', 6);
const copyAddress = async () => {
if (!address) return;
await Clipboard.setStringAsync(address);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
const openChat = () => {
if (!address) return;
router.replace(`/(app)/chats/${address}` as never);
};
const onToggleFollow = async () => {
if (!keyFile || !address || isMe || followingBusy) return;
setFollowingBusy(true);
const wasFollowing = following;
setFollowing(!wasFollowing);
try {
if (wasFollowing) {
await unfollowUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address });
} else {
await followUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address });
}
} catch (e: any) {
setFollowing(wasFollowing);
// Surface the error via alert — feed lib already formats humanizeTxError.
alert(humanizeTxError(e));
} finally {
setFollowingBusy(false);
}
};
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Profile"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>
{/* ── Hero: avatar + Follow button ──────────────────────────── */}
<View style={{ flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12 }}>
<Avatar name={displayName} address={address} size={72} saved={isMe} />
<View style={{ flex: 1 }} />
{!isMe ? (
<Pressable
onPress={onToggleFollow}
disabled={followingBusy}
style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 9,
borderRadius: 999,
backgroundColor: following
? (pressed ? '#1a1a1a' : '#111111')
: (pressed ? '#e7e7e7' : '#ffffff'),
borderWidth: following ? 1 : 0,
borderColor: '#1f1f1f',
minWidth: 120,
alignItems: 'center',
})}
>
{followingBusy ? (
<ActivityIndicator
size="small"
color={following ? '#ffffff' : '#000000'}
/>
) : (
<Text
style={{
color: following ? '#ffffff' : '#000000',
fontWeight: '700',
fontSize: 13,
}}
>
{following ? 'Following' : 'Follow'}
</Text>
)}
</Pressable>
) : (
<Pressable
onPress={() => keyFile && router.push(`/(app)/chats/${keyFile.pub_key}` as never)}
style={({ pressed }) => ({
paddingHorizontal: 16, paddingVertical: 9,
borderRadius: 999,
flexDirection: 'row', alignItems: 'center', gap: 6,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Ionicons name="bookmark" size={13} color="#f0b35a" />
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Saved Messages
</Text>
</Pressable>
)}
</View>
{/* Name + verified tick */}
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text
style={{ color: '#ffffff', fontSize: 22, fontWeight: '800', letterSpacing: -0.3 }}
numberOfLines={1}
>
{displayName}
</Text>
{contact?.username && (
<Ionicons name="checkmark-circle" size={18} color="#1d9bf0" style={{ marginLeft: 5 }} />
)}
</View>
{/* Action row — View posts is universal (anyone can have a wall,
even non-contacts). Open chat appears alongside only when this
address is already a direct-chat contact. */}
<View style={{ flexDirection: 'row', gap: 8, marginTop: 14 }}>
<Pressable
onPress={() => address && router.push(`/(app)/feed/author/${address}` as never)}
style={({ pressed }) => ({
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 11,
borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Ionicons name="document-text-outline" size={15} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
View posts
</Text>
</Pressable>
{!isMe && contact && (
<Pressable
onPress={openChat}
style={({ pressed }) => ({
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 11,
borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Ionicons name="chatbubble-outline" size={15} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
Open chat
</Text>
</Pressable>
)}
</View>
{/* ── Info card ───────────────────────────────────────────────── */}
<View
style={{
marginTop: 18,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}
>
{/* Address — entire row is tappable → copies */}
<Pressable
onPress={copyAddress}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 12,
backgroundColor: pressed ? '#0f0f0f' : 'transparent',
})}
>
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>
Address
</Text>
<Text
style={{
color: copied ? '#3ba55d' : '#ffffff',
fontSize: 13,
fontFamily: 'monospace',
fontWeight: '600',
}}
numberOfLines={1}
>
{copied ? 'Copied' : shortAddr(address ?? '')}
</Text>
<Ionicons
name={copied ? 'checkmark' : 'copy-outline'}
size={14}
color={copied ? '#3ba55d' : '#6a6a6a'}
style={{ marginLeft: 8 }}
/>
</Pressable>
{/* Username — shown if the on-chain identity record has one.
Different from contact.username (which may be a local alias). */}
{identity?.nickname ? (
<>
<Divider />
<InfoRow
label="Username"
value={`@${identity.nickname}`}
icon="at-outline"
/>
</>
) : null}
{/* DC address — the human-readable form of the pub key. */}
{identity?.address ? (
<>
<Divider />
<InfoRow
label="DC address"
value={identity.address}
icon="pricetag-outline"
/>
</>
) : null}
{/* Balance — always shown once fetched. */}
<Divider />
<InfoRow
label="Balance"
value={loadingChain
? '…'
: `${formatAmount(balanceUT ?? 0)} UT`}
icon="wallet-outline"
/>
{/* Relay node — shown only if this address is a registered relay. */}
{relay && (
<>
<Divider />
<InfoRow
label="Relay node"
value={`${formatAmount(relay.relay.fee_per_msg_ut)} UT / msg`}
icon="radio-outline"
/>
{relay.last_heartbeat ? (
<>
<Divider />
<InfoRow
label="Last seen"
value={new Date(relay.last_heartbeat * 1000).toLocaleString()}
icon="pulse-outline"
/>
</>
) : null}
</>
)}
{/* Encryption status */}
{contact && (
<>
<Divider />
<InfoRow
label="Encryption"
value={contact.x25519Pub
? 'end-to-end (NaCl)'
: 'key not published yet'}
danger={!contact.x25519Pub}
icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'}
/>
<Divider />
<InfoRow
label="Added"
value={new Date(contact.addedAt).toLocaleDateString()}
icon="calendar-outline"
/>
{/* Group-only: participant count. DMs always have exactly two
people so the row would be noise. Groups would show real
member count here from chain state once v2.1.0 ships groups. */}
{contact.kind === 'group' && (
<>
<Divider />
<InfoRow
label="Members"
value="—"
icon="people-outline"
/>
</>
)}
</>
)}
</View>
{!contact && !isMe && (
<Text style={{
color: '#6a6a6a',
fontSize: 12,
textAlign: 'center',
marginTop: 14,
paddingHorizontal: 24,
lineHeight: 17,
}}>
This user isn't in your contacts yet. Tap "Follow" to see their posts in your feed, or add them as a chat contact via @username.
</Text>
)}
</ScrollView>
</View>
);
}
function Divider() {
return <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />;
}
function InfoRow({
label, value, icon, danger,
}: {
label: string;
value: string;
icon?: React.ComponentProps<typeof Ionicons>['name'];
danger?: boolean;
}) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 12,
}}
>
{icon && (
<Ionicons
name={icon}
size={14}
color={danger ? '#f0b35a' : '#6a6a6a'}
style={{ marginRight: 8 }}
/>
)}
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
<Text
style={{
color: danger ? '#f0b35a' : '#ffffff',
fontSize: 13,
fontWeight: '600',
}}
numberOfLines={1}
>
{value}
</Text>
</View>
);
}

View File

@@ -0,0 +1,24 @@
/**
* Profile group layout — provides a dedicated native Stack for the
* /(app)/profile/* routes so that `router.back()` returns to the screen
* that pushed us here (post detail, chat, feed tab, etc.) instead of
* falling through to the root.
*
* The parent (app)/_layout.tsx uses AnimatedSlot → <Slot>, which is
* stack-less. Nesting a <Stack> here gives profile routes proper back
* history without affecting the outer tabs.
*/
import React from 'react';
import { Stack } from 'expo-router';
export default function ProfileLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#000000' },
animation: 'slide_from_right',
}}
/>
);
}

View File

@@ -0,0 +1,173 @@
/**
* Contact requests / notifications — dark minimalist.
*
* В референсе нижний таб «notifications» ведёт сюда. Пока это только
* incoming CONTACT_REQUEST'ы; позже сюда же придут другие системные
* уведомления (slash, ADD_VALIDATOR со-sig-ing, и т.д.).
*/
import React, { useState } from 'react';
import { View, Text, FlatList, Alert, Pressable, ActivityIndicator } from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import {
buildAcceptContactTx, submitTx, getIdentity, humanizeTxError,
} from '@/lib/api';
import { saveContact } from '@/lib/storage';
import { shortAddr } from '@/lib/crypto';
import { relativeTime } from '@/lib/utils';
import type { ContactRequest } from '@/lib/types';
import { Avatar } from '@/components/Avatar';
import { TabHeader } from '@/components/TabHeader';
import { IconButton } from '@/components/IconButton';
export default function RequestsScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const requests = useStore(s => s.requests);
const setRequests = useStore(s => s.setRequests);
const upsertContact = useStore(s => s.upsertContact);
const [accepting, setAccepting] = useState<string | null>(null);
async function accept(req: ContactRequest) {
if (!keyFile) return;
setAccepting(req.txHash);
try {
const identity = await getIdentity(req.from);
const x25519Pub = identity?.x25519_pub ?? '';
const tx = buildAcceptContactTx({
from: keyFile.pub_key, to: req.from, privKey: keyFile.priv_key,
});
await submitTx(tx);
const contact = { address: req.from, x25519Pub, username: req.username, addedAt: Date.now() };
upsertContact(contact);
await saveContact(contact);
setRequests(requests.filter(r => r.txHash !== req.txHash));
router.replace(`/(app)/chats/${req.from}` as never);
} catch (e: any) {
Alert.alert('Accept failed', humanizeTxError(e));
} finally {
setAccepting(null);
}
}
function decline(req: ContactRequest) {
Alert.alert(
'Decline request',
`Decline request from ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Decline',
style: 'destructive',
onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)),
},
],
);
}
const renderItem = ({ item: req }: { item: ContactRequest }) => {
const name = req.username ? `@${req.username}` : shortAddr(req.from);
const isAccepting = accepting === req.txHash;
return (
<View
style={{
flexDirection: 'row',
paddingHorizontal: 14,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#0f0f0f',
}}
>
<Avatar name={name} address={req.from} size={44} />
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}>
{name}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
wants to add you as a contact · {relativeTime(req.timestamp)}
</Text>
{req.intro ? (
<Text
style={{
color: '#d0d0d0', fontSize: 13, lineHeight: 18,
marginTop: 6,
padding: 8,
borderRadius: 10,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
}}
numberOfLines={3}
>
{req.intro}
</Text>
) : null}
<View style={{ flexDirection: 'row', gap: 8, marginTop: 10 }}>
<Pressable
onPress={() => accept(req)}
disabled={isAccepting}
style={({ pressed }) => ({
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingVertical: 9, borderRadius: 999,
backgroundColor: isAccepting ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{isAccepting ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>Accept</Text>
)}
</Pressable>
<Pressable
onPress={() => decline(req)}
disabled={isAccepting}
style={({ pressed }) => ({
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingVertical: 9, borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>Decline</Text>
</Pressable>
</View>
</View>
</View>
);
};
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Notifications" />
{requests.length === 0 ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
<Ionicons name="notifications-outline" size={42} color="#3a3a3a" />
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
All caught up
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', marginTop: 6, lineHeight: 19 }}>
Contact requests and network events will appear here.
</Text>
</View>
) : (
<FlatList
data={requests}
keyExtractor={r => r.txHash}
renderItem={renderItem}
contentContainerStyle={{ paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
/>
)}
</View>
);
}

View File

@@ -0,0 +1,595 @@
/**
* Settings screen — sub-route, открывается по tap'у на profile-avatar в
* TabHeader. Использует обычный `<Header>` с back-кнопкой.
*
* Секции:
* 1. Профиль — avatar, @username, short-address, Copy row.
* 2. Username — регистрация в native:username_registry (если не куплено).
* 3. Node — URL + contract ID + Save + Status.
* 4. Account — Export key, Delete account.
*
* Весь Pressable'овый layout живёт на ВНЕШНЕМ View с static style —
* Pressable handle-ит только background change (через вложенный View
* в ({pressed}) callback'е), никаких layout props в callback-style.
* Это лечит web-баг, где Pressable style-функция не применяет
* percentage/padding layout надёжно.
*/
import React, { useState, useEffect } from 'react';
import {
View, Text, ScrollView, TextInput, Alert, Pressable, ActivityIndicator, Share,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { saveSettings, deleteKeyFile } from '@/lib/storage';
import {
setNodeUrl, getNetStats, resolveUsername, reverseResolve,
buildCallContractTx, submitTx,
USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
humanizeTxError,
} from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount, safeBack } from '@/lib/utils';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
type NodeStatus = 'idle' | 'checking' | 'ok' | 'error';
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
// ─── Shared layout primitives ─────────────────────────────────────
function SectionLabel({ children }: { children: string }) {
return (
<Text
style={{
color: '#5a5a5a',
fontSize: 11,
letterSpacing: 1.2,
textTransform: 'uppercase',
marginTop: 18,
marginBottom: 8,
paddingHorizontal: 14,
fontWeight: '700',
}}
>
{children}
</Text>
);
}
function Card({ children }: { children: React.ReactNode }) {
return (
<View
style={{
backgroundColor: '#0a0a0a',
borderRadius: 14,
marginHorizontal: 14,
overflow: 'hidden',
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
{children}
</View>
);
}
/**
* Row — clickable / non-clickable list item внутри Card'а.
*
* Layout живёт на ВНЕШНЕМ контейнере (View если read-only, Pressable
* если tappable). Для pressed-стейта используется вложенный `<View>`
* с background-color, чтобы не полагаться на style-функцию Pressable'а
* (web-баг).
*/
function Row({
icon, label, value, onPress, right, danger, first,
}: {
icon: IoniconName;
label: string;
value?: string;
onPress?: () => void;
right?: React.ReactNode;
danger?: boolean;
first?: boolean;
}) {
const body = (pressed: boolean) => (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 13,
backgroundColor: pressed ? '#151515' : 'transparent',
borderTopWidth: first ? 0 : 1,
borderTopColor: '#1f1f1f',
}}
>
<View
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: danger ? 'rgba(244,33,46,0.12)' : '#1a1a1a',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
}}
>
<Ionicons name={icon} size={16} color={danger ? '#f4212e' : '#ffffff'} />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text
style={{
color: danger ? '#f4212e' : '#ffffff',
fontSize: 14,
fontWeight: '600',
}}
>
{label}
</Text>
{value !== undefined && (
<Text numberOfLines={1} style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
{value}
</Text>
)}
</View>
{right}
{onPress && !right && (
<Ionicons name="chevron-forward" size={16} color="#5a5a5a" />
)}
</View>
);
if (!onPress) return <View>{body(false)}</View>;
return (
<Pressable onPress={onPress}>
{({ pressed }) => body(pressed)}
</Pressable>
);
}
// ─── Screen ───────────────────────────────────────────────────────
export default function SettingsScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const setKeyFile = useStore(s => s.setKeyFile);
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
const username = useStore(s => s.username);
const setUsername = useStore(s => s.setUsername);
const balance = useStore(s => s.balance);
const [nodeUrl, setNodeUrlInput] = useState(settings.nodeUrl);
const [contractId, setContractId] = useState(settings.contractId);
const [nodeStatus, setNodeStatus] = useState<NodeStatus>('idle');
const [peerCount, setPeerCount] = useState<number | null>(null);
const [blockCount, setBlockCount] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const [savingNode, setSavingNode] = useState(false);
// Username registration state
const [nameInput, setNameInput] = useState('');
const [nameError, setNameError] = useState<string | null>(null);
const [registering, setRegistering] = useState(false);
useEffect(() => { checkNode(); }, []);
useEffect(() => { setContractId(settings.contractId); }, [settings.contractId]);
useEffect(() => {
if (!settings.contractId || !keyFile) { setUsername(null); return; }
(async () => {
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
setUsername(name);
})();
}, [settings.contractId, keyFile, setUsername]);
async function checkNode() {
setNodeStatus('checking');
try {
const stats = await getNetStats();
setNodeStatus('ok');
setPeerCount(stats.peer_count);
setBlockCount(stats.total_blocks);
} catch {
setNodeStatus('error');
}
}
async function saveNode() {
setSavingNode(true);
const url = nodeUrl.trim().replace(/\/$/, '');
setNodeUrl(url);
const next = { nodeUrl: url, contractId: contractId.trim() };
setSettings(next);
await saveSettings(next);
await checkNode();
setSavingNode(false);
Alert.alert('Saved', 'Node settings updated.');
}
async function copyAddress() {
if (!keyFile) return;
await Clipboard.setStringAsync(keyFile.pub_key);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
async function exportKey() {
if (!keyFile) return;
try {
await Share.share({
message: JSON.stringify(keyFile, null, 2),
title: 'DChain key file',
});
} catch (e: any) {
Alert.alert('Export failed', e?.message ?? 'Unknown error');
}
}
function logout() {
Alert.alert(
'Delete account',
'Your key will be removed from this device. Make sure you have a backup!',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await deleteKeyFile();
setKeyFile(null);
router.replace('/');
},
},
],
);
}
const onNameChange = (v: string) => {
const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH);
setNameInput(cleaned);
setNameError(null);
};
const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput);
async function registerUsername() {
if (!keyFile) return;
const name = nameInput.trim();
if (!nameIsValid) {
setNameError(`Min ${MIN_USERNAME_LENGTH} chars, starts with a-z`);
return;
}
if (!settings.contractId) {
setNameError('No registry contract in node settings');
return;
}
const total = USERNAME_REGISTRATION_FEE + 1000 + 2000;
if (balance < total) {
setNameError(`Need ${formatAmount(total)}, have ${formatAmount(balance)}`);
return;
}
try {
const existing = await resolveUsername(settings.contractId, name);
if (existing) { setNameError(`@${name} already taken`); return; }
} catch { /* ignore */ }
Alert.alert(
`Buy @${name}?`,
`Cost: ${formatAmount(USERNAME_REGISTRATION_FEE)} + fee ${formatAmount(1000)}.\nBinds to your address until released.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Buy',
onPress: async () => {
setRegistering(true);
setNameError(null);
try {
const tx = buildCallContractTx({
from: keyFile.pub_key,
contractId: settings.contractId,
method: 'register',
args: [name],
amount: USERNAME_REGISTRATION_FEE,
privKey: keyFile.priv_key,
});
await submitTx(tx);
setNameInput('');
Alert.alert('Submitted', 'Registration tx accepted. Name appears in a few seconds.');
let attempts = 0;
const iv = setInterval(async () => {
attempts++;
const got = keyFile
? await reverseResolve(settings.contractId, keyFile.pub_key)
: null;
if (got) { setUsername(got); clearInterval(iv); }
else if (attempts >= 10) clearInterval(iv);
}, 2000);
} catch (e: any) {
setNameError(humanizeTxError(e));
} finally {
setRegistering(false);
}
},
},
],
);
}
const statusColor =
nodeStatus === 'ok' ? '#3ba55d' :
nodeStatus === 'error' ? '#f4212e' :
'#f0b35a';
const statusLabel =
nodeStatus === 'ok' ? 'Connected' :
nodeStatus === 'error' ? 'Unreachable' :
'Checking…';
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Settings"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
>
{/* ── Profile ── */}
<SectionLabel>Profile</SectionLabel>
<Card>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
padding: 14,
gap: 14,
}}
>
<Avatar
name={username ?? keyFile?.pub_key ?? '?'}
address={keyFile?.pub_key}
size={56}
/>
<View style={{ flex: 1, minWidth: 0 }}>
{username ? (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 17 }}>
@{username}
</Text>
<Ionicons name="checkmark-circle" size={15} color="#1d9bf0" style={{ marginLeft: 4 }} />
</View>
) : (
<Text style={{ color: '#8b8b8b', fontSize: 14 }}>No username yet</Text>
)}
<Text
style={{
color: '#8b8b8b',
fontSize: 11,
marginTop: 2,
fontFamily: 'monospace',
}}
numberOfLines={1}
>
{keyFile ? shortAddr(keyFile.pub_key, 10) : '—'}
</Text>
</View>
</View>
<Row
icon={copied ? 'checkmark-outline' : 'copy-outline'}
label={copied ? 'Copied!' : 'Copy address'}
onPress={copyAddress}
right={<View style={{ width: 16 }} />}
/>
</Card>
{/* ── Username (только если ещё нет) ── */}
{!username && (
<>
<SectionLabel>Username</SectionLabel>
<Card>
<View style={{ padding: 14 }}>
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14, marginBottom: 4 }}>
Buy a username
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 10 }}>
Flat {formatAmount(USERNAME_REGISTRATION_FEE)} fee + {formatAmount(1000)} network.
Only a-z, 0-9, _, -. Starts with a letter.
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#000000',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 4,
borderWidth: 1,
borderColor: nameError ? '#f4212e' : '#1f1f1f',
}}
>
<Text style={{ color: '#5a5a5a', fontSize: 15, marginRight: 2 }}>@</Text>
<TextInput
value={nameInput}
onChangeText={onNameChange}
placeholder="alice"
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
maxLength={MAX_USERNAME_LENGTH}
style={{
flex: 1,
color: '#ffffff',
fontSize: 15,
paddingVertical: 8,
}}
/>
</View>
{nameError && (
<Text style={{ color: '#f4212e', fontSize: 12, marginTop: 6 }}>
{nameError}
</Text>
)}
<PrimaryButton
onPress={registerUsername}
disabled={registering || !nameIsValid || !settings.contractId}
loading={registering}
label={`Buy @${nameInput || 'username'}`}
style={{ marginTop: 12 }}
/>
</View>
</Card>
</>
)}
{/* ── Node ── */}
<SectionLabel>Node</SectionLabel>
<Card>
<View style={{ padding: 14, gap: 10 }}>
<LabeledInput
label="Node URL"
value={nodeUrl}
onChangeText={setNodeUrlInput}
placeholder="http://localhost:8080"
/>
<LabeledInput
label="Username contract"
value={contractId}
onChangeText={setContractId}
placeholder="auto-discovered via /api/well-known-contracts"
monospace
/>
<PrimaryButton
onPress={saveNode}
disabled={savingNode}
loading={savingNode}
label="Save"
style={{ marginTop: 4 }}
/>
</View>
<Row
icon="pulse-outline"
label="Status"
value={
nodeStatus === 'ok'
? `${statusLabel} · ${blockCount ?? 0} blocks · ${peerCount ?? 0} peers`
: statusLabel
}
right={
<View
style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: statusColor }}
/>
}
/>
</Card>
{/* ── Account ── */}
<SectionLabel>Account</SectionLabel>
<Card>
<Row
icon="download-outline"
label="Export key"
value="Save your private key as JSON"
onPress={exportKey}
first
/>
<Row
icon="trash-outline"
label="Delete account"
value="Remove key from this device"
onPress={logout}
danger
/>
</Card>
</ScrollView>
</View>
);
}
// ─── Form primitives ──────────────────────────────────────────────
function LabeledInput({
label, value, onChangeText, placeholder, monospace,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
monospace?: boolean;
}) {
return (
<View>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginBottom: 6 }}>{label}</Text>
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
style={{
color: '#ffffff',
fontSize: monospace ? 13 : 14,
fontFamily: monospace ? 'monospace' : undefined,
backgroundColor: '#000000',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
borderWidth: 1,
borderColor: '#1f1f1f',
}}
/>
</View>
);
}
function PrimaryButton({
label, onPress, disabled, loading, style,
}: {
label: string;
onPress: () => void;
disabled?: boolean;
loading?: boolean;
style?: object;
}) {
return (
<Pressable onPress={onPress} disabled={disabled} style={style}>
{({ pressed }) => (
<View
style={{
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 11,
borderRadius: 999,
backgroundColor: disabled
? '#1a1a1a'
: pressed ? '#1a8cd8' : '#1d9bf0',
}}
>
{loading ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text
style={{
color: disabled ? '#5a5a5a' : '#ffffff',
fontWeight: '700',
fontSize: 14,
}}
>
{label}
</Text>
)}
</View>
)}
</Pressable>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,653 @@
/**
* Wallet screen — dark minimalist.
*
* Сетка:
* [TabHeader: profile-avatar | Wallet | refresh]
* [Balance hero card — gradient-ish dark card, big number, address chip, action row]
* [SectionLabel: Recent transactions]
* [TX list card — tiles per tx, in/out coloring, relative time]
* [Send modal: slide-up sheet с полями recipient/amount/fee + total preview]
*
* Все кнопки и инпуты — те же плоские стили, что на других экранах.
* Никаких style-функций у Pressable'ов с layout-пропсами (избегаем web
* layout-баги, которые мы уже ловили на ChatTile/MessageBubble).
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
View, Text, ScrollView, Modal, Alert, RefreshControl, Pressable, TextInput, ActivityIndicator,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { useBalance } from '@/hooks/useBalance';
import { buildTransferTx, submitTx, getTxHistory, getBalance, humanizeTxError } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount, relativeTime } from '@/lib/utils';
import type { TxRecord } from '@/lib/types';
import { TabHeader } from '@/components/TabHeader';
import { IconButton } from '@/components/IconButton';
// ─── TX meta (icon + label + tone) ─────────────────────────────────
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
interface TxMeta {
label: string;
icon: IoniconName;
tone: 'in' | 'out' | 'neutral';
}
const TX_META: Record<string, TxMeta> = {
TRANSFER: { label: 'Transfer', icon: 'swap-horizontal-outline', tone: 'neutral' },
CONTACT_REQUEST: { label: 'Contact request', icon: 'person-add-outline', tone: 'out' },
ACCEPT_CONTACT: { label: 'Contact accepted', icon: 'person-outline', tone: 'in' },
BLOCK_CONTACT: { label: 'Block', icon: 'ban-outline', tone: 'out' },
DEPLOY_CONTRACT: { label: 'Deploy', icon: 'document-text-outline', tone: 'out' },
CALL_CONTRACT: { label: 'Call contract', icon: 'flash-outline', tone: 'out' },
STAKE: { label: 'Stake', icon: 'lock-closed-outline', tone: 'out' },
UNSTAKE: { label: 'Unstake', icon: 'lock-open-outline', tone: 'in' },
REGISTER_KEY: { label: 'Register key', icon: 'key-outline', tone: 'neutral' },
BLOCK_REWARD: { label: 'Block reward', icon: 'diamond-outline', tone: 'in' },
};
function txMeta(type: string): TxMeta {
return TX_META[type] ?? { label: type.replace(/_/g, ' '), icon: 'ellipse-outline', tone: 'neutral' };
}
const toneColor = (tone: TxMeta['tone']): string =>
tone === 'in' ? '#3ba55d' : tone === 'out' ? '#f4212e' : '#e7e7e7';
// ─── Main ──────────────────────────────────────────────────────────
export default function WalletScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const balance = useStore(s => s.balance);
const setBalance = useStore(s => s.setBalance);
useBalance();
const [txHistory, setTxHistory] = useState<TxRecord[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [copied, setCopied] = useState(false);
const [showSend, setShowSend] = useState(false);
const load = useCallback(async () => {
if (!keyFile) return;
setRefreshing(true);
try {
const [hist, bal] = await Promise.all([
getTxHistory(keyFile.pub_key),
getBalance(keyFile.pub_key),
]);
setTxHistory(hist);
setBalance(bal);
} catch { /* ignore — WS/HTTP retries sample */ }
setRefreshing(false);
}, [keyFile, setBalance]);
useEffect(() => { load(); }, [load]);
const copyAddress = async () => {
if (!keyFile) return;
await Clipboard.setStringAsync(keyFile.pub_key);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
const mine = keyFile?.pub_key ?? '';
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader
title="Wallet"
right={<IconButton icon="refresh-outline" size={36} onPress={load} />}
/>
<ScrollView
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} tintColor="#1d9bf0" />}
contentContainerStyle={{ paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
>
<BalanceHero
balance={balance}
address={mine}
copied={copied}
onCopy={copyAddress}
onSend={() => setShowSend(true)}
/>
<SectionLabel>Recent transactions</SectionLabel>
{txHistory.length === 0 ? (
<EmptyTx />
) : (
<View
style={{
marginHorizontal: 14,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}
>
{txHistory.map((tx, i) => (
<TxTile
key={tx.hash + i}
tx={tx}
first={i === 0}
mine={mine}
/>
))}
</View>
)}
</ScrollView>
<SendModal
visible={showSend}
onClose={() => setShowSend(false)}
balance={balance}
keyFile={keyFile}
onSent={() => {
setShowSend(false);
setTimeout(load, 1200);
}}
/>
</View>
);
}
// ─── Hero card ─────────────────────────────────────────────────────
function BalanceHero({
balance, address, copied, onCopy, onSend,
}: {
balance: number;
address: string;
copied: boolean;
onCopy: () => void;
onSend: () => void;
}) {
return (
<View
style={{
marginHorizontal: 14,
marginTop: 10,
padding: 20,
borderRadius: 18,
backgroundColor: '#0a0a0a',
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
<Text style={{ color: '#8b8b8b', fontSize: 12, letterSpacing: 0.3 }}>
Balance
</Text>
<Text
style={{
color: '#ffffff',
fontSize: 36,
fontWeight: '800',
letterSpacing: -0.8,
marginTop: 4,
}}
>
{formatAmount(balance)}
</Text>
{/* Address chip */}
<Pressable onPress={onCopy} style={{ marginTop: 14 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#111111',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 9,
}}
>
<Ionicons
name={copied ? 'checkmark-outline' : 'copy-outline'}
size={14}
color={copied ? '#3ba55d' : '#8b8b8b'}
/>
<Text
style={{
color: copied ? '#3ba55d' : '#8b8b8b',
fontSize: 12,
marginLeft: 6,
fontFamily: 'monospace',
flex: 1,
}}
numberOfLines={1}
>
{copied ? 'Copied!' : shortAddr(address, 10)}
</Text>
</View>
</Pressable>
{/* Actions */}
<View style={{ flexDirection: 'row', gap: 10, marginTop: 14 }}>
<HeroButton icon="paper-plane-outline" label="Send" primary onPress={onSend} />
<HeroButton icon="download-outline" label="Receive" onPress={onCopy} />
</View>
</View>
);
}
function HeroButton({
icon, label, primary, onPress,
}: {
icon: IoniconName;
label: string;
primary?: boolean;
onPress: () => void;
}) {
const base = {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 11,
borderRadius: 999,
gap: 6,
} as const;
return (
<Pressable onPress={onPress} style={{ flex: 1 }}>
{({ pressed }) => (
<View
style={{
...base,
backgroundColor: primary
? (pressed ? '#1a8cd8' : '#1d9bf0')
: (pressed ? '#202020' : '#111111'),
borderWidth: primary ? 0 : 1,
borderColor: '#1f1f1f',
}}
>
<Ionicons name={icon} size={15} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
{label}
</Text>
</View>
)}
</Pressable>
);
}
// ─── Section label ────────────────────────────────────────────────
function SectionLabel({ children }: { children: string }) {
return (
<Text
style={{
color: '#5a5a5a',
fontSize: 11,
fontWeight: '700',
letterSpacing: 1.2,
textTransform: 'uppercase',
marginTop: 22,
marginBottom: 8,
paddingHorizontal: 14,
}}
>
{children}
</Text>
);
}
// ─── Empty state ──────────────────────────────────────────────────
function EmptyTx() {
return (
<View
style={{
marginHorizontal: 14,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1,
borderColor: '#1f1f1f',
paddingVertical: 36,
alignItems: 'center',
}}
>
<Ionicons name="receipt-outline" size={32} color="#5a5a5a" />
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 8 }}>
No transactions yet
</Text>
<Text style={{ color: '#5a5a5a', fontSize: 11, marginTop: 2 }}>
Pull to refresh
</Text>
</View>
);
}
// ─── TX tile ──────────────────────────────────────────────────────
//
// Pressable с ВНЕШНИМ плоским style (background через static object),
// внутренняя View handles row-layout. Избегаем web-баг со style-функциями
// Pressable'а.
function TxTile({
tx, first, mine,
}: {
tx: TxRecord;
first: boolean;
mine: string;
}) {
const m = txMeta(tx.type);
const isMineTx = tx.from === mine;
const amt = tx.amount ?? 0;
const sign = m.tone === 'in' ? '+' : m.tone === 'out' ? '' : '';
const color = toneColor(m.tone);
return (
<Pressable onPress={() => router.push(`/(app)/tx/${tx.hash}` as never)}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 12,
borderTopWidth: first ? 0 : 1,
borderTopColor: '#1f1f1f',
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: '#111111',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
}}
>
<Ionicons name={m.icon} size={16} color="#e7e7e7" />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}>
{m.label}
</Text>
<Text
style={{
color: '#8b8b8b',
fontSize: 11,
marginTop: 1,
fontFamily: 'monospace',
}}
numberOfLines={1}
>
{tx.type === 'TRANSFER'
? (isMineTx ? `${shortAddr(tx.to ?? '', 5)}` : `${shortAddr(tx.from, 5)}`)
: shortAddr(tx.hash, 8)}
{' · '}
{relativeTime(tx.timestamp)}
</Text>
</View>
{amt > 0 && (
<Text style={{ color, fontWeight: '700', fontSize: 14 }}>
{sign}{formatAmount(amt)}
</Text>
)}
</View>
</Pressable>
);
}
// ─── Send modal ───────────────────────────────────────────────────
function SendModal({
visible, onClose, balance, keyFile, onSent,
}: {
visible: boolean;
onClose: () => void;
balance: number;
keyFile: { pub_key: string; priv_key: string } | null;
onSent: () => void;
}) {
const insets = useSafeAreaInsets();
const [to, setTo] = useState('');
const [amount, setAmount] = useState('');
const [fee, setFee] = useState('1000');
const [sending, setSending] = useState(false);
useEffect(() => {
if (!visible) {
// reset при закрытии
setTo(''); setAmount(''); setFee('1000'); setSending(false);
}
}, [visible]);
const amt = parseInt(amount || '0', 10) || 0;
const f = parseInt(fee || '0', 10) || 0;
const total = amt + f;
const ok = !!to.trim() && amt > 0 && total <= balance;
const send = async () => {
if (!keyFile) return;
if (!ok) {
Alert.alert('Check inputs', total > balance
? `Need ${formatAmount(total)}, have ${formatAmount(balance)}.`
: 'Recipient and amount are required.');
return;
}
setSending(true);
try {
const tx = buildTransferTx({
from: keyFile.pub_key,
to: to.trim(),
amount: amt,
fee: f,
privKey: keyFile.priv_key,
});
await submitTx(tx);
onSent();
} catch (e: any) {
Alert.alert('Send failed', humanizeTxError(e));
} finally {
setSending(false);
}
};
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
<Pressable
onPress={onClose}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.82)', justifyContent: 'flex-end' }}
>
<Pressable
onPress={() => { /* block bubble-close */ }}
style={{
backgroundColor: '#0a0a0a',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingTop: 10,
paddingBottom: Math.max(insets.bottom, 14) + 12,
paddingHorizontal: 14,
borderTopWidth: 1,
borderColor: '#1f1f1f',
}}
>
<View
style={{
alignSelf: 'center',
width: 40, height: 4, borderRadius: 2,
backgroundColor: '#2a2a2a',
marginBottom: 14,
}}
/>
<Text style={{ color: '#ffffff', fontSize: 18, fontWeight: '700', marginBottom: 14 }}>
Send tokens
</Text>
<Field label="Recipient address">
<TextInput
value={to}
onChangeText={setTo}
placeholder="DC… or pub_key hex"
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
style={{
color: '#ffffff',
fontSize: 13,
fontFamily: 'monospace',
paddingVertical: 0,
}}
/>
</Field>
<View style={{ flexDirection: 'row', gap: 10, marginTop: 10 }}>
<View style={{ flex: 2 }}>
<Field label="Amount (µT)">
<TextInput
value={amount}
onChangeText={setAmount}
placeholder="1000"
placeholderTextColor="#5a5a5a"
keyboardType="numeric"
style={{ color: '#ffffff', fontSize: 14, paddingVertical: 0 }}
/>
</Field>
</View>
<View style={{ flex: 1 }}>
<Field label="Fee (µT)">
<TextInput
value={fee}
onChangeText={setFee}
placeholder="1000"
placeholderTextColor="#5a5a5a"
keyboardType="numeric"
style={{ color: '#ffffff', fontSize: 14, paddingVertical: 0 }}
/>
</Field>
</View>
</View>
{/* Summary */}
<View
style={{
marginTop: 12,
padding: 12,
borderRadius: 10,
backgroundColor: '#111111',
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
<SummaryRow label="Amount" value={formatAmount(amt)} />
<SummaryRow label="Fee" value={formatAmount(f)} muted />
<View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 6 }} />
<SummaryRow
label="Total"
value={formatAmount(total)}
accent={total > balance ? '#f4212e' : '#ffffff'}
/>
</View>
<View style={{ flexDirection: 'row', gap: 10, marginTop: 16 }}>
<Pressable onPress={onClose} style={{ flex: 1 }}>
{({ pressed }) => (
<View
style={{
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
Cancel
</Text>
</View>
)}
</Pressable>
<Pressable onPress={send} disabled={!ok || sending} style={{ flex: 2 }}>
{({ pressed }) => (
<View
style={{
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 999,
backgroundColor: !ok || sending
? '#1a1a1a'
: pressed ? '#1a8cd8' : '#1d9bf0',
}}
>
{sending ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
Send
</Text>
)}
</View>
)}
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<View>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginBottom: 6 }}>{label}</Text>
<View
style={{
backgroundColor: '#000000',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
{children}
</View>
</View>
);
}
function SummaryRow({
label, value, muted, accent,
}: {
label: string;
value: string;
muted?: boolean;
accent?: string;
}) {
return (
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 3,
}}
>
<Text style={{ color: '#8b8b8b', fontSize: 12 }}>{label}</Text>
<Text
style={{
color: accent ?? (muted ? '#8b8b8b' : '#ffffff'),
fontSize: 13,
fontWeight: muted ? '500' : '700',
}}
>
{value}
</Text>
</View>
);
}

View File

@@ -0,0 +1,140 @@
/**
* Create Account — dark minimalist.
* Генерирует Ed25519 + X25519 keypair локально, сохраняет в SecureStore.
*/
import React, { useState } from 'react';
import { View, Text, ScrollView, Alert, Pressable, ActivityIndicator } from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { generateKeyFile } from '@/lib/crypto';
import { saveKeyFile } from '@/lib/storage';
import { useStore } from '@/lib/store';
import { safeBack } from '@/lib/utils';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
export default function CreateAccountScreen() {
const insets = useSafeAreaInsets();
const setKeyFile = useStore(s => s.setKeyFile);
const [loading, setLoading] = useState(false);
async function handleCreate() {
setLoading(true);
try {
const kf = generateKeyFile();
await saveKeyFile(kf);
setKeyFile(kf);
router.replace('/(auth)/created' as never);
} catch (e: any) {
Alert.alert('Error', e?.message ?? 'Unknown error');
} finally {
setLoading(false);
}
}
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Create account"
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
/>
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700', marginBottom: 4 }}>
A new identity is created locally
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 18 }}>
Your private key never leaves this device. The app encrypts it in the
platform secure store.
</Text>
<View
style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}
>
<InfoRow icon="key-outline" label="Ed25519 signing key" desc="Your on-chain address and tx signer" first />
<InfoRow icon="lock-closed-outline" label="X25519 encryption key" desc="End-to-end encryption for messages" />
<InfoRow icon="phone-portrait-outline" label="Stored on device" desc="Encrypted in SecureStore / Keystore" />
</View>
<View
style={{
marginTop: 16,
padding: 12,
borderRadius: 12,
backgroundColor: 'rgba(240,179,90,0.08)',
borderWidth: 1, borderColor: 'rgba(240,179,90,0.25)',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
<Ionicons name="warning-outline" size={14} color="#f0b35a" style={{ marginRight: 6 }} />
<Text style={{ color: '#f0b35a', fontSize: 13, fontWeight: '700' }}>Important</Text>
</View>
<Text style={{ color: '#d0a26a', fontSize: 12, lineHeight: 17 }}>
Export and backup your key file right after creation. If you lose
it there is no recovery blockchain has no password reset.
</Text>
</View>
<Pressable
onPress={handleCreate}
disabled={loading}
style={({ pressed }) => ({
alignItems: 'center', justifyContent: 'center',
paddingVertical: 13, borderRadius: 999, marginTop: 20,
backgroundColor: loading ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{loading ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}>
Generate keys & continue
</Text>
)}
</Pressable>
</ScrollView>
</View>
);
}
function InfoRow({
icon, label, desc, first,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
desc: string;
first?: boolean;
}) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
padding: 14,
gap: 12,
borderTopWidth: first ? 0 : 1,
borderTopColor: '#1f1f1f',
}}
>
<View
style={{
width: 32, height: 32, borderRadius: 8,
backgroundColor: '#111111',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name={icon} size={16} color="#ffffff" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}>{label}</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>{desc}</Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,196 @@
/**
* Account Created confirmation screen — dark minimalist.
* Показывает адрес + x25519, кнопки copy и export (share) key.json.
*/
import React, { useState } from 'react';
import { View, Text, ScrollView, Alert, Pressable, Share } from 'react-native';
import { router } from 'expo-router';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { Header } from '@/components/Header';
export default function AccountCreatedScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const [copied, setCopied] = useState<string | null>(null);
if (!keyFile) {
router.replace('/');
return null;
}
async function copy(value: string, label: string) {
await Clipboard.setStringAsync(value);
setCopied(label);
setTimeout(() => setCopied(null), 1800);
}
async function exportKey() {
try {
const json = JSON.stringify(keyFile, null, 2);
// Используем плоский Share API — без записи во временный файл.
// Получатель (mail, notes, etc.) получит текст целиком; юзер сам
// сохраняет как .json если нужно.
await Share.share({
message: json,
title: 'DChain key file',
});
} catch (e: any) {
Alert.alert('Export failed', e?.message ?? 'Unknown error');
}
}
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header title="Account created" />
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
{/* Success badge */}
<View style={{ alignItems: 'center', marginTop: 10, marginBottom: 18 }}>
<View
style={{
width: 64, height: 64, borderRadius: 32,
backgroundColor: 'rgba(59,165,93,0.15)',
alignItems: 'center', justifyContent: 'center',
marginBottom: 10,
}}
>
<Ionicons name="checkmark" size={32} color="#3ba55d" />
</View>
<Text style={{ color: '#ffffff', fontSize: 20, fontWeight: '800' }}>
Welcome aboard
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 6, textAlign: 'center' }}>
Keys have been generated and stored securely.
</Text>
</View>
{/* Address */}
<KeyCard
title="Your address (Ed25519)"
value={keyFile.pub_key}
copied={copied === 'address'}
onCopy={() => copy(keyFile.pub_key, 'address')}
/>
{/* X25519 */}
<View style={{ height: 10 }} />
<KeyCard
title="Encryption key (X25519)"
value={keyFile.x25519_pub}
copied={copied === 'x25519'}
onCopy={() => copy(keyFile.x25519_pub, 'x25519')}
/>
{/* Backup */}
<View
style={{
marginTop: 16,
padding: 14,
borderRadius: 14,
backgroundColor: 'rgba(240,179,90,0.08)',
borderWidth: 1, borderColor: 'rgba(240,179,90,0.25)',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 6 }}>
<Ionicons name="lock-closed-outline" size={14} color="#f0b35a" />
<Text style={{ color: '#f0b35a', fontSize: 13, fontWeight: '700', marginLeft: 6 }}>
Backup your key file
</Text>
</View>
<Text style={{ color: '#d0a26a', fontSize: 12, lineHeight: 17, marginBottom: 10 }}>
Export it now and store somewhere safe password managers, cold
storage, printed paper. If you lose it, you lose the account.
</Text>
<Pressable
onPress={exportKey}
style={({ pressed }) => ({
alignItems: 'center', justifyContent: 'center',
paddingVertical: 10, borderRadius: 999,
backgroundColor: pressed ? '#2a1f0f' : '#1a1409',
borderWidth: 1, borderColor: 'rgba(240,179,90,0.35)',
})}
>
<Text style={{ color: '#f0b35a', fontWeight: '700', fontSize: 14 }}>
Export key.json
</Text>
</Pressable>
</View>
{/* Continue */}
<Pressable
onPress={() => router.replace('/(app)/chats' as never)}
style={({ pressed }) => ({
alignItems: 'center', justifyContent: 'center',
paddingVertical: 14, borderRadius: 999, marginTop: 20,
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}>
Open messenger
</Text>
</Pressable>
</ScrollView>
</View>
);
}
function KeyCard({
title, value, copied, onCopy,
}: {
title: string;
value: string;
copied: boolean;
onCopy: () => void;
}) {
return (
<View
style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
padding: 14,
}}
>
<Text
style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8,
}}
>
{title}
</Text>
<Text style={{ color: '#ffffff', fontSize: 12, fontFamily: 'monospace', lineHeight: 18 }}>
{value}
</Text>
<Pressable
onPress={onCopy}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 9, borderRadius: 999,
marginTop: 10,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Ionicons
name={copied ? 'checkmark' : 'copy-outline'}
size={14}
color={copied ? '#3ba55d' : '#ffffff'}
/>
<Text
style={{
color: copied ? '#3ba55d' : '#ffffff',
fontSize: 13, fontWeight: '600', marginLeft: 6,
}}
>
{copied ? 'Copied' : 'Copy'}
</Text>
</Pressable>
</View>
);
}

View File

@@ -0,0 +1,231 @@
/**
* Import existing key — dark minimalist.
* Два пути:
* 1. Paste JSON напрямую в textarea.
* 2. Pick файл .json через DocumentPicker.
*/
import React, { useState } from 'react';
import {
View, Text, ScrollView, TextInput, Alert, Pressable, ActivityIndicator,
} from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as DocumentPicker from 'expo-document-picker';
import * as Clipboard from 'expo-clipboard';
import { saveKeyFile } from '@/lib/storage';
import { useStore } from '@/lib/store';
import { safeBack } from '@/lib/utils';
import type { KeyFile } from '@/lib/types';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
type Tab = 'paste' | 'file';
const REQUIRED_FIELDS: (keyof KeyFile)[] = ['pub_key', 'priv_key', 'x25519_pub', 'x25519_priv'];
function validateKeyFile(raw: string): KeyFile {
let parsed: any;
try { parsed = JSON.parse(raw.trim()); }
catch { throw new Error('Invalid JSON — check that you copied the full key file.'); }
for (const field of REQUIRED_FIELDS) {
if (!parsed[field] || typeof parsed[field] !== 'string') {
throw new Error(`Missing or invalid field: "${field}"`);
}
if (!/^[0-9a-f]+$/i.test(parsed[field])) {
throw new Error(`Field "${field}" must be a hex string.`);
}
}
return parsed as KeyFile;
}
export default function ImportKeyScreen() {
const insets = useSafeAreaInsets();
const setKeyFile = useStore(s => s.setKeyFile);
const [tab, setTab] = useState<Tab>('paste');
const [jsonText, setJsonText] = useState('');
const [fileName, setFileName] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function applyKey(kf: KeyFile) {
setLoading(true); setError(null);
try {
await saveKeyFile(kf);
setKeyFile(kf);
router.replace('/(app)/chats' as never);
} catch (e: any) {
setError(e?.message ?? 'Import failed');
} finally {
setLoading(false);
}
}
async function handlePasteImport() {
setError(null);
const text = jsonText.trim();
if (!text) {
const clip = await Clipboard.getStringAsync();
if (clip) setJsonText(clip);
return;
}
try { await applyKey(validateKeyFile(text)); }
catch (e: any) { setError(e?.message ?? 'Import failed'); }
}
async function pickFile() {
setError(null);
try {
const result = await DocumentPicker.getDocumentAsync({
type: ['application/json', 'text/plain', '*/*'],
copyToCacheDirectory: true,
});
if (result.canceled) return;
const asset = result.assets[0];
setFileName(asset.name);
const response = await fetch(asset.uri);
const raw = await response.text();
await applyKey(validateKeyFile(raw));
} catch (e: any) {
setError(e?.message ?? 'Import failed');
}
}
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Import key"
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: 40 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 14 }}>
Restore your account from a previously exported{' '}
<Text style={{ color: '#ffffff', fontWeight: '600' }}>dchain_key.json</Text>.
</Text>
{/* Tabs */}
<View
style={{
flexDirection: 'row',
padding: 4,
borderRadius: 999,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
marginBottom: 14,
}}
>
{(['paste', 'file'] as Tab[]).map(t => (
<Pressable
key={t}
onPress={() => setTab(t)}
style={{
flex: 1,
alignItems: 'center',
paddingVertical: 8,
borderRadius: 999,
backgroundColor: tab === t ? '#1d9bf0' : 'transparent',
}}
>
<Text
style={{
color: tab === t ? '#ffffff' : '#8b8b8b',
fontWeight: '700', fontSize: 13,
}}
>
{t === 'paste' ? 'Paste JSON' : 'Pick file'}
</Text>
</Pressable>
))}
</View>
{tab === 'paste' ? (
<>
<TextInput
value={jsonText}
onChangeText={setJsonText}
placeholder='{"pub_key":"…","priv_key":"…","x25519_pub":"…","x25519_priv":"…"}'
placeholderTextColor="#5a5a5a"
multiline
autoCapitalize="none"
autoCorrect={false}
style={{
color: '#ffffff',
fontSize: 12,
fontFamily: 'monospace',
backgroundColor: '#0a0a0a',
borderRadius: 12,
padding: 12,
minHeight: 180,
textAlignVertical: 'top',
borderWidth: 1, borderColor: '#1f1f1f',
}}
/>
<Pressable
onPress={handlePasteImport}
disabled={loading}
style={({ pressed }) => ({
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
paddingVertical: 12, borderRadius: 999, marginTop: 12,
backgroundColor: loading ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{loading ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
{jsonText.trim() ? 'Import key' : 'Paste from clipboard'}
</Text>
)}
</Pressable>
</>
) : (
<>
<Pressable
onPress={pickFile}
disabled={loading}
style={({ pressed }) => ({
alignItems: 'center', justifyContent: 'center',
paddingVertical: 40, borderRadius: 14,
backgroundColor: pressed ? '#111111' : '#0a0a0a',
borderWidth: 1, borderStyle: 'dashed', borderColor: '#1f1f1f',
})}
>
<Ionicons name="document-outline" size={32} color="#8b8b8b" />
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700', marginTop: 10 }}>
{fileName ?? 'Tap to pick key.json'}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 4 }}>
Will auto-import on selection
</Text>
</Pressable>
{loading && (
<View style={{ alignItems: 'center', marginTop: 12 }}>
<ActivityIndicator color="#1d9bf0" />
</View>
)}
</>
)}
{error && (
<View
style={{
marginTop: 14,
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(244,33,46,0.08)',
borderWidth: 1, borderColor: 'rgba(244,33,46,0.25)',
}}
>
<Text style={{ color: '#f4212e', fontSize: 13 }}>{error}</Text>
</View>
)}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,59 @@
import '../global.css';
import React, { useEffect } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
// GestureHandlerRootView обязателен для работы gesture-handler'а
// на всех страницах: Pan/LongPress/Tap жестах внутри чатов.
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { loadKeyFile, loadSettings } from '@/lib/storage';
import { setNodeUrl } from '@/lib/api';
import { useStore } from '@/lib/store';
export default function RootLayout() {
const setKeyFile = useStore(s => s.setKeyFile);
const setSettings = useStore(s => s.setSettings);
const booted = useStore(s => s.booted);
const setBooted = useStore(s => s.setBooted);
// Bootstrap: load key + settings from storage синхронно до первого
// render'а экранов. Пока `booted=false` мы рендерим чёрный экран —
// это убирает "мелькание" welcome'а при старте, когда ключи уже есть
// в AsyncStorage, но ещё не успели загрузиться в store.
useEffect(() => {
(async () => {
try {
const [kf, settings] = await Promise.all([loadKeyFile(), loadSettings()]);
if (kf) setKeyFile(kf);
setSettings(settings);
setNodeUrl(settings.nodeUrl);
} finally {
setBooted(true);
}
})();
}, []);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<View className="flex-1 bg-background">
<StatusBar style="light" />
{booted ? (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#000000' },
animation: 'slide_from_right',
}}
/>
) : (
// Пустой чёрный экран пока bootstrap идёт — без flicker'а.
<View style={{ flex: 1, backgroundColor: '#000000' }} />
)}
</View>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}

519
client-app/app/index.tsx Normal file
View File

@@ -0,0 +1,519 @@
/**
* Onboarding — 3-слайдовый pager перед auth-экранами.
*
* Slide 1 — "Why DChain": value-proposition, 3 пункта с иконками.
* Slide 2 — "How it works": выбор релей-ноды (public paid vs свой node),
* ссылка на Gitea, + node URL input с live ping.
* Slide 3 — "Your keys": кнопки Create / Import.
*
* Если `keyFile` в store уже есть (bootstrap из RootLayout загрузил) —
* делаем <Redirect /> в (app), чтобы пользователь не видел вообще никакого
* мелькания onboarding'а. До загрузки `booted === false` root показывает
* чёрный экран.
*/
import React, { useEffect, useState, useCallback, useRef } from 'react';
import {
View, Text, TextInput, Pressable, ScrollView,
Alert, ActivityIndicator, Linking, Dimensions,
useWindowDimensions,
} from 'react-native';
import { router, Redirect } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { saveSettings } from '@/lib/storage';
import { setNodeUrl, getNetStats } from '@/lib/api';
const { width: SCREEN_W } = Dimensions.get('window');
const GITEA_URL = 'https://git.vsecoder.vodka/vsecoder/dchain';
export default function WelcomeScreen() {
const insets = useSafeAreaInsets();
const { height: SCREEN_H } = useWindowDimensions();
const keyFile = useStore(s => s.keyFile);
const booted = useStore(s => s.booted);
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
const scrollRef = useRef<ScrollView>(null);
const [page, setPage] = useState(0);
const [nodeInput, setNodeInput] = useState('');
const [scanning, setScanning] = useState(false);
const [checking, setChecking] = useState(false);
const [nodeOk, setNodeOk] = useState<boolean | null>(null);
const [permission, requestPermission] = useCameraPermissions();
useEffect(() => { setNodeInput(settings.nodeUrl); }, [settings.nodeUrl]);
// ВСЕ hooks должны быть объявлены ДО любого early-return, иначе
// React на следующем render'е посчитает разное число hooks и выкинет
// "Rendered fewer hooks than expected". useCallback ниже — тоже hook.
const applyNode = useCallback(async (url: string) => {
const clean = url.trim().replace(/\/$/, '');
if (!clean) return;
setChecking(true);
setNodeOk(null);
setNodeUrl(clean);
try {
await getNetStats();
setNodeOk(true);
const next = { ...settings, nodeUrl: clean };
setSettings(next);
await saveSettings(next);
} catch {
setNodeOk(false);
} finally {
setChecking(false);
}
}, [settings, setSettings]);
const onQrScanned = useCallback(({ data }: { data: string }) => {
setScanning(false);
let url = data.trim();
try { const p = JSON.parse(url); if (p.nodeUrl) url = p.nodeUrl; } catch {}
setNodeInput(url);
applyNode(url);
}, [applyNode]);
// Bootstrap ещё не закончился — ничего не рендерим, RootLayout покажет
// чёрный экран (single source of truth для splash-state'а).
if (!booted) return null;
// Ключи уже загружены — сразу в main app, без мелькания onboarding'а.
if (keyFile) return <Redirect href={'/(app)/chats' as never} />;
const openScanner = async () => {
if (!permission?.granted) {
const { granted } = await requestPermission();
if (!granted) {
Alert.alert('Camera permission required', 'Allow camera access to scan QR codes.');
return;
}
}
setScanning(true);
};
const goToPage = (p: number) => {
scrollRef.current?.scrollTo({ x: p * SCREEN_W, animated: true });
setPage(p);
};
if (scanning) {
return (
<View style={{ flex: 1, backgroundColor: '#000' }}>
<CameraView
style={{ flex: 1 }}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={onQrScanned}
/>
<View style={{
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
alignItems: 'center', justifyContent: 'center',
}}>
<View style={{ width: 240, height: 240, borderWidth: 2, borderColor: '#fff', borderRadius: 16 }} />
<Text style={{ color: '#fff', marginTop: 20, opacity: 0.8 }}>
Point at a DChain node QR code
</Text>
</View>
<Pressable
onPress={() => setScanning(false)}
style={{
position: 'absolute', top: 56, left: 16,
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20,
paddingHorizontal: 16, paddingVertical: 8,
}}
>
<Text style={{ color: '#fff', fontSize: 16 }}> Cancel</Text>
</Pressable>
</View>
);
}
const statusColor = nodeOk === true ? '#3ba55d' : nodeOk === false ? '#f4212e' : '#8b8b8b';
// Высота footer'а (dots + inset) — резервируем под неё снизу каждого
// слайда, чтобы CTA-кнопки оказывались прямо над индикатором страниц,
// а не залезали под него.
const FOOTER_H = Math.max(insets.bottom, 20) + 8 + 12 + 7; // = padBottom + padTop + dot
const PAGE_H = SCREEN_H - FOOTER_H;
return (
<View style={{ flex: 1, backgroundColor: '#000000' }}>
<ScrollView
ref={scrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={e => {
const p = Math.round(e.nativeEvent.contentOffset.x / SCREEN_W);
setPage(p);
}}
style={{ flex: 1 }}
keyboardShouldPersistTaps="handled"
>
{/* ───────── Slide 1: Why DChain ───────── */}
<View style={{ width: SCREEN_W, height: PAGE_H }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingHorizontal: 28,
paddingTop: insets.top + 60,
paddingBottom: 16,
}}
showsVerticalScrollIndicator={false}
>
<View style={{ alignItems: 'center', marginBottom: 36 }}>
<View
style={{
width: 88, height: 88, borderRadius: 24,
backgroundColor: '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
marginBottom: 14,
}}
>
<Ionicons name="chatbubbles" size={44} color="#ffffff" />
</View>
<Text style={{ color: '#ffffff', fontSize: 30, fontWeight: '800', letterSpacing: -0.8 }}>
DChain
</Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 14, lineHeight: 20, marginTop: 6 }}>
A messenger that belongs to you.
</Text>
</View>
<FeatureRow
icon="lock-closed"
title="End-to-end encryption"
text="X25519 + NaCl on every message. Not even the relay node can read your conversations."
/>
<FeatureRow
icon="key"
title="Your keys, your account"
text="No phone, email, or server passwords. Keys never leave your device."
/>
<FeatureRow
icon="git-network"
title="Decentralised"
text="Anyone can run a node. No single point of failure or censorship."
/>
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end',
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTAPrimary label="Continue" onPress={() => goToPage(1)} />
</View>
</View>
{/* ───────── Slide 2: How it works ───────── */}
<View style={{ width: SCREEN_W, height: PAGE_H }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingHorizontal: 28,
paddingTop: insets.top + 40,
paddingBottom: 16,
}}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5 }}>
How it works
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, marginBottom: 22 }}>
Messages travel through a relay node in encrypted form.
Pick a public one or run your own.
</Text>
<OptionCard
icon="globe"
title="Public node"
text="Quick and easy — community-hosted relay, small fee per delivered message."
/>
<OptionCard
icon="hardware-chip"
title="Self-hosted"
text="Maximum control. Source is open — spin up your own in five minutes."
/>
<Text style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2, marginTop: 20, marginBottom: 8,
}}>
Node URL
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View
style={{
flex: 1, flexDirection: 'row', alignItems: 'center',
backgroundColor: '#0a0a0a', borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 12, paddingHorizontal: 12, gap: 8,
}}
>
<View style={{ width: 7, height: 7, borderRadius: 3.5, backgroundColor: statusColor }} />
<TextInput
value={nodeInput}
onChangeText={t => { setNodeInput(t); setNodeOk(null); }}
onEndEditing={() => applyNode(nodeInput)}
onSubmitEditing={() => applyNode(nodeInput)}
placeholder="http://192.168.1.10:8080"
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="done"
style={{ flex: 1, color: '#ffffff', fontSize: 14, paddingVertical: 12 }}
/>
{checking
? <ActivityIndicator size="small" color="#8b8b8b" />
: nodeOk === true
? <Ionicons name="checkmark" size={16} color="#3ba55d" />
: nodeOk === false
? <Ionicons name="close" size={16} color="#f4212e" />
: null}
</View>
<Pressable
onPress={openScanner}
style={({ pressed }) => ({
width: 48, alignItems: 'center', justifyContent: 'center',
backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 12,
})}
>
<Ionicons name="qr-code-outline" size={22} color="#ffffff" />
</Pressable>
</View>
{nodeOk === false && (
<Text style={{ color: '#f4212e', fontSize: 12, marginTop: 6 }}>
Cannot reach node check URL and that the node is running
</Text>
)}
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTASecondary
label="Source"
icon="logo-github"
onPress={() => Linking.openURL(GITEA_URL).catch(() => {})}
/>
<CTAPrimary label="Continue" onPress={() => goToPage(2)} />
</View>
</View>
{/* ───────── Slide 3: Your keys ───────── */}
<View style={{ width: SCREEN_W, height: PAGE_H }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingHorizontal: 28,
paddingTop: insets.top + 60,
paddingBottom: 16,
}}
showsVerticalScrollIndicator={false}
>
<View style={{ alignItems: 'center', marginBottom: 36 }}>
<View
style={{
width: 88, height: 88, borderRadius: 24,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}
>
<Ionicons name="key" size={44} color="#1d9bf0" />
</View>
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5, textAlign: 'center' }}>
Your account
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, textAlign: 'center', maxWidth: 280 }}>
Generate a fresh keypair or import an existing one.
Keys stay on this device only.
</Text>
</View>
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTASecondary
label="Import"
onPress={() => router.push('/(auth)/import' as never)}
/>
<CTAPrimary
label="Create account"
onPress={() => router.push('/(auth)/create' as never)}
/>
</View>
</View>
</ScrollView>
{/* Footer: dots-only pager indicator. CTA-кнопки теперь inline
на каждом слайде, чтобы выглядели как полноценные кнопки, а не
мелкий "Далее" в углу. */}
<View style={{
paddingHorizontal: 28,
paddingBottom: Math.max(insets.bottom, 20) + 8,
paddingTop: 12,
flexDirection: 'row',
alignItems: 'center', justifyContent: 'center',
gap: 6,
}}>
{[0, 1, 2].map(i => (
<Pressable
key={i}
onPress={() => goToPage(i)}
hitSlop={8}
style={{
width: page === i ? 22 : 7,
height: 7,
borderRadius: 3.5,
backgroundColor: page === i ? '#1d9bf0' : '#2a2a2a',
}}
/>
))}
</View>
</View>
);
}
// ───────── helper components ─────────
/**
* Primary CTA button — синий pill. Натуральная ширина (hugs content),
* `numberOfLines={1}` на лейбле чтобы текст не переносился. Фон
* применяется через inner View, а не напрямую на Pressable — это
* обходит редкие RN-баги, когда backgroundColor на Pressable не
* рендерится пока кнопка не нажата.
*/
function CTAPrimary({ label, onPress }: { label: string; onPress: () => void }) {
return (
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.85 : 1 })}>
<View
style={{
height: 46,
paddingHorizontal: 22,
borderRadius: 999,
backgroundColor: '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
}}
>
<Text
numberOfLines={1}
style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}
>
{label}
</Text>
</View>
</Pressable>
);
}
/** Secondary CTA — тёмный pill с border'ом, optional icon слева. */
function CTASecondary({
label, icon, onPress,
}: {
label: string;
icon?: React.ComponentProps<typeof Ionicons>['name'];
onPress: () => void;
}) {
return (
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}>
<View
style={{
height: 46,
paddingHorizontal: 18,
borderRadius: 999,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
gap: 6,
}}
>
{icon && <Ionicons name={icon} size={15} color="#ffffff" />}
<Text
numberOfLines={1}
style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}
>
{label}
</Text>
</View>
</Pressable>
);
}
function FeatureRow({
icon, title, text,
}: { icon: React.ComponentProps<typeof Ionicons>['name']; title: string; text: string }) {
return (
<View style={{ flexDirection: 'row', marginBottom: 20, gap: 14 }}>
<View
style={{
width: 40, height: 40, borderRadius: 12,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name={icon} size={20} color="#1d9bf0" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700', marginBottom: 3 }}>
{title}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19 }}>
{text}
</Text>
</View>
</View>
);
}
function OptionCard({
icon, title, text, actionLabel, onAction,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
title: string;
text: string;
actionLabel?: string;
onAction?: () => void;
}) {
return (
<View
style={{
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 14, padding: 14, marginBottom: 10,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<Ionicons name={icon} size={18} color="#1d9bf0" />
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}>
{title}
</Text>
</View>
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19 }}>
{text}
</Text>
{actionLabel && onAction && (
<Pressable onPress={onAction} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, marginTop: 8 })}>
<Text style={{ color: '#1d9bf0', fontSize: 13, fontWeight: '600' }}>
{actionLabel}
</Text>
</Pressable>
)}
</View>
);
}

View File

@@ -0,0 +1,12 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
plugins: [
'react-native-reanimated/plugin', // must be last
],
};
};

View File

@@ -0,0 +1,35 @@
/**
* AnimatedSlot — renders the (app) group as a native <Stack>.
*
* Why Stack instead of Slot: Slot is stack-less. When a child route
* (e.g. profile/[address]) pushes another child, Slot swaps content
* with no history, so router.back() falls all the way through to the
* URL root instead of returning to the caller (e.g. /chats/xyz →
* /profile/abc → back should go to /chats/xyz, not to /chats).
*
* Tab switching stays flat because NavBar uses router.replace, which
* maps to navigation.replace on the Stack → no history accumulation.
*
* animation: 'none' on tab roots keeps tab-swap instant (matches the
* prior Slot look). Sub-routes (profile/*, compose, feed/*, chats/[id])
* inherit slide_from_right from their own nested _layout.tsx Stacks,
* which is where the push animation happens.
*
* This file is named AnimatedSlot for git-history continuity — the
* Animated.Value + translateX slide was removed earlier (got stuck at
* ±width when interrupted by re-render cascades).
*/
import React from 'react';
import { Stack } from 'expo-router';
export function AnimatedSlot() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#000000' },
animation: 'none',
}}
/>
);
}

View File

@@ -0,0 +1,86 @@
/**
* Avatar — круглая заглушка с инициалом, опционально online-пип.
* Нет зависимостей от асинхронных источников (картинок) — для messenger-тайла
* важнее мгновенный рендер, чем фотография. Если в будущем будут фото,
* расширяем здесь.
*/
import React from 'react';
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export interface AvatarProps {
/** Имя / @username — берём первый символ для placeholder. */
name?: string;
/** Адрес (hex pubkey) — fallback для тех у кого нет имени. */
address?: string;
/** Общий размер в px. По умолчанию 48 (tile size). */
size?: number;
/** Цвет пипа справа-снизу. undefined = без пипа. */
dotColor?: string;
/** Класс для обёртки (position: relative кадр). */
className?: string;
/**
* Saved Messages variant — blue circle with a bookmark glyph, Telegram-style.
* When set, `name`/`address` are ignored for the visual.
*/
saved?: boolean;
}
/** Простое хэширование имени → один из 6 оттенков серого для разнообразия. */
function pickBg(seed: string): string {
const shades = ['#1a1a1a', '#222222', '#2a2a2a', '#151515', '#1c1c1c', '#1f1f1f'];
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) & 0xffff;
return shades[h % shades.length];
}
export function Avatar({ name, address, size = 48, dotColor, className, saved }: AvatarProps) {
const seed = (name ?? address ?? '?').replace(/^@/, '');
const initial = seed.charAt(0).toUpperCase() || '?';
const bg = saved ? '#1d9bf0' : pickBg(seed);
return (
<View className={className} style={{ width: size, height: size, position: 'relative' }}>
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: bg,
alignItems: 'center',
justifyContent: 'center',
}}
>
{saved ? (
<Ionicons name="bookmark" size={size * 0.5} color="#ffffff" />
) : (
<Text
style={{
color: '#d0d0d0',
fontSize: size * 0.4,
fontWeight: '600',
includeFontPadding: false,
}}
>
{initial}
</Text>
)}
</View>
{dotColor && (
<View
style={{
position: 'absolute',
right: 0,
bottom: 0,
width: size * 0.28,
height: size * 0.28,
borderRadius: size * 0.14,
backgroundColor: dotColor,
borderWidth: 2,
borderColor: '#000',
}}
/>
)}
</View>
);
}

View File

@@ -0,0 +1,179 @@
/**
* ChatTile — одна строка в списке чатов на главной (Messages screen).
*
* Layout:
* [avatar 44] [name (+verified) (+kind-icon)] [time]
* [last-msg preview] [unread pill]
*
* Kind-icon — мегафон для channel, 👥 для group, ничего для direct.
* Verified checkmark — если у контакта есть @username.
* Online-dot на аватарке — только для direct-чатов с x25519 ключом.
*/
import React from 'react';
import { View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { Contact, Message } from '@/lib/types';
import { Avatar } from '@/components/Avatar';
import { formatWhen } from '@/lib/dates';
import { useStore } from '@/lib/store';
function previewText(s: string, max = 50): string {
return s.length <= max ? s : s.slice(0, max).trimEnd() + '…';
}
/**
* Текстовое превью последнего сообщения. Если у сообщения нет текста
* (только вложение) — возвращаем маркер с иконкой названием типа:
* "🖼 Photo" / "🎬 Video" / "🎙 Voice" / "📎 File"
* Если текст есть — он используется; если есть и то и другое, префикс
* добавляется перед текстом.
*/
function lastPreview(m: Message): string {
const emojiByKind = {
image: '🖼', video: '🎬', voice: '🎙', file: '📎',
} as const;
const labelByKind = {
image: 'Photo', video: 'Video', voice: 'Voice message', file: 'File',
} as const;
const text = m.text.trim();
if (m.attachment) {
const prefix = `${emojiByKind[m.attachment.kind]} ${labelByKind[m.attachment.kind]}`;
return text ? `${prefix} ${previewText(text, 40)}` : prefix;
}
return previewText(text);
}
function shortAddr(a: string, n = 5): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
function displayName(c: Contact): string {
return c.username ? `@${c.username}` : c.alias ?? shortAddr(c.address);
}
export interface ChatTileProps {
contact: Contact;
lastMessage: Message | null;
onPress: () => void;
/** Render as the Saved Messages tile (blue bookmark avatar, fixed name). */
saved?: boolean;
}
export function ChatTile({ contact: c, lastMessage, onPress, saved }: ChatTileProps) {
const name = saved ? 'Saved Messages' : displayName(c);
const last = lastMessage;
// Визуальный маркер типа чата.
const kindIcon: React.ComponentProps<typeof Ionicons>['name'] | null =
c.kind === 'group' ? 'people' : null;
// Unread берётся из runtime-store'а (инкрементится в useGlobalInbox,
// обнуляется при открытии чата). Fallback на c.unread для legacy seed.
const storeUnread = useStore(s => s.unreadByContact[c.address] ?? 0);
const unreadCount = storeUnread || (c.unread ?? 0);
const unread = unreadCount > 0 ? unreadCount : null;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
backgroundColor: pressed ? '#0a0a0a' : 'transparent',
})}
>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: 14,
paddingVertical: 12,
}}
>
<Avatar
name={name}
address={c.address}
size={44}
saved={saved}
dotColor={!saved && c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined}
/>
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
{/* Первая строка: [kind-icon] name [verified] ··· time */}
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{kindIcon && (
<Ionicons
name={kindIcon}
size={12}
color="#8b8b8b"
style={{ marginRight: 5 }}
/>
)}
<Text
numberOfLines={1}
style={{ color: '#ffffff', fontWeight: '700', fontSize: 15, flex: 1 }}
>
{name}
</Text>
{c.username && (
<Ionicons
name="checkmark-circle"
size={14}
color="#1d9bf0"
style={{ marginLeft: 4, marginRight: 2 }}
/>
)}
{last && (
<Text style={{ color: '#8b8b8b', fontSize: 12, marginLeft: 6 }}>
{formatWhen(last.timestamp)}
</Text>
)}
</View>
{/* Вторая строка: [✓✓ mine-seen] preview ··· [unread] */}
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
{last?.mine && (
<Ionicons
name="checkmark-done-outline"
size={13}
color="#8b8b8b"
style={{ marginRight: 4 }}
/>
)}
<Text
numberOfLines={1}
style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}
>
{last
? lastPreview(last)
: saved
? 'Your personal notes & files'
: c.x25519Pub
? 'Tap to start encrypted chat'
: 'Waiting for identity…'}
</Text>
{unread !== null && (
<View
style={{
marginLeft: 8,
minWidth: 18,
height: 18,
paddingHorizontal: 5,
borderRadius: 9,
backgroundColor: '#1d9bf0',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '700' }}>
{unread > 99 ? '99+' : unread}
</Text>
</View>
)}
</View>
</View>
</View>
</Pressable>
);
}

View File

@@ -0,0 +1,329 @@
/**
* Composer — плавающий блок ввода сообщения, прибит к низу.
*
* Композиция:
* 1. Опциональный баннер (edit / reply) сверху.
* 2. Опциональная pending-attachment preview.
* 3. Либо:
* - обычный input-bubble с `[+] [textarea] [↑/🎤/⭕]`
* - inline VoiceRecorder когда идёт запись голосового
*
* Send-action зависит от состояния:
* - есть текст/attachment → ↑ (send)
* - пусто → показываем две иконки: 🎤 (start voice) + ⭕ (open video circle)
*
* API:
* mode, onCancelMode
* text, onChangeText
* onSend, sending
* onAttach — tap на + (AttachmentMenu)
* attachment, onClearAttach
* onFinishVoice — готовая voice-attachment (из VoiceRecorder)
* onStartVideoCircle — tap на ⭕, родитель открывает VideoCircleRecorder
* placeholder
*/
import React, { useRef, useState } from 'react';
import { View, Text, TextInput, Pressable, ActivityIndicator, Image } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { Attachment } from '@/lib/types';
import { VoiceRecorder } from '@/components/chat/VoiceRecorder';
export type ComposerMode =
| { kind: 'new' }
| { kind: 'edit'; text: string }
| { kind: 'reply'; msgId: string; author: string; preview: string };
export interface ComposerProps {
mode: ComposerMode;
onCancelMode?: () => void;
text: string;
onChangeText: (t: string) => void;
onSend: () => void;
sending?: boolean;
onAttach?: () => void;
attachment?: Attachment | null;
onClearAttach?: () => void;
/** Voice recording завершена и отправляем сразу (мгновенный flow). */
onFinishVoice?: (att: Attachment) => void;
/** Tap на "⭕" — родитель открывает VideoCircleRecorder. */
onStartVideoCircle?: () => void;
placeholder?: string;
}
const INPUT_MIN_HEIGHT = 24;
const INPUT_MAX_HEIGHT = 72;
export function Composer(props: ComposerProps) {
const {
mode, onCancelMode, text, onChangeText, onSend, sending, onAttach,
attachment, onClearAttach,
onFinishVoice, onStartVideoCircle,
placeholder,
} = props;
const inputRef = useRef<TextInput>(null);
const [recordingVoice, setRecordingVoice] = useState(false);
const hasContent = !!text.trim() || !!attachment;
const canSend = hasContent && !sending;
const inEdit = mode.kind === 'edit';
const inReply = mode.kind === 'reply';
const focusInput = () => inputRef.current?.focus();
return (
<View style={{ paddingHorizontal: 8, paddingTop: 6, paddingBottom: 4, gap: 6 }}>
{/* ── Banner: edit / reply ── */}
{(inEdit || inReply) && !recordingVoice && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: '#111111',
borderRadius: 18,
paddingHorizontal: 14,
paddingVertical: 10,
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
<Ionicons
name={inEdit ? 'create-outline' : 'arrow-undo-outline'}
size={16}
color="#ffffff"
/>
<View style={{ flex: 1, minWidth: 0 }}>
{inEdit && (
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}>
Edit message
</Text>
)}
{inReply && (
<>
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '700' }} numberOfLines={1}>
Reply to {(mode as { author: string }).author}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12 }} numberOfLines={1}>
{(mode as { preview: string }).preview}
</Text>
</>
)}
</View>
<Pressable
onPress={onCancelMode}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
>
<Ionicons name="close" size={20} color="#8b8b8b" />
</Pressable>
</View>
)}
{/* ── Pending attachment preview ── */}
{attachment && !recordingVoice && (
<AttachmentChip attachment={attachment} onClear={onClearAttach} />
)}
{/* ── Voice recording (inline) ИЛИ обычный input ── */}
{recordingVoice ? (
<VoiceRecorder
onFinish={(att) => {
setRecordingVoice(false);
onFinishVoice?.(att);
}}
onCancel={() => setRecordingVoice(false)}
/>
) : (
<Pressable onPress={focusInput}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#111111',
borderRadius: 22,
borderWidth: 1,
borderColor: '#1f1f1f',
paddingLeft: 4,
paddingRight: 8,
paddingVertical: 6,
gap: 4,
}}
>
{/* + attach — всегда, кроме edit */}
{onAttach && !inEdit && (
<Pressable
onPress={(e) => { e.stopPropagation?.(); onAttach(); }}
hitSlop={6}
style={({ pressed }) => ({
width: 32, height: 32, borderRadius: 16,
alignItems: 'center', justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
})}
>
<Ionicons name="add" size={22} color="#ffffff" />
</Pressable>
)}
<TextInput
ref={inputRef}
value={text}
onChangeText={onChangeText}
placeholder={placeholder ?? 'Message'}
placeholderTextColor="#5a5a5a"
multiline
maxLength={2000}
style={{
flex: 1,
color: '#ffffff',
fontSize: 15,
lineHeight: 20,
minHeight: INPUT_MIN_HEIGHT,
maxHeight: INPUT_MAX_HEIGHT,
paddingTop: 6,
paddingBottom: 6,
paddingLeft: onAttach && !inEdit ? 6 : 10,
paddingRight: 6,
}}
/>
{/* Правая часть: send ИЛИ [mic + video-circle] */}
{canSend ? (
<Pressable
onPress={(e) => { e.stopPropagation?.(); onSend(); }}
style={({ pressed }) => ({
width: 32, height: 32, borderRadius: 16,
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
})}
>
{sending ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Ionicons name="arrow-up" size={18} color="#ffffff" />
)}
</Pressable>
) : !inEdit && (onFinishVoice || onStartVideoCircle) ? (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{onStartVideoCircle && (
<Pressable
onPress={(e) => { e.stopPropagation?.(); onStartVideoCircle(); }}
hitSlop={6}
style={({ pressed }) => ({
width: 32, height: 32, borderRadius: 16,
alignItems: 'center', justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
})}
>
<Ionicons name="videocam-outline" size={20} color="#ffffff" />
</Pressable>
)}
{onFinishVoice && (
<Pressable
onPress={(e) => { e.stopPropagation?.(); setRecordingVoice(true); }}
hitSlop={6}
style={({ pressed }) => ({
width: 32, height: 32, borderRadius: 16,
alignItems: 'center', justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
})}
>
<Ionicons name="mic-outline" size={20} color="#ffffff" />
</Pressable>
)}
</View>
) : null}
</View>
</Pressable>
)}
</View>
);
}
// ─── Attachment chip — preview текущего pending attachment'а ────────
function AttachmentChip({
attachment, onClear,
}: {
attachment: Attachment;
onClear?: () => void;
}) {
const icon: React.ComponentProps<typeof Ionicons>['name'] =
attachment.kind === 'image' ? 'image-outline' :
attachment.kind === 'video' ? 'videocam-outline' :
attachment.kind === 'voice' ? 'mic-outline' :
'document-outline';
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: '#111111',
borderRadius: 14,
paddingHorizontal: 10,
paddingVertical: 8,
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
{attachment.kind === 'image' || attachment.kind === 'video' ? (
<Image
source={{ uri: attachment.uri }}
style={{
width: 40, height: 40,
borderRadius: attachment.circle ? 20 : 8,
backgroundColor: '#0a0a0a',
}}
/>
) : (
<View
style={{
width: 40, height: 40, borderRadius: 8,
backgroundColor: '#1a1a1a',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name={icon} size={20} color="#ffffff" />
</View>
)}
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '600' }} numberOfLines={1}>
{attachment.name ?? attachmentLabel(attachment)}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 11 }} numberOfLines={1}>
{attachment.kind.toUpperCase()}
{attachment.circle ? ' · circle' : ''}
{attachment.size ? ` · ${(attachment.size / 1024).toFixed(0)} KB` : ''}
{attachment.duration ? ` · ${attachment.duration}s` : ''}
</Text>
</View>
<Pressable
onPress={onClear}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, padding: 4 })}
>
<Ionicons name="close" size={18} color="#8b8b8b" />
</Pressable>
</View>
);
}
function attachmentLabel(a: Attachment): string {
switch (a.kind) {
case 'image': return 'Photo';
case 'video': return a.circle ? 'Video message' : 'Video';
case 'voice': return 'Voice message';
case 'file': return 'File';
}
}

View File

@@ -0,0 +1,76 @@
/**
* Header — единая шапка экрана: [left slot] [title centered] [right slot].
*
* Правила выравнивания:
* - left/right принимают натуральную ширину контента (обычно 1-2
* IconButton'а 36px, или pressable-avatar 32px).
* - title (ReactNode, принимает как string, так и compound — аватар +
* имя вместе) всегда центрирован через flex:1 + alignItems:center.
* Абсолютно не позиционируется, т.к. при слишком широком title'е
* лучше ужать его, чем наложить на кнопки.
*
* `title` может быть строкой (тогда рендерится как Text 17px semibold)
* либо произвольным node'ом — используется в chat detail для
* [avatar][name + typing-subtitle] compound-блока.
*
* `divider` (default true) — тонкая 1px линия снизу; в tab-страницах
* обычно выключена (TabHeader всегда ставит divider=false).
*/
import React, { ReactNode } from 'react';
import { View, Text } from 'react-native';
export interface HeaderProps {
title?: ReactNode;
left?: ReactNode;
right?: ReactNode;
/** Показывать нижнюю тонкую линию-разделитель. По умолчанию true. */
divider?: boolean;
}
export function Header({ title, left, right, divider = true }: HeaderProps) {
return (
<View
style={{
paddingHorizontal: 14,
paddingVertical: 10,
borderBottomWidth: divider ? 1 : 0,
borderBottomColor: '#0f0f0f',
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
minHeight: 44,
}}
>
{/* Left slot — натуральная ширина, минимум 44 чтобы title
визуально центрировался для одно-icon-left + одно-icon-right. */}
<View style={{ minWidth: 44, alignItems: 'flex-start' }}>{left}</View>
{/* Title centered */}
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
{typeof title === 'string' ? (
<Text
numberOfLines={1}
style={{
color: '#ffffff',
fontSize: 17,
fontWeight: '700',
letterSpacing: -0.2,
}}
>
{title}
</Text>
) : title ?? null}
</View>
{/* Right slot — row, натуральная ширина, минимум 44. gap=4
чтобы несколько IconButton'ов не слипались в selection-mode. */}
<View style={{ minWidth: 44, flexDirection: 'row', justifyContent: 'flex-end', gap: 4 }}>
{right}
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,61 @@
/**
* IconButton — круглая touch-target кнопка под Ionicon.
*
* Три варианта:
* - 'ghost' — прозрачная, используется в хедере (шестерёнка, back).
* - 'solid' — акцентный заливной круг, например composer FAB.
* - 'tile' — квадратная заливка 36×36 для небольших action-chip'ов.
*
* Размер управляется props.size (диаметр). Touch-target никогда меньше 40px
* (accessibility), поэтому для size<40 внутренний иконопад растёт.
*/
import React from 'react';
import { Pressable, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
export interface IconButtonProps {
icon: IoniconName;
onPress?: () => void;
variant?: 'ghost' | 'solid' | 'tile';
size?: number; // visual diameter; hit slop ensures accessibility
color?: string; // override icon color
disabled?: boolean;
className?: string;
}
export function IconButton({
icon, onPress, variant = 'ghost', size = 40, color, disabled, className,
}: IconButtonProps) {
const iconSize = Math.round(size * 0.5);
const bg =
variant === 'solid' ? '#1d9bf0' :
variant === 'tile' ? '#1a1a1a' :
'transparent';
const tint =
color ??
(variant === 'solid' ? '#ffffff' :
disabled ? '#3a3a3a' :
'#e7e7e7');
const radius = variant === 'tile' ? 10 : size / 2;
return (
<Pressable
onPress={disabled ? undefined : onPress}
hitSlop={8}
className={className}
style={({ pressed }) => ({
width: size,
height: size,
borderRadius: radius,
backgroundColor: pressed && !disabled ? (variant === 'solid' ? '#1a8cd8' : '#1a1a1a') : bg,
alignItems: 'center',
justifyContent: 'center',
})}
>
<Ionicons name={icon} size={iconSize} color={tint} />
</Pressable>
);
}

View File

@@ -0,0 +1,150 @@
/**
* NavBar — нижний бар на 5 иконок без подписей.
*
* Активный таб:
* - иконка заполненная (Ionicons variant без `-outline`)
* - вокруг иконки subtle highlight-блок (чуть светлее bg), радиус 14
* - текст/бейдж остаются как у inactive
*
* Inactive:
* - outline-иконка, цвет #6b6b6b
* - soon-таб дополнительно dimmed и показывает чип SOON
*
* Роутинг через expo-router `router.replace` — без стекa, каждый tab это
* полная страница без "back" концепции.
*/
import React from 'react';
import { View, Pressable, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter, usePathname } from 'expo-router';
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
interface Item {
key: string;
href: string;
icon: IoniconName;
iconActive: IoniconName;
badge?: number;
soon?: boolean;
}
export interface NavBarProps {
bottomInset?: number;
requestCount?: number;
notifCount?: number;
}
export function NavBar({ bottomInset = 0, requestCount = 0, notifCount = 0 }: NavBarProps) {
const router = useRouter();
const pathname = usePathname();
const items: Item[] = [
{ key: 'home', href: '/(app)/chats', icon: 'home-outline', iconActive: 'home', badge: requestCount },
{ key: 'add', href: '/(app)/new-contact', icon: 'search-outline', iconActive: 'search' },
{ key: 'feed', href: '/(app)/feed', icon: 'newspaper-outline', iconActive: 'newspaper' },
{ key: 'notif', href: '/(app)/requests', icon: 'notifications-outline', iconActive: 'notifications', badge: notifCount },
{ key: 'wallet', href: '/(app)/wallet', icon: 'wallet-outline', iconActive: 'wallet' },
];
// NavBar active-matching: путь может начинаться с "/chats" ИЛИ с href
// напрямую. Вариант `/chats/xyz` тоже считается active для home.
const isActive = (href: string) => {
// Нормализуем /(app)/chats → /chats
const norm = href.replace(/^\/\(app\)/, '');
return pathname === norm || pathname.startsWith(norm + '/');
};
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
backgroundColor: '#000000',
borderTopWidth: 1,
borderTopColor: '#0f0f0f',
paddingTop: 8,
paddingBottom: Math.max(bottomInset, 8),
}}
>
{items.map((it) => {
const active = isActive(it.href);
return (
<Pressable
key={it.key}
onPress={() => {
if (it.soon) return;
router.replace(it.href as never);
}}
hitSlop={6}
style={({ pressed }) => ({
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 4,
opacity: pressed ? 0.65 : 1,
})}
>
<View
style={{
// highlight-блок вокруг active-иконки
width: 52,
height: 36,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
<Ionicons
name={active ? it.iconActive : it.icon}
size={26}
color={active ? '#ffffff' : it.soon ? '#3a3a3a' : '#6b6b6b'}
/>
{it.badge && it.badge > 0 ? (
<View
style={{
position: 'absolute',
top: 2,
right: 8,
minWidth: 16,
height: 16,
paddingHorizontal: 4,
borderRadius: 8,
backgroundColor: '#1d9bf0',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1.5,
borderColor: '#000',
}}
>
<Text style={{ color: '#fff', fontSize: 9, fontWeight: '700' }}>
{it.badge > 99 ? '99+' : it.badge}
</Text>
</View>
) : null}
{it.soon && (
<View
style={{
position: 'absolute',
top: -2,
right: 2,
paddingHorizontal: 4,
paddingVertical: 1,
borderRadius: 4,
backgroundColor: '#1a1a1a',
}}
>
<Text style={{ color: '#8b8b8b', fontSize: 7, fontWeight: '700', letterSpacing: 0.3 }}>
SOON
</Text>
</View>
)}
</View>
</Pressable>
);
})}
</View>
);
}

View File

@@ -0,0 +1,69 @@
/**
* SearchBar — single-TextInput pill. Icon + input в одном ряду, без
* idle/focused двойного состояния (раньше был хак с невидимым
* TextInput поверх отцентрированного Text — ломал focus и выравнивание
* на Android).
*/
import React from 'react';
import { View, TextInput, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export interface SearchBarProps {
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
autoFocus?: boolean;
onSubmitEditing?: () => void;
onClear?: () => void;
}
export function SearchBar({
value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing, onClear,
}: SearchBarProps) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#111111',
borderWidth: 1,
borderColor: '#1f1f1f',
borderRadius: 999,
paddingHorizontal: 14,
gap: 8,
}}
>
<Ionicons name="search" size={16} color="#6a6a6a" />
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
autoFocus={autoFocus}
onSubmitEditing={onSubmitEditing}
returnKeyType="search"
style={{
flex: 1,
color: '#ffffff',
fontSize: 14,
paddingVertical: 10,
padding: 0,
includeFontPadding: false,
}}
/>
{value.length > 0 && (
<Pressable
onPress={() => {
onChangeText('');
onClear?.();
}}
hitSlop={8}
>
<Ionicons name="close-circle" size={16} color="#6a6a6a" />
</Pressable>
)}
</View>
);
}

View File

@@ -0,0 +1,59 @@
/**
* TabHeader — общая шапка для всех tab-страниц (home/feed/notifications/wallet).
*
* Структура строго как в референсе Messages-экрана:
* [avatar 32 → /settings] [title] [right slot]
*
* Без нижнего разделителя (divider=false) — тот же уровень, что и фон экрана.
*
* Right-slot по умолчанию — шестерёнка → /settings. Но экраны могут передать
* свой (например, refresh в wallet). Левый avatar — всегда клик-навигация в
* settings, как в референсе.
*/
import React from 'react';
import { Pressable } from 'react-native';
import { useRouter } from 'expo-router';
import { useStore } from '@/lib/store';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
export interface TabHeaderProps {
title: string;
/** Right-slot. Если не передан — выставляется IconButton с settings-outline. */
right?: React.ReactNode;
/** Dot-color на profile-avatar'е (например, WS live/polling indicator). */
profileDotColor?: string;
}
export function TabHeader({ title, right, profileDotColor }: TabHeaderProps) {
const router = useRouter();
const username = useStore(s => s.username);
const keyFile = useStore(s => s.keyFile);
return (
<Header
title={title}
divider={false}
left={
<Pressable onPress={() => router.push('/(app)/settings' as never)} hitSlop={8}>
<Avatar
name={username ?? '?'}
address={keyFile?.pub_key}
size={32}
dotColor={profileDotColor}
/>
</Pressable>
}
right={
right ?? (
<IconButton
icon="settings-outline"
size={36}
onPress={() => router.push('/(app)/settings' as never)}
/>
)
}
/>
);
}

View File

@@ -0,0 +1,188 @@
/**
* AttachmentMenu — bottom-sheet с вариантами прикрепления.
*
* Выводится при нажатии на `+` в composer'е. Опции:
* - 📷 Photo / video из галереи (expo-image-picker)
* - 📸 Take photo (камера)
* - 📎 File (expo-document-picker)
* - 🎙️ Voice message — stub (запись через expo-av потребует
* permissions runtime + recording UI; сейчас добавляет мок-
* голосовое с duration 4s)
*
* Всё визуально — тёмный overlay + sheet снизу. Закрытие по tap'у на
* overlay или на Cancel.
*/
import React from 'react';
import { View, Text, Pressable, Alert, Modal } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as ImagePicker from 'expo-image-picker';
import * as DocumentPicker from 'expo-document-picker';
import type { Attachment } from '@/lib/types';
export interface AttachmentMenuProps {
visible: boolean;
onClose: () => void;
/** Вызывается когда attachment готов для отправки. */
onPick: (att: Attachment) => void;
}
export function AttachmentMenu({ visible, onClose, onPick }: AttachmentMenuProps) {
const insets = useSafeAreaInsets();
const pickImageOrVideo = async () => {
try {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Permission needed', 'Grant photos access to attach media.');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.All,
quality: 0.85,
allowsEditing: false,
});
if (result.canceled) return;
const asset = result.assets[0];
onPick({
kind: asset.type === 'video' ? 'video' : 'image',
uri: asset.uri,
mime: asset.mimeType,
width: asset.width,
height: asset.height,
duration: asset.duration ? Math.round(asset.duration / 1000) : undefined,
});
onClose();
} catch (e: any) {
Alert.alert('Pick failed', e?.message ?? 'Unknown error');
}
};
const takePhoto = async () => {
try {
const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm.granted) {
Alert.alert('Permission needed', 'Grant camera access to take a photo.');
return;
}
const result = await ImagePicker.launchCameraAsync({ quality: 0.85 });
if (result.canceled) return;
const asset = result.assets[0];
onPick({
kind: asset.type === 'video' ? 'video' : 'image',
uri: asset.uri,
mime: asset.mimeType,
width: asset.width,
height: asset.height,
});
onClose();
} catch (e: any) {
Alert.alert('Camera failed', e?.message ?? 'Unknown error');
}
};
const pickFile = async () => {
try {
const res = await DocumentPicker.getDocumentAsync({
type: '*/*',
copyToCacheDirectory: true,
});
if (res.canceled) return;
const asset = res.assets[0];
onPick({
kind: 'file',
uri: asset.uri,
name: asset.name,
mime: asset.mimeType ?? undefined,
size: asset.size,
});
onClose();
} catch (e: any) {
Alert.alert('File pick failed', e?.message ?? 'Unknown error');
}
};
// Voice recorder больше не stub — см. inline-кнопку 🎤 в composer'е,
// которая разворачивает VoiceRecorder (expo-av Audio.Recording). Опция
// Voice в этом меню убрана, т.к. дублировала бы UX.
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable
onPress={onClose}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.55)' }}
>
<View style={{ flex: 1 }} />
<Pressable
onPress={() => {}}
style={{
backgroundColor: '#111111',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingTop: 8,
paddingBottom: Math.max(insets.bottom, 12) + 10,
paddingHorizontal: 10,
borderTopWidth: 1, borderColor: '#1f1f1f',
}}
>
{/* Drag handle */}
<View
style={{
alignSelf: 'center',
width: 40, height: 4, borderRadius: 2,
backgroundColor: '#2a2a2a',
marginBottom: 12,
}}
/>
<Text
style={{
color: '#ffffff', fontSize: 16, fontWeight: '700',
marginLeft: 8, marginBottom: 12,
}}
>
Attach
</Text>
<Row icon="images-outline" label="Photo / video" onPress={pickImageOrVideo} />
<Row icon="camera-outline" label="Take photo" onPress={takePhoto} />
<Row icon="document-outline" label="File" onPress={pickFile} />
</Pressable>
</Pressable>
</Modal>
);
}
function Row({
icon, label, onPress,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
gap: 14,
paddingHorizontal: 14,
paddingVertical: 14,
borderRadius: 14,
backgroundColor: pressed ? '#1a1a1a' : 'transparent',
})}
>
<View
style={{
width: 40, height: 40, borderRadius: 10,
backgroundColor: '#1a1a1a',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name={icon} size={20} color="#ffffff" />
</View>
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '600' }}>{label}</Text>
</Pressable>
);
}

View File

@@ -0,0 +1,178 @@
/**
* AttachmentPreview — рендер `Message.attachment` внутри bubble'а.
*
* Четыре формы:
* - image → Image с object-fit cover, aspect-ratio из width/height
* - video → то же + play-overlay в центре, duration внизу-справа
* - voice → row [play-icon] [waveform stub] [duration]
* - file → row [file-icon] [name + size]
*
* Вложения размещаются ВНУТРИ того же bubble'а что и текст, чуть ниже
* footer'а нет и ширина bubble'а снимает maxWidth-ограничение ради
* изображений (отдельный media-first-bubble case).
*/
import React from 'react';
import { View, Text, Image } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { Attachment } from '@/lib/types';
import { VoicePlayer } from '@/components/chat/VoicePlayer';
import { VideoCirclePlayer } from '@/components/chat/VideoCirclePlayer';
export interface AttachmentPreviewProps {
attachment: Attachment;
/** Используется для тонирования footer-элементов. */
own?: boolean;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
export function AttachmentPreview({ attachment, own }: AttachmentPreviewProps) {
switch (attachment.kind) {
case 'image':
return <ImageAttachment att={attachment} />;
case 'video':
// circle=true — круглое видео-сообщение (Telegram-стиль).
return attachment.circle
? <VideoCirclePlayer uri={attachment.uri} duration={attachment.duration} />
: <VideoAttachment att={attachment} />;
case 'voice':
return <VoicePlayer uri={attachment.uri} duration={attachment.duration} own={own} />;
case 'file':
return <FileAttachment att={attachment} own={own} />;
}
}
// ─── Image ──────────────────────────────────────────────────────────
function ImageAttachment({ att }: { att: Attachment }) {
// Aspect-ratio из реальных width/height; fallback 4:3.
const aspect = att.width && att.height ? att.width / att.height : 4 / 3;
return (
<Image
source={{ uri: att.uri }}
style={{
width: '100%',
aspectRatio: aspect,
borderRadius: 12,
marginBottom: 4,
backgroundColor: '#0a0a0a',
}}
resizeMode="cover"
/>
);
}
// ─── Video ──────────────────────────────────────────────────────────
function VideoAttachment({ att }: { att: Attachment }) {
const aspect = att.width && att.height ? att.width / att.height : 16 / 9;
return (
<View style={{ position: 'relative', marginBottom: 4 }}>
<Image
source={{ uri: att.uri }}
style={{
width: '100%',
aspectRatio: aspect,
borderRadius: 12,
backgroundColor: '#0a0a0a',
}}
resizeMode="cover"
/>
{/* Play overlay по центру */}
<View
style={{
position: 'absolute',
top: '50%', left: '50%',
transform: [{ translateX: -22 }, { translateY: -22 }],
width: 44, height: 44, borderRadius: 22,
backgroundColor: 'rgba(0,0,0,0.55)',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name="play" size={22} color="#ffffff" style={{ marginLeft: 2 }} />
</View>
{att.duration !== undefined && (
<View
style={{
position: 'absolute',
right: 8, bottom: 8,
backgroundColor: 'rgba(0,0,0,0.6)',
paddingHorizontal: 6, paddingVertical: 2,
borderRadius: 4,
}}
>
<Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '600' }}>
{formatDuration(att.duration)}
</Text>
</View>
)}
</View>
);
}
// ─── Voice ──────────────────────────────────────────────────────────
// Реальный плеер — см. components/chat/VoicePlayer.tsx (expo-av Sound).
// ─── File ───────────────────────────────────────────────────────────
function FileAttachment({ att, own }: { att: Attachment; own?: boolean }) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
gap: 10,
paddingVertical: 4,
}}
>
<View
style={{
width: 36, height: 36, borderRadius: 10,
backgroundColor: own ? 'rgba(255,255,255,0.18)' : '#1a1a1a',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons
name="document-text"
size={18}
color={own ? '#ffffff' : '#ffffff'}
/>
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text
numberOfLines={1}
style={{
color: '#ffffff',
fontSize: 14,
fontWeight: '600',
}}
>
{att.name ?? 'file'}
</Text>
<Text
style={{
color: own ? 'rgba(255,255,255,0.75)' : '#8b8b8b',
fontSize: 11,
}}
>
{att.size !== undefined ? formatSize(att.size) : ''}
{att.size !== undefined && att.mime ? ' · ' : ''}
{att.mime ?? ''}
</Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,36 @@
/**
* DaySeparator — центральный лейбл "Сегодня" / "Вчера" / "17 июня 2025"
* между группами сообщений.
*
* Стиль: тонкий шрифт серого цвета, маленький размер. В референсе этот
* лейбл не должен перетягивать на себя внимание — он визуальный якорь,
* не заголовок.
*/
import React from 'react';
import { View, Text, Platform } from 'react-native';
export interface DaySeparatorProps {
label: string;
}
export function DaySeparator({ label }: DaySeparatorProps) {
return (
<View style={{ alignItems: 'center', marginTop: 14, marginBottom: 6 }}>
<Text
style={{
color: '#6b6b6b',
fontSize: 12,
// Тонкий шрифт — на iOS "200" рисует ultra-light, на Android —
// sans-serif-thin. В Expo font-weight 300 почти идентичен на
// обеих платформах и доступен без дополнительных шрифтов.
fontWeight: '300',
// Android font-weight 100-300 требует явной семьи, иначе
// округляется до 400. Для thin визуала передаём serif-thin.
...(Platform.OS === 'android' ? { fontFamily: 'sans-serif-thin' } : null),
}}
>
{label}
</Text>
</View>
);
}

View File

@@ -0,0 +1,385 @@
/**
* MessageBubble — рендер одного сообщения с gesture interactions.
*
* Гестуры — разведены по двум примитивам во избежание конфликта со
* скроллом FlatList'а:
*
* 1. Swipe-left (reply): PanResponder на Animated.View обёртке
* bubble'а. `onMoveShouldSetPanResponder` клеймит responder ТОЛЬКО
* когда пользователь сдвинул палец > 6px влево и горизонталь
* преобладает над вертикалью. Для вертикального скролла
* `onMoveShouldSet` возвращает false — FlatList получает gesture.
* Touchdown ничего не клеймит (onStartShouldSetPanResponder
* отсутствует).
*
* 2. Long-press / tap: через View.onTouchStart/End. Primitive touch
* events bubble'ятся независимо от responder'а. Long-press запускаем
* timer'ом на 550ms, cancel при `onTouchMove` с достаточной
* амплитудой. Tap — короткое касание без move в selection mode.
*
* 3. `selectionMode=true` — PanResponder disabled (в selection режиме
* свайпы не работают).
*
* 4. ReplyQuote — отдельный Pressable над bubble-текстом; tap прыгает
* к оригиналу через onJumpToReply.
*
* 5. highlight prop — bubble-row мерцает accent-blue фоном, использует
* Animated.Value; управляется из ChatScreen после scrollToIndex.
*/
import React, { useRef, useEffect } from 'react';
import {
View, Text, Pressable, ViewStyle, Animated, PanResponder,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { Message } from '@/lib/types';
import { relTime } from '@/lib/dates';
import { Avatar } from '@/components/Avatar';
import { AttachmentPreview } from '@/components/chat/AttachmentPreview';
import { ReplyQuote } from '@/components/chat/ReplyQuote';
import { PostRefCard } from '@/components/chat/PostRefCard';
export const PEER_AVATAR_SLOT = 34;
const SWIPE_THRESHOLD = 60;
const LONG_PRESS_MS = 550;
const TAP_MAX_MOVEMENT = 8;
const TAP_MAX_ELAPSED = 300;
export interface MessageBubbleProps {
msg: Message;
peerName: string;
peerAddress?: string;
withSenderMeta?: boolean;
showName: boolean;
showAvatar: boolean;
onReply?: (m: Message) => void;
onLongPress?: (m: Message) => void;
onTap?: (m: Message) => void;
onOpenProfile?: () => void;
onJumpToReply?: (originalId: string) => void;
selectionMode?: boolean;
selected?: boolean;
/** Mgnt-управляемый highlight: row мерцает accent-фоном ~1-2 секунды. */
highlighted?: boolean;
}
// ─── Bubble styles ──────────────────────────────────────────────────
const bubbleBase: ViewStyle = {
borderRadius: 18,
paddingHorizontal: 14,
paddingTop: 8,
paddingBottom: 6,
};
const peerBubble: ViewStyle = {
...bubbleBase,
backgroundColor: '#1a1a1a',
borderBottomLeftRadius: 6,
};
const ownBubble: ViewStyle = {
...bubbleBase,
backgroundColor: '#1d9bf0',
borderBottomRightRadius: 6,
};
const bubbleText = { color: '#ffffff', fontSize: 15, lineHeight: 20 } as const;
// ─── Main ───────────────────────────────────────────────────────────
export function MessageBubble(props: MessageBubbleProps) {
if (props.msg.mine) return <RowShell {...props} variant="own" />;
if (!props.withSenderMeta) return <RowShell {...props} variant="peer-compact" />;
return <RowShell {...props} variant="group-peer" />;
}
type Variant = 'own' | 'peer-compact' | 'group-peer';
function RowShell({
msg, peerName, peerAddress, showName, showAvatar,
onReply, onLongPress, onTap, onOpenProfile, onJumpToReply,
selectionMode, selected, highlighted, variant,
}: MessageBubbleProps & { variant: Variant }) {
const translateX = useRef(new Animated.Value(0)).current;
const startTs = useRef(0);
const moved = useRef(false);
const lpTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearLp = () => {
if (lpTimer.current) { clearTimeout(lpTimer.current); lpTimer.current = null; }
};
// Touch start — запускаем long-press timer (НЕ клеймим responder).
const onTouchStart = () => {
startTs.current = Date.now();
moved.current = false;
clearLp();
if (onLongPress) {
lpTimer.current = setTimeout(() => {
if (!moved.current) onLongPress(msg);
lpTimer.current = null;
}, LONG_PRESS_MS);
}
};
const onTouchMove = (e: { nativeEvent: { pageX: number; pageY: number } }) => {
// Если пользователь двигает палец — отменяем long-press timer.
// Малые движения (< TAP_MAX_MOVEMENT) игнорируем — устраняют
// fale-cancel от дрожания пальца.
// Здесь нет точного dx/dy от gesture-системы, используем primitive
// touch coords отсчитываемые по абсолютным координатам. Проще —
// всегда отменяем на first move (PanResponder ниже отнимет
// responder если leftward).
moved.current = true;
clearLp();
};
const onTouchEnd = () => {
const elapsed = Date.now() - startTs.current;
clearLp();
// Короткий tap без движения → в selection mode toggle.
if (!moved.current && elapsed < TAP_MAX_ELAPSED && selectionMode) {
onTap?.(msg);
}
};
// Swipe-to-reply: PanResponder клеймит ТОЛЬКО leftward-dominant move.
// Для vertical scroll / rightward swipe / start-touch возвращает false,
// FlatList / AnimatedSlot получают gesture.
const panResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (_e, g) => {
if (selectionMode) return false;
// Leftward > 6px и горизонталь преобладает.
return g.dx < -6 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5;
},
onPanResponderGrant: () => {
// Как только мы заклеймили gesture, отменяем long-press
// (пользователь явно свайпает, не удерживает).
clearLp();
moved.current = true;
},
onPanResponderMove: (_e, g) => {
translateX.setValue(Math.min(0, g.dx));
},
onPanResponderRelease: (_e, g) => {
if (g.dx <= -SWIPE_THRESHOLD) onReply?.(msg);
Animated.spring(translateX, {
toValue: 0, friction: 6, tension: 80, useNativeDriver: true,
}).start();
},
onPanResponderTerminate: () => {
Animated.spring(translateX, {
toValue: 0, friction: 6, tension: 80, useNativeDriver: true,
}).start();
},
}),
).current;
// Highlight fade: при переключении highlighted=true крутим короткую
// анимацию "flash + fade out" через Animated.Value (0→1→0 за ~1.8s).
const highlightAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (!highlighted) return;
highlightAnim.setValue(0);
Animated.sequence([
Animated.timing(highlightAnim, { toValue: 1, duration: 150, useNativeDriver: false }),
Animated.delay(1400),
Animated.timing(highlightAnim, { toValue: 0, duration: 450, useNativeDriver: false }),
]).start();
}, [highlighted, highlightAnim]);
const highlightBg = highlightAnim.interpolate({
inputRange: [0, 1],
outputRange: ['rgba(29,155,240,0)', 'rgba(29,155,240,0.22)'],
});
const isMine = variant === 'own';
const hasAttachment = !!msg.attachment;
const hasPostRef = !!msg.postRef;
const hasReply = !!msg.replyTo;
const attachmentOnly = hasAttachment && !msg.text.trim();
const bubbleStyle = attachmentOnly
? { ...(isMine ? ownBubble : peerBubble), padding: 4 }
: (isMine ? ownBubble : peerBubble);
const bubbleNode = (
<Animated.View
{...panResponder.panHandlers}
style={{
transform: [{ translateX }],
maxWidth: hasAttachment ? '80%' : '85%',
minWidth: hasAttachment || hasReply ? 220 : undefined,
}}
>
<View style={bubbleStyle}>
{msg.replyTo && (
<ReplyQuote
author={msg.replyTo.author}
preview={msg.replyTo.text}
own={isMine}
onJump={() => onJumpToReply?.(msg.replyTo!.id)}
/>
)}
{msg.attachment && (
<AttachmentPreview attachment={msg.attachment} own={isMine} />
)}
{msg.postRef && (
<PostRefCard
postID={msg.postRef.postID}
author={msg.postRef.author}
excerpt={msg.postRef.excerpt}
hasImage={!!msg.postRef.hasImage}
own={isMine}
/>
)}
{msg.text.trim() ? (
<Text style={bubbleText}>{msg.text}</Text>
) : null}
<BubbleFooter
edited={!!msg.edited}
time={relTime(msg.timestamp)}
own={isMine}
read={!!msg.read}
/>
</View>
</Animated.View>
);
const contentRow =
variant === 'own' ? (
<View style={{ flexDirection: 'row', justifyContent: 'flex-end' }}>
{bubbleNode}
</View>
) : variant === 'peer-compact' ? (
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
{bubbleNode}
</View>
) : (
<View>
{showName && (
<Pressable
onPress={onOpenProfile}
hitSlop={4}
style={{ marginLeft: PEER_AVATAR_SLOT, marginBottom: 3 }}
>
<Text style={{ color: '#8b8b8b', fontSize: 12 }} numberOfLines={1}>
{peerName}
</Text>
</Pressable>
)}
<View style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
<View style={{ width: PEER_AVATAR_SLOT, alignItems: 'flex-start' }}>
{showAvatar ? (
<Pressable onPress={onOpenProfile} hitSlop={4}>
<Avatar name={peerName} address={peerAddress} size={26} />
</Pressable>
) : null}
</View>
{bubbleNode}
</View>
</View>
);
return (
<Animated.View
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={() => { clearLp(); moved.current = true; }}
style={{
paddingHorizontal: 8,
marginBottom: 6,
// Selection & highlight накладываются: highlight flash побеждает
// когда анимация > 0, иначе статичный selection-tint.
backgroundColor: selected ? 'rgba(29,155,240,0.12)' : highlightBg,
position: 'relative',
}}
>
{contentRow}
{selectionMode && (
<CheckDot
selected={!!selected}
onPress={() => onTap?.(msg)}
/>
)}
</Animated.View>
);
}
// ─── Clickable check-dot ────────────────────────────────────────────
function CheckDot({ selected, onPress }: { selected: boolean; onPress: () => void }) {
return (
<Pressable
onPress={onPress}
hitSlop={12}
style={{
position: 'absolute',
right: 4,
top: 0, bottom: 0,
alignItems: 'center',
justifyContent: 'center',
}}
>
<View
style={{
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: selected ? '#1d9bf0' : 'rgba(0,0,0,0.55)',
borderWidth: 2,
borderColor: selected ? '#1d9bf0' : '#6b6b6b',
alignItems: 'center',
justifyContent: 'center',
}}
>
{selected && <Ionicons name="checkmark" size={12} color="#ffffff" />}
</View>
</Pressable>
);
}
// ─── Footer ─────────────────────────────────────────────────────────
interface FooterProps {
edited: boolean;
time: string;
own?: boolean;
read?: boolean;
}
function BubbleFooter({ edited, time, own, read }: FooterProps) {
const textColor = own ? 'rgba(255,255,255,0.78)' : '#8b8b8b';
const dotColor = own ? 'rgba(255,255,255,0.55)' : '#5a5a5a';
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: 2,
gap: 4,
}}
>
{edited && (
<>
<Text style={{ color: textColor, fontSize: 11 }}>Edited</Text>
<Text style={{ color: dotColor, fontSize: 11 }}>·</Text>
</>
)}
<Text style={{ color: textColor, fontSize: 11 }}>{time}</Text>
{own && (
<Ionicons
name={read ? 'checkmark-circle' : 'checkmark-circle-outline'}
size={13}
color={read ? '#ffffff' : 'rgba(255,255,255,0.78)'}
style={{ marginLeft: 2 }}
/>
)}
</View>
);
}

View File

@@ -0,0 +1,143 @@
/**
* PostRefCard — renders a shared feed post inside a chat bubble.
*
* Visually distinct from plain messages so the user sees at-a-glance
* that this came from the feed, not a direct-typed text. Matches
* VK's "shared wall post" embed pattern:
*
* [newspaper icon] ПОСТ
* @author · 2 строки excerpt'а
* [📷 Фото in this post]
*
* Tap → /(app)/feed/{postID}. The full post (with image + stats +
* like button) is displayed in the standard post-detail screen.
*/
import React from 'react';
import { View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useStore } from '@/lib/store';
import { Avatar } from '@/components/Avatar';
export interface PostRefCardProps {
postID: string;
author: string;
excerpt: string;
hasImage?: boolean;
/** True when the card appears inside the sender's own bubble (our own
* share). Adjusts colour contrast so it reads on the blue bubble
* background. */
own: boolean;
}
function shortAddr(a: string, n = 6): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
export function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefCardProps) {
const contacts = useStore(s => s.contacts);
// Resolve author name the same way the feed does.
const contact = contacts.find(c => c.address === author);
const displayName = contact?.username
? `@${contact.username}`
: contact?.alias ?? shortAddr(author);
const onOpen = () => {
router.push(`/(app)/feed/${postID}` as never);
};
// Tinted palette based on bubble side — inside an "own" (blue) bubble
// the card uses a deeper blue so it reads as a distinct nested block,
// otherwise we use the standard card colours.
const bg = own ? 'rgba(0, 0, 0, 0.22)' : '#0a0a0a';
const border = own ? 'rgba(255, 255, 255, 0.15)' : '#1f1f1f';
const labelColor = own ? 'rgba(255, 255, 255, 0.75)' : '#1d9bf0';
const bodyColor = own ? '#ffffff' : '#ffffff';
const subColor = own ? 'rgba(255, 255, 255, 0.65)' : '#8b8b8b';
return (
<Pressable
onPress={onOpen}
style={({ pressed }) => ({
marginBottom: 6,
borderRadius: 14,
backgroundColor: pressed ? 'rgba(0,0,0,0.35)' : bg,
borderWidth: 1,
borderColor: border,
overflow: 'hidden',
})}
>
{/* Top ribbon: "ПОСТ" label — makes the shared nature unmistakable. */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingTop: 8,
paddingBottom: 4,
}}
>
<Ionicons name="newspaper-outline" size={11} color={labelColor} />
<Text
style={{
color: labelColor,
fontSize: 10,
fontWeight: '700',
letterSpacing: 1.2,
}}
>
POST
</Text>
</View>
{/* Author + excerpt */}
<View style={{ flexDirection: 'row', paddingHorizontal: 10, paddingBottom: 10 }}>
<Avatar name={displayName} address={author} size={28} />
<View style={{ flex: 1, marginLeft: 8, minWidth: 0, overflow: 'hidden' }}>
<Text
numberOfLines={1}
style={{
color: bodyColor,
fontWeight: '700',
fontSize: 13,
}}
>
{displayName}
</Text>
{excerpt.length > 0 && (
<Text
numberOfLines={3}
style={{
color: subColor,
fontSize: 12,
lineHeight: 16,
marginTop: 2,
}}
>
{excerpt}
</Text>
)}
{hasImage && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: 6,
}}
>
<Ionicons name="image-outline" size={11} color={subColor} />
<Text style={{ color: subColor, fontSize: 11 }}>
photo
</Text>
</View>
)}
</View>
</View>
</Pressable>
);
}

View File

@@ -0,0 +1,70 @@
/**
* ReplyQuote — блок "цитаты" внутри bubble'а сообщения-ответа.
*
* Визуал: slim-row с синим бордером слева (accent-bar), author в синем,
* preview text — серым, в одну строку.
*
* Tap на quoted-блок → onJump → ChatScreen скроллит к оригиналу и
* подсвечивает его на пару секунд. Если оригинал не найден в текущем
* списке (удалён / ушёл за пределы пагинации) — onJump может просто
* no-op'нуть.
*
* Цвета зависят от того в чьём bubble'е мы находимся:
* - own (синий bubble) → quote border = белый, текст белый/85%
* - peer (серый bubble) → quote border = accent blue, текст white
*/
import React from 'react';
import { View, Text, Pressable } from 'react-native';
export interface ReplyQuoteProps {
author: string;
preview: string;
own?: boolean;
onJump?: () => void;
}
export function ReplyQuote({ author, preview, own, onJump }: ReplyQuoteProps) {
const barColor = own ? 'rgba(255,255,255,0.85)' : '#1d9bf0';
const authorColor = own ? '#ffffff' : '#1d9bf0';
const previewColor = own ? 'rgba(255,255,255,0.85)' : '#c0c0c0';
return (
<Pressable
onPress={onJump}
style={({ pressed }) => ({
flexDirection: 'row',
backgroundColor: own ? 'rgba(255,255,255,0.10)' : 'rgba(29,155,240,0.10)',
borderRadius: 10,
overflow: 'hidden',
marginBottom: 5,
opacity: pressed ? 0.7 : 1,
})}
>
{/* Accent bar слева */}
<View
style={{
width: 3,
backgroundColor: barColor,
}}
/>
<View style={{ flex: 1, paddingHorizontal: 8, paddingVertical: 6 }}>
<Text
style={{
color: authorColor,
fontSize: 12,
fontWeight: '700',
}}
numberOfLines={1}
>
{author}
</Text>
<Text
style={{ color: previewColor, fontSize: 13 }}
numberOfLines={1}
>
{preview || 'attachment'}
</Text>
</View>
</Pressable>
);
}

View File

@@ -0,0 +1,158 @@
/**
* VideoCirclePlayer — telegram-style круглое видео-сообщение.
*
* Мигрировано с expo-av `<Video>` на expo-video `<VideoView>` +
* useVideoPlayer hook (expo-av deprecated в SDK 54).
*
* UI:
* - Круглая thumbnail-рамка (Image превью первого кадра) с play-overlay
* - Tap → полноэкранный Modal с VideoView в круглой рамке, auto-play + loop
* - Duration badge снизу
*/
import React, { useState } from 'react';
import { View, Text, Pressable, Modal, Image } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useVideoPlayer, VideoView } from 'expo-video';
export interface VideoCirclePlayerProps {
uri: string;
duration?: number;
size?: number;
}
function formatClock(sec: number): string {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
export function VideoCirclePlayer({ uri, duration, size = 220 }: VideoCirclePlayerProps) {
const [open, setOpen] = useState(false);
return (
<>
<Pressable
onPress={() => setOpen(true)}
style={{
width: size, height: size, borderRadius: size / 2,
overflow: 'hidden',
backgroundColor: '#0a0a0a',
marginBottom: 4,
alignItems: 'center', justifyContent: 'center',
}}
>
{/* Статический thumbnail через Image (первый кадр если платформа
поддерживает, иначе чёрный фон). Реальное видео играет только
в Modal ради производительности FlatList'а. */}
<Image
source={{ uri }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
<View
style={{
position: 'absolute',
width: 52, height: 52, borderRadius: 26,
backgroundColor: 'rgba(0,0,0,0.55)',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name="play" size={22} color="#ffffff" style={{ marginLeft: 2 }} />
</View>
{duration !== undefined && (
<View
style={{
position: 'absolute',
right: size / 2 - 26, bottom: 16,
paddingHorizontal: 6, paddingVertical: 2,
borderRadius: 6,
backgroundColor: 'rgba(0,0,0,0.6)',
}}
>
<Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '600' }}>
{formatClock(duration)}
</Text>
</View>
)}
</Pressable>
{open && (
<VideoModal uri={uri} onClose={() => setOpen(false)} />
)}
</>
);
}
// Modal рендерится только когда open=true — это значит useVideoPlayer
// не создаёт лишних плееров пока пользователь не открыл overlay.
function VideoModal({ uri, onClose }: { uri: string; onClose: () => void }) {
// useVideoPlayer может throw'нуть на некоторых платформах при
// невалидных source'ах. try/catch вокруг render'а защищает парента
// от полного crash'а.
let player: ReturnType<typeof useVideoPlayer> | null = null;
try {
// eslint-disable-next-line react-hooks/rules-of-hooks
player = useVideoPlayer({ uri }, (p) => {
p.loop = true;
p.muted = false;
p.play();
});
} catch {
player = null;
}
return (
<Modal visible transparent animationType="fade" onRequestClose={onClose}>
<Pressable
onPress={onClose}
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.92)',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
}}
>
<View
style={{
width: '90%',
aspectRatio: 1,
maxWidth: 420, maxHeight: 420,
borderRadius: 9999,
overflow: 'hidden',
backgroundColor: '#000',
}}
>
{player ? (
<VideoView
player={player}
style={{ width: '100%', height: '100%' }}
contentFit="cover"
nativeControls={false}
/>
) : (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Ionicons name="alert-circle-outline" size={36} color="#8b8b8b" />
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 8 }}>
Playback not available
</Text>
</View>
)}
</View>
<Pressable
onPress={onClose}
style={{
position: 'absolute',
top: 48, right: 16,
width: 40, height: 40, borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.14)',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name="close" size={22} color="#ffffff" />
</Pressable>
</Pressable>
</Modal>
);
}

View File

@@ -0,0 +1,217 @@
/**
* VideoCircleRecorder — full-screen Modal для записи круглого видео-
* сообщения (Telegram-style).
*
* UX:
* 1. Открывается Modal с CameraView (по умолчанию front-camera).
* 2. Превью — круглое (аналогично VideoCirclePlayer).
* 3. Большая красная кнопка внизу: tap-to-start, tap-to-stop.
* 4. Максимум 15 секунд — авто-стоп.
* 5. По stop'у возвращаем attachment { kind:'video', circle:true, uri, duration }.
* 6. Свайп вниз / close-icon → cancel (без отправки).
*/
import React, { useEffect, useRef, useState } from 'react';
import { View, Text, Pressable, Modal, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
import type { Attachment } from '@/lib/types';
export interface VideoCircleRecorderProps {
visible: boolean;
onClose: () => void;
onFinish: (att: Attachment) => void;
}
const MAX_DURATION_SEC = 15;
function formatClock(sec: number): string {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
export function VideoCircleRecorder({ visible, onClose, onFinish }: VideoCircleRecorderProps) {
const insets = useSafeAreaInsets();
const camRef = useRef<CameraView>(null);
const [camPerm, requestCam] = useCameraPermissions();
const [micPerm, requestMic] = useMicrophonePermissions();
const [recording, setRecording] = useState(false);
const [elapsed, setElapsed] = useState(0);
const startedAt = useRef(0);
const facing: 'front' | 'back' = 'front';
// Timer + auto-stop at MAX_DURATION_SEC
useEffect(() => {
if (!recording) return;
const t = setInterval(() => {
const s = Math.floor((Date.now() - startedAt.current) / 1000);
setElapsed(s);
if (s >= MAX_DURATION_SEC) stopAndSend();
}, 250);
return () => clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recording]);
// Permissions on mount of visible
useEffect(() => {
if (!visible) {
setRecording(false);
setElapsed(0);
return;
}
(async () => {
if (!camPerm?.granted) await requestCam();
if (!micPerm?.granted) await requestMic();
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
const start = async () => {
if (!camRef.current || recording) return;
try {
startedAt.current = Date.now();
setElapsed(0);
setRecording(true);
// recordAsync блокируется до stopRecording или maxDuration
const result = await camRef.current.recordAsync({ maxDuration: MAX_DURATION_SEC });
setRecording(false);
if (!result?.uri) return;
const seconds = Math.max(1, Math.floor((Date.now() - startedAt.current) / 1000));
onFinish({
kind: 'video',
circle: true,
uri: result.uri,
duration: seconds,
mime: 'video/mp4',
});
onClose();
} catch (e: any) {
setRecording(false);
Alert.alert('Recording failed', e?.message ?? 'Unknown error');
}
};
const stopAndSend = () => {
if (!recording) return;
camRef.current?.stopRecording();
// recordAsync promise выше resolve'нется с uri → onFinish
};
const cancel = () => {
if (recording) {
camRef.current?.stopRecording();
}
onClose();
};
const permOK = camPerm?.granted && micPerm?.granted;
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={cancel}>
<View
style={{
flex: 1,
backgroundColor: '#000000',
paddingTop: insets.top,
paddingBottom: Math.max(insets.bottom, 12),
}}
>
{/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', padding: 12 }}>
<Pressable
onPress={cancel}
hitSlop={10}
style={{
width: 36, height: 36, borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.08)',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name="close" size={20} color="#ffffff" />
</Pressable>
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', flex: 1, textAlign: 'center' }}>
Video message
</Text>
<View style={{ width: 36 }} />
</View>
{/* Camera */}
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 20 }}>
{permOK ? (
<View
style={{
width: '85%',
aspectRatio: 1,
maxWidth: 360, maxHeight: 360,
borderRadius: 9999,
overflow: 'hidden',
backgroundColor: '#0a0a0a',
borderWidth: recording ? 3 : 0,
borderColor: '#f4212e',
}}
>
<CameraView
ref={camRef}
style={{ flex: 1 }}
facing={facing}
mode="video"
/>
</View>
) : (
<View style={{ alignItems: 'center', paddingHorizontal: 24 }}>
<Ionicons name="videocam-off-outline" size={42} color="#8b8b8b" />
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 12 }}>
Permissions needed
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 4, textAlign: 'center' }}>
Camera + microphone access are required to record a video message.
</Text>
</View>
)}
{/* Timer */}
{recording && (
<Text
style={{
color: '#f4212e',
fontSize: 14, fontWeight: '700',
marginTop: 14,
}}
>
{formatClock(elapsed)} / {formatClock(MAX_DURATION_SEC)}
</Text>
)}
</View>
{/* Record / Stop button */}
<View style={{ alignItems: 'center', paddingBottom: 16 }}>
<Pressable
onPress={recording ? stopAndSend : start}
disabled={!permOK}
style={({ pressed }) => ({
width: 72, height: 72, borderRadius: 36,
backgroundColor: !permOK ? '#1a1a1a' : recording ? '#f4212e' : '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
opacity: pressed ? 0.85 : 1,
borderWidth: 4,
borderColor: 'rgba(255,255,255,0.2)',
})}
>
<Ionicons
name={recording ? 'stop' : 'videocam'}
size={30}
color="#ffffff"
/>
</Pressable>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 10 }}>
{recording ? 'Tap to stop & send' : permOK ? 'Tap to record' : 'Grant permissions'}
</Text>
</View>
</View>
</Modal>
);
}

View File

@@ -0,0 +1,166 @@
/**
* VoicePlayer — play/pause voice message через expo-audio.
*
* Раздел на две подкомпоненты:
* - RealVoicePlayer: useAudioPlayer с настоящим URI
* - StubVoicePlayer: отрисовка waveform без player'а (seed-URI)
*
* Разделение важно: useAudioPlayer не должен получать null/stub-строки —
* при падении внутри expo-audio это крашит render всего bubble'а и
* (в FlatList) визуально "пропадает" интерфейс чата.
*
* UI:
* [▶/⏸] ▮▮▮▮▮▮▮▮▮▯▯▯▯▯▯▯▯ 0:03 / 0:17
*/
import React, { useMemo } from 'react';
import { View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
export interface VoicePlayerProps {
uri: string;
duration?: number;
own?: boolean;
}
const BAR_COUNT = 22;
function formatClock(sec: number): string {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
function isStubUri(u: string): boolean {
return u.startsWith('voice-stub://') || u.startsWith('voice-demo://');
}
function useBars(uri: string) {
return useMemo(() => {
const seed = uri.length;
return Array.from({ length: BAR_COUNT }, (_, i) => {
const h = ((seed * (i + 1) * 7919) % 11) + 4;
return h;
});
}, [uri]);
}
// ─── Top-level router ──────────────────────────────────────────────
export function VoicePlayer(props: VoicePlayerProps) {
// Stub-URI (seed) не передаётся в useAudioPlayer — hook может крашить
// на невалидных source'ах. Рендерим статический waveform.
if (isStubUri(props.uri)) return <StubVoicePlayer {...props} />;
return <RealVoicePlayer {...props} />;
}
// ─── Stub (seed / preview) ─────────────────────────────────────────
function StubVoicePlayer({ uri, duration, own }: VoicePlayerProps) {
const bars = useBars(uri);
const accent = own ? 'rgba(255,255,255,0.92)' : '#1d9bf0';
const subtle = own ? 'rgba(255,255,255,0.35)' : '#3a3a3a';
const textColor = own ? 'rgba(255,255,255,0.85)' : '#8b8b8b';
return (
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
<View
style={{
width: 32, height: 32, borderRadius: 16,
backgroundColor: own ? 'rgba(255,255,255,0.18)' : '#1a1a1a',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name="play" size={14} color="#ffffff" style={{ marginLeft: 1 }} />
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2, flex: 1 }}>
{bars.map((h, i) => (
<View
key={i}
style={{
width: 2, height: h, borderRadius: 1,
backgroundColor: i < 6 ? accent : subtle,
}}
/>
))}
</View>
<Text style={{ color: textColor, fontSize: 12 }}>
{formatClock(duration ?? 0)}
</Text>
</View>
);
}
// ─── Real expo-audio player ────────────────────────────────────────
function RealVoicePlayer({ uri, duration, own }: VoicePlayerProps) {
const player = useAudioPlayer({ uri });
const status = useAudioPlayerStatus(player);
const bars = useBars(uri);
const accent = own ? 'rgba(255,255,255,0.92)' : '#1d9bf0';
const subtle = own ? 'rgba(255,255,255,0.35)' : '#3a3a3a';
const textColor = own ? 'rgba(255,255,255,0.85)' : '#8b8b8b';
const playing = !!status.playing;
const loading = !!status.isBuffering && !status.isLoaded;
const curSec = status.currentTime ?? 0;
const totalSec = (status.duration && status.duration > 0) ? status.duration : (duration ?? 0);
const playedRatio = totalSec > 0 ? Math.min(1, curSec / totalSec) : 0;
const playedBars = Math.round(playedRatio * BAR_COUNT);
const toggle = () => {
try {
if (status.playing) {
player.pause();
} else {
if (status.duration && curSec >= status.duration - 0.05) {
player.seekTo(0);
}
player.play();
}
} catch {
/* dbl-tap during load */
}
};
return (
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
<Pressable
onPress={toggle}
hitSlop={8}
style={{
width: 32, height: 32, borderRadius: 16,
backgroundColor: own ? 'rgba(255,255,255,0.18)' : '#1a1a1a',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons
name={playing ? 'pause' : (loading ? 'hourglass-outline' : 'play')}
size={14}
color="#ffffff"
style={{ marginLeft: playing || loading ? 0 : 1 }}
/>
</Pressable>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2, flex: 1 }}>
{bars.map((h, i) => (
<View
key={i}
style={{
width: 2, height: h, borderRadius: 1,
backgroundColor: i < playedBars ? accent : subtle,
}}
/>
))}
</View>
<Text style={{ color: textColor, fontSize: 12 }}>
{playing || curSec > 0
? `${formatClock(Math.floor(curSec))} / ${formatClock(Math.floor(totalSec))}`
: formatClock(Math.floor(totalSec))}
</Text>
</View>
);
}

View File

@@ -0,0 +1,183 @@
/**
* VoiceRecorder — inline UI для записи голосового сообщения через
* expo-audio (заменил deprecated expo-av).
*
* UX:
* - При монтировании проверяет permission + запускает запись
* - [🗑] ● timer Recording… [↑]
* - 🗑 = cancel (discard), ↑ = stop + send
*
* Состояние recorder'а живёт в useAudioRecorder hook'е. Prepare + start
* вызывается из useEffect. Stop — при release, finalized URI через
* `recorder.uri`.
*/
import React, { useEffect, useRef, useState } from 'react';
import { View, Text, Pressable, Alert, Animated } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import {
useAudioRecorder, AudioModule, RecordingPresets, setAudioModeAsync,
} from 'expo-audio';
import type { Attachment } from '@/lib/types';
export interface VoiceRecorderProps {
onFinish: (att: Attachment) => void;
onCancel: () => void;
}
export function VoiceRecorder({ onFinish, onCancel }: VoiceRecorderProps) {
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const startedAt = useRef(0);
const [elapsed, setElapsed] = useState(0);
const [error, setError] = useState<string | null>(null);
const [ready, setReady] = useState(false);
// Pulsing red dot
const pulse = useRef(new Animated.Value(1)).current;
useEffect(() => {
const loop = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 0.4, duration: 500, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 1, duration: 500, useNativeDriver: true }),
]),
);
loop.start();
return () => loop.stop();
}, [pulse]);
// Start recording at mount
useEffect(() => {
let cancelled = false;
(async () => {
try {
const perm = await AudioModule.requestRecordingPermissionsAsync();
if (!perm.granted) {
setError('Microphone permission denied');
return;
}
await setAudioModeAsync({
allowsRecording: true,
playsInSilentMode: true,
});
await recorder.prepareToRecordAsync();
if (cancelled) return;
recorder.record();
startedAt.current = Date.now();
setReady(true);
} catch (e: any) {
setError(e?.message ?? 'Failed to start recording');
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Timer tick
useEffect(() => {
if (!ready) return;
const t = setInterval(() => {
setElapsed(Math.floor((Date.now() - startedAt.current) / 1000));
}, 250);
return () => clearInterval(t);
}, [ready]);
const stop = async (send: boolean) => {
try {
if (recorder.isRecording) {
await recorder.stop();
}
const uri = recorder.uri;
const seconds = Math.max(1, Math.floor((Date.now() - startedAt.current) / 1000));
if (!send || !uri || seconds < 1) {
onCancel();
return;
}
onFinish({
kind: 'voice',
uri,
duration: seconds,
mime: 'audio/m4a',
});
} catch (e: any) {
Alert.alert('Recording failed', e?.message ?? 'Unknown error');
onCancel();
}
};
const mm = Math.floor(elapsed / 60);
const ss = elapsed % 60;
if (error) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#111111',
borderRadius: 22,
borderWidth: 1, borderColor: '#1f1f1f',
paddingHorizontal: 14, paddingVertical: 8,
gap: 10,
}}
>
<Ionicons name="alert-circle" size={18} color="#f4212e" />
<Text style={{ color: '#f4212e', fontSize: 13, flex: 1 }}>{error}</Text>
<Pressable onPress={onCancel} hitSlop={8}>
<Ionicons name="close" size={20} color="#8b8b8b" />
</Pressable>
</View>
);
}
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#111111',
borderRadius: 22,
borderWidth: 1, borderColor: '#1f1f1f',
paddingHorizontal: 10, paddingVertical: 6,
gap: 10,
}}
>
<Pressable
onPress={() => stop(false)}
hitSlop={8}
style={{
width: 32, height: 32, borderRadius: 16,
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name="trash-outline" size={20} color="#f4212e" />
</Pressable>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, flex: 1 }}>
<Animated.View
style={{
width: 10, height: 10, borderRadius: 5,
backgroundColor: '#f4212e',
opacity: pulse,
}}
/>
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '600' }}>
{mm}:{String(ss).padStart(2, '0')}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12 }}>
Recording
</Text>
</View>
<Pressable
onPress={() => stop(true)}
style={({ pressed }) => ({
width: 32, height: 32, borderRadius: 16,
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
})}
>
<Ionicons name="arrow-up" size={18} color="#ffffff" />
</Pressable>
</View>
);
}

View File

@@ -0,0 +1,79 @@
/**
* Группировка сообщений в rows для FlatList чат-экрана.
*
* Чистая функция — никаких React-зависимостей, легко тестируется unit'ом.
*
* Правила:
* 1. Между разными календарными днями вставляется {kind:'sep', label}.
* 2. Внутри одного дня peer-сообщения группируются в "лесенку" с учётом:
* - смены отправителя
* - перерыва > 1 часа между соседними сообщениями
* В пределах одной группы:
* showName = true только у первого
* showAvatar = true только у последнего
* 3. mine-сообщения всегда idle: showName=false, showAvatar=false
* (в референсе X-style никогда не рисуется имя/аватар над своим bubble).
*
* showName/showAvatar всё равно вычисляются — даже если потом render-слой
* их проигнорирует (DM / channel — без sender-meta). Логика кнопки renders
* сама решает показывать ли их, см. MessageBubble → withSenderMeta.
*/
import type { Message } from '@/lib/types';
import { dateBucket } from '@/lib/dates';
export type Row =
| { kind: 'sep'; id: string; label: string }
| {
kind: 'msg';
msg: Message;
showName: boolean;
showAvatar: boolean;
};
// Максимальная пауза внутри "лесенки" — после неё новый run.
const RUN_GAP_SECONDS = 60 * 60; // 1 час
export function buildRows(msgs: Message[]): Row[] {
const out: Row[] = [];
let lastBucket = '';
for (let i = 0; i < msgs.length; i++) {
const m = msgs[i];
const b = dateBucket(m.timestamp);
if (b !== lastBucket) {
out.push({ kind: 'sep', id: `sep_${b}_${m.id}`, label: b });
lastBucket = b;
}
const prev = msgs[i - 1];
const next = msgs[i + 1];
// "Прервать run" флаги:
// - разный день
// - разный отправитель
// - своё vs чужое
// - пауза > 1 часа
const breakBefore =
!prev ||
dateBucket(prev.timestamp) !== b ||
prev.from !== m.from ||
prev.mine !== m.mine ||
(m.timestamp - prev.timestamp) > RUN_GAP_SECONDS;
const breakAfter =
!next ||
dateBucket(next.timestamp) !== b ||
next.from !== m.from ||
next.mine !== m.mine ||
(next.timestamp - m.timestamp) > RUN_GAP_SECONDS;
// Для mine — никогда не показываем имя/аватар.
const showName = m.mine ? false : breakBefore;
const showAvatar = m.mine ? false : breakAfter;
out.push({ kind: 'msg', msg: m, showName, showAvatar });
}
return out;
}

View File

@@ -0,0 +1,480 @@
/**
* PostCard — Twitter-style feed row.
*
* Layout (top-to-bottom, left-to-right):
*
* [avatar 44] [@author · time · ⋯ menu]
* [post text body with #tags + @mentions highlighted]
* [optional attachment preview]
* [💬 0 🔁 link ❤️ likes 👁 views]
*
* Interaction model:
* - Tap anywhere except controls → navigate to post detail
* - Tap author/avatar → profile
* - Double-tap the post body → like (with a short heart-bounce animation)
* - Long-press → context menu (copy, share link, delete-if-mine)
*
* Performance notes:
* - Memoised. Feed lists re-render often (after every like, view bump,
* new post), but each card only needs to update when ITS own stats
* change. We use shallow prop comparison + stable key on post_id.
* - Stats are passed in by parent (fetched once per refresh), not
* fetched here — avoids N /stats requests per timeline render.
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
View, Text, Pressable, Alert, Animated, Image,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { Avatar } from '@/components/Avatar';
import { useStore } from '@/lib/store';
import { getNodeUrl } from '@/lib/api';
import type { FeedPostItem } from '@/lib/feed';
import {
formatRelativeTime, formatCount, likePost, unlikePost, deletePost,
} from '@/lib/feed';
import { ShareSheet } from '@/components/feed/ShareSheet';
export interface PostCardProps {
post: FeedPostItem;
/** true = current user has liked this post (used for filled heart). */
likedByMe?: boolean;
/** Called after a successful like/unlike so parent can refresh stats. */
onStatsChanged?: (postID: string) => void;
/** Called after delete so parent can drop the card from the list. */
onDeleted?: (postID: string) => void;
/** Compact (no attachment, less padding) — used in nested thread context. */
compact?: boolean;
}
function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }: PostCardProps) {
const keyFile = useStore(s => s.keyFile);
const contacts = useStore(s => s.contacts);
// Optimistic local state — immediate response to tap, reconciled after tx.
const [localLiked, setLocalLiked] = useState<boolean>(!!likedByMe);
const [localLikeCount, setLocalLikeCount] = useState<number>(post.likes);
const [busy, setBusy] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
React.useEffect(() => {
setLocalLiked(!!likedByMe);
setLocalLikeCount(post.likes);
}, [likedByMe, post.likes]);
// Heart bounce animation when liked (Twitter-style).
const heartScale = useMemo(() => new Animated.Value(1), []);
const animateHeart = useCallback(() => {
heartScale.setValue(0.6);
Animated.spring(heartScale, {
toValue: 1,
friction: 3,
tension: 120,
useNativeDriver: true,
}).start();
}, [heartScale]);
const mine = !!keyFile && keyFile.pub_key === post.author;
// Find a display-friendly name for the author. If it's a known contact
// with @username, use that; otherwise short-addr.
//
// `mine` takes precedence over the contact lookup: our own pub key has
// a self-contact entry with alias "Saved Messages" (that's how the
// self-chat tile is rendered), but that label is wrong in the feed —
// posts there should read as "You", not as a messaging-app affordance.
const displayName = useMemo(() => {
if (mine) return 'You';
const c = contacts.find(x => x.address === post.author);
if (c?.username) return `@${c.username}`;
if (c?.alias) return c.alias;
return shortAddr(post.author);
}, [contacts, post.author, mine]);
const onToggleLike = useCallback(async () => {
if (!keyFile || busy) return;
setBusy(true);
const wasLiked = localLiked;
// Optimistic update.
setLocalLiked(!wasLiked);
setLocalLikeCount(c => c + (wasLiked ? -1 : 1));
animateHeart();
try {
if (wasLiked) {
await unlikePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
} else {
await likePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
}
// Refresh stats from server so counts reconcile (on-chain is delayed
// by block time; server returns current cached count).
setTimeout(() => onStatsChanged?.(post.post_id), 1500);
} catch (e: any) {
// Roll back optimistic update.
setLocalLiked(wasLiked);
setLocalLikeCount(c => c + (wasLiked ? 1 : -1));
Alert.alert('Failed', String(e?.message ?? e));
} finally {
setBusy(false);
}
}, [keyFile, busy, localLiked, post.post_id, animateHeart, onStatsChanged]);
const onOpenDetail = useCallback(() => {
router.push(`/(app)/feed/${post.post_id}` as never);
}, [post.post_id]);
const onOpenAuthor = useCallback(() => {
router.push(`/(app)/profile/${post.author}` as never);
}, [post.author]);
const onLongPress = useCallback(() => {
if (!keyFile) return;
const options: Array<{ label: string; destructive?: boolean; onPress: () => void }> = [];
if (mine) {
options.push({
label: 'Delete post',
destructive: true,
onPress: () => {
Alert.alert('Delete post?', 'This action cannot be undone.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await deletePost({
from: keyFile.pub_key,
privKey: keyFile.priv_key,
postID: post.post_id,
});
onDeleted?.(post.post_id);
} catch (e: any) {
Alert.alert('Error', String(e?.message ?? e));
}
},
},
]);
},
});
}
if (options.length === 0) return;
const buttons: Array<{ text: string; style?: 'default' | 'cancel' | 'destructive'; onPress?: () => void }> = [
...options.map(o => ({
text: o.label,
style: (o.destructive ? 'destructive' : 'default') as 'default' | 'destructive',
onPress: o.onPress,
})),
{ text: 'Cancel', style: 'cancel' as const },
];
Alert.alert('Actions', '', buttons);
}, [keyFile, mine, post.post_id, onDeleted]);
// Attachment preview URL — native Image can stream straight from the
// hosting relay's /feed/post/{id}/attachment endpoint. `getNodeUrl()`
// returns the node the client is connected to; for cross-relay posts
// that's actually the hosting relay once /api/relays resolution lands
// (Phase D). For now we assume same-node.
const attachmentURL = post.has_attachment
? `${getNodeUrl()}/feed/post/${post.post_id}/attachment`
: null;
// Body content truncation:
// - In the timeline (compact=undefined/false) cap at 5 lines. If the
// text is longer the rest is hidden behind "…" — tapping the card
// opens the detail view where the full body is shown.
// - In post detail (compact=true) show everything.
const bodyLines = compact ? undefined : 5;
return (
<>
{/* Outer container is a plain View so layout styles (padding, row
direction) are static and always applied. Pressable's dynamic
style-function has been observed to drop properties between
renders on some RN versions — we hit that with the FAB, so
we're not relying on it here either. Tap handling lives on the
content-column Pressable (covers ~90% of the card area) plus a
separate Pressable around the avatar. */}
{/* Card = vertical stack: [HEADER row with avatar+name+time] /
[FULL-WIDTH content column with body/image/actions]. Putting
content under the header (rather than in a column next to the
avatar) means body text and attachments occupy the full card
width — no risk of the text running next to the avatar and
clipping off the right edge. */}
<View
style={{
paddingLeft: 16,
paddingRight: 16,
paddingVertical: compact ? 10 : 12,
}}
>
{/* ── HEADER ROW: [avatar] [name · time] [menu] ──────────────── */}
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Pressable onPress={onOpenAuthor} hitSlop={4} style={{ width: 44 }}>
<Avatar name={displayName} address={post.author} size={44} />
</Pressable>
{/* Name + time take all remaining horizontal space in the
header, with the name truncating (numberOfLines:1 +
flexShrink:1) and the "· <time>" tail pinned to stay on
the same line (flexShrink:0). */}
<Pressable
onPress={onOpenAuthor}
hitSlop={{ top: 4, bottom: 2 }}
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', marginLeft: 10, minWidth: 0 }}
>
<Text
numberOfLines={1}
style={{
color: '#ffffff',
fontWeight: '700',
fontSize: 14,
letterSpacing: -0.2,
flexShrink: 1,
}}
>
{displayName}
</Text>
<Text
numberOfLines={1}
style={{
color: '#6a6a6a',
fontSize: 13,
marginLeft: 6,
flexShrink: 0,
}}
>
· {formatRelativeTime(post.created_at)}
</Text>
</Pressable>
{mine && (
<Pressable onPress={onLongPress} hitSlop={8} style={{ marginLeft: 8 }}>
<Ionicons name="ellipsis-horizontal" size={16} color="#6a6a6a" />
</Pressable>
)}
</View>
{/* ── CONTENT (body, attachment, actions) — full card width ──── */}
<Pressable
onPress={onOpenDetail}
onLongPress={onLongPress}
style={({ pressed }) => ({
marginTop: 14,
overflow: 'hidden',
opacity: pressed ? 0.85 : 1,
})}
>
{/* Body text with hashtag highlighting. Full card width now
(we moved it out of the avatar-sibling column) — no special
width tricks needed, normal wrapping just works. */}
{post.content.length > 0 && (
<Text
numberOfLines={bodyLines}
ellipsizeMode="tail"
style={{
color: '#ffffff',
fontSize: 15,
lineHeight: 20,
}}
>
{renderInline(post.content)}
</Text>
)}
{/* Attachment preview.
Timeline (compact=false): aspect-ratio capped at 4:5 so a
portrait photo doesn't occupy the whole screen — extra height
is cropped via resizeMode="cover", full image shows in detail.
Detail (compact=true): contain with no aspect cap → original
proportions preserved. */}
{attachmentURL && (
<View
style={{
marginTop: 10,
borderRadius: 14,
overflow: 'hidden',
backgroundColor: '#0a0a0a',
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
<Image
source={{ uri: attachmentURL }}
style={compact ? {
width: '100%',
aspectRatio: 1, // will be overridden by onLoad if known
maxHeight: undefined,
} : {
width: '100%',
aspectRatio: 4 / 5, // portrait-friendly but bounded
}}
resizeMode={compact ? 'contain' : 'cover'}
/>
</View>
)}
{/* Action row — 3 buttons (Twitter-style). Comments button
intentionally omitted: v2.0.0 doesn't implement replies and
a dead button with "0" adds noise. Heart + views + share
distribute across the row; share pins to the right edge. */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 12,
paddingHorizontal: 12,
}}
>
<View style={{ flex: 1, alignItems: 'flex-start' }}>
<Pressable
onPress={onToggleLike}
disabled={busy}
hitSlop={8}
style={({ pressed }) => ({
flexDirection: 'row', alignItems: 'center', gap: 6,
opacity: pressed ? 0.5 : 1,
})}
>
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
<Ionicons
name={localLiked ? 'heart' : 'heart-outline'}
size={16}
color={localLiked ? '#e0245e' : '#6a6a6a'}
/>
</Animated.View>
<Text
style={{
color: localLiked ? '#e0245e' : '#6a6a6a',
fontSize: 12,
fontWeight: localLiked ? '600' : '400',
lineHeight: 16,
includeFontPadding: false,
textAlignVertical: 'center',
}}
>
{formatCount(localLikeCount)}
</Text>
</Pressable>
</View>
<View style={{ flex: 1, alignItems: 'flex-start' }}>
<ActionButton
icon="eye-outline"
label={formatCount(post.views)}
/>
</View>
<View style={{ alignItems: 'flex-end' }}>
<ActionButton
icon="share-outline"
onPress={() => setShareOpen(true)}
/>
</View>
</View>
</Pressable>
</View>
<ShareSheet
visible={shareOpen}
post={post}
onClose={() => setShareOpen(false)}
/>
</>
);
}
export const PostCard = React.memo(PostCardInner);
/**
* PostSeparator — visible divider line between post cards. Exported so
* every feed surface (timeline, author, hashtag, post detail) can pass
* it as ItemSeparatorComponent and get identical spacing / colour.
*
* Layout: 12px blank space → 1px grey line → 12px blank space. The
* blank space on each side makes the line "float" between posts rather
* than hugging the edge of the card — gives clear visual separation
* without needing big card padding everywhere.
*
* Colour #2a2a2a is the minimum grey that reads on OLED black under
* mobile-bright-mode gamma; darker and the seam vanishes.
*/
export function PostSeparator() {
return (
<View style={{ paddingVertical: 12 }}>
<View style={{ height: 1, backgroundColor: '#2a2a2a' }} />
</View>
);
}
// ── Inline helpers ──────────────────────────────────────────────────────
/** ActionButton — small icon + optional label. */
function ActionButton({ icon, label, onPress }: {
icon: React.ComponentProps<typeof Ionicons>['name'];
label?: string;
onPress?: () => void;
}) {
return (
<Pressable
onPress={onPress}
hitSlop={8}
disabled={!onPress}
style={({ pressed }) => ({
flexDirection: 'row', alignItems: 'center', gap: 6,
opacity: pressed ? 0.5 : 1,
})}
>
<Ionicons name={icon} size={16} color="#6a6a6a" />
{label && (
<Text
style={{
color: '#6a6a6a',
fontSize: 12,
// Match icon height (16) as lineHeight so baseline-anchored
// text aligns with the icon's vertical centre. Without this,
// RN renders Text one pixel lower than the icon mid-point on
// most Android fonts, which looks sloppy next to the heart /
// eye glyphs.
lineHeight: 16,
includeFontPadding: false,
textAlignVertical: 'center',
}}
>
{label}
</Text>
)}
</Pressable>
);
}
/**
* Render post body with hashtag highlighting. Splits by the hashtag regex,
* wraps matches in blue-coloured Text spans that are tappable → hashtag
* feed. For future: @mentions highlighting + URL auto-linking.
*/
function renderInline(text: string): React.ReactNode {
const parts = text.split(/(#[A-Za-z0-9_\u0400-\u04FF]{1,40})/g);
return parts.map((part, i) => {
if (part.startsWith('#')) {
const tag = part.slice(1);
return (
<Text
key={i}
style={{ color: '#1d9bf0' }}
onPress={() => router.push(`/(app)/feed/tag/${encodeURIComponent(tag)}` as never)}
>
{part}
</Text>
);
}
return (
<Text key={i}>{part}</Text>
);
});
}
function shortAddr(a: string, n = 6): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}

View File

@@ -0,0 +1,302 @@
/**
* ShareSheet — bottom-sheet picker that forwards a feed post into one
* (or several) chats. Opens when the user taps the share icon on a
* PostCard.
*
* Design notes
* ------------
* - Single modal component, managed by the parent via `visible` +
* `onClose`. Parent passes the `post` it wants to share.
* - Multi-select: the user can tick several contacts at once and hit
* "Отправить". Fits the common "share with a couple of friends"
* flow better than one-at-a-time.
* - Only contacts with an x25519 key show up — those are the ones we
* can actually encrypt for. An info note explains absent contacts.
* - Search: typing filters the list by username / alias / address
* prefix. Useful once the user has more than a screenful of
* contacts.
*/
import React, { useMemo, useState } from 'react';
import {
View, Text, Pressable, Modal, FlatList, TextInput, ActivityIndicator, Alert,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Avatar } from '@/components/Avatar';
import { useStore } from '@/lib/store';
import type { Contact } from '@/lib/types';
import type { FeedPostItem } from '@/lib/feed';
import { forwardPostToContacts } from '@/lib/forwardPost';
export interface ShareSheetProps {
visible: boolean;
post: FeedPostItem | null;
onClose: () => void;
}
export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
const insets = useSafeAreaInsets();
const contacts = useStore(s => s.contacts);
const keyFile = useStore(s => s.keyFile);
const [query, setQuery] = useState('');
const [picked, setPicked] = useState<Set<string>>(new Set());
const [sending, setSending] = useState(false);
const available = useMemo(() => {
const q = query.trim().toLowerCase();
const withKeys = contacts.filter(c => !!c.x25519Pub);
if (!q) return withKeys;
return withKeys.filter(c =>
(c.username ?? '').toLowerCase().includes(q) ||
(c.alias ?? '').toLowerCase().includes(q) ||
c.address.toLowerCase().startsWith(q),
);
}, [contacts, query]);
const toggle = (address: string) => {
setPicked(prev => {
const next = new Set(prev);
if (next.has(address)) next.delete(address);
else next.add(address);
return next;
});
};
const doSend = async () => {
if (!post || !keyFile) return;
const targets = contacts.filter(c => picked.has(c.address));
if (targets.length === 0) return;
setSending(true);
try {
const { ok, failed } = await forwardPostToContacts({
post, contacts: targets, keyFile,
});
if (failed > 0) {
Alert.alert('Done', `Sent to ${ok} of ${ok + failed} ${plural(ok + failed)}.`);
}
// Close + reset regardless — done is done.
setPicked(new Set());
setQuery('');
onClose();
} catch (e: any) {
Alert.alert('Failed', String(e?.message ?? e));
} finally {
setSending(false);
}
};
const closeAndReset = () => {
setPicked(new Set());
setQuery('');
onClose();
};
return (
<Modal
visible={visible}
animationType="slide"
transparent
onRequestClose={closeAndReset}
>
{/* Dim backdrop — tap to dismiss */}
<Pressable
onPress={closeAndReset}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.72)', justifyContent: 'flex-end' }}
>
{/* Sheet body — stopPropagation so inner taps don't dismiss */}
<Pressable
onPress={(e) => e.stopPropagation?.()}
style={{
backgroundColor: '#0a0a0a',
borderTopLeftRadius: 22,
borderTopRightRadius: 22,
paddingTop: 10,
paddingBottom: Math.max(insets.bottom, 10) + 10,
maxHeight: '78%',
borderTopWidth: 1,
borderTopColor: '#1f1f1f',
}}
>
{/* Drag handle */}
<View
style={{
alignSelf: 'center',
width: 44, height: 4,
borderRadius: 2,
backgroundColor: '#2a2a2a',
marginBottom: 10,
}}
/>
{/* Title row */}
<View style={{
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 16, marginBottom: 10,
}}>
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}>
Share post
</Text>
<View style={{ flex: 1 }} />
<Pressable onPress={closeAndReset} hitSlop={8}>
<Ionicons name="close" size={22} color="#8b8b8b" />
</Pressable>
</View>
{/* Search */}
<View style={{ paddingHorizontal: 16, marginBottom: 10 }}>
<View style={{
flexDirection: 'row', alignItems: 'center',
backgroundColor: '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 12,
paddingHorizontal: 10,
gap: 6,
}}>
<Ionicons name="search" size={14} color="#6a6a6a" />
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search contacts"
placeholderTextColor="#5a5a5a"
style={{
flex: 1,
color: '#ffffff',
fontSize: 14,
paddingVertical: 10,
}}
autoCorrect={false}
autoCapitalize="none"
/>
{query.length > 0 && (
<Pressable onPress={() => setQuery('')} hitSlop={6}>
<Ionicons name="close-circle" size={16} color="#6a6a6a" />
</Pressable>
)}
</View>
</View>
{/* Contact list */}
<FlatList
data={available}
keyExtractor={c => c.address}
renderItem={({ item }) => (
<ContactRow
contact={item}
checked={picked.has(item.address)}
onToggle={() => toggle(item.address)}
/>
)}
ListEmptyComponent={
<View style={{
paddingVertical: 40,
alignItems: 'center',
}}>
<Ionicons name="people-outline" size={28} color="#5a5a5a" />
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 10 }}>
{query.length > 0
? 'No contacts match this search'
: 'No contacts with encryption keys yet'}
</Text>
</View>
}
/>
{/* Send button */}
<View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
<Pressable
onPress={doSend}
disabled={picked.size === 0 || sending}
style={({ pressed }) => ({
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 13,
borderRadius: 999,
backgroundColor:
picked.size === 0 ? '#1f1f1f'
: pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{sending ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text style={{
color: picked.size === 0 ? '#5a5a5a' : '#ffffff',
fontWeight: '700',
fontSize: 14,
}}>
{picked.size === 0
? 'Select contacts'
: `Send (${picked.size})`}
</Text>
)}
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
}
// ── Row ─────────────────────────────────────────────────────────────────
function ContactRow({ contact, checked, onToggle }: {
contact: Contact;
checked: boolean;
onToggle: () => void;
}) {
const name = contact.username
? `@${contact.username}`
: contact.alias ?? shortAddr(contact.address);
return (
<Pressable
onPress={onToggle}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: pressed ? '#111111' : 'transparent',
})}
>
<Avatar name={name} address={contact.address} size={38} />
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
<Text
numberOfLines={1}
style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}
>
{name}
</Text>
<Text
numberOfLines={1}
style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}
>
{shortAddr(contact.address, 8)}
</Text>
</View>
{/* Checkbox indicator */}
<View
style={{
width: 22, height: 22,
borderRadius: 11,
borderWidth: 1.5,
borderColor: checked ? '#1d9bf0' : '#2a2a2a',
backgroundColor: checked ? '#1d9bf0' : 'transparent',
alignItems: 'center', justifyContent: 'center',
}}
>
{checked && <Ionicons name="checkmark" size={14} color="#ffffff" />}
</View>
</Pressable>
);
}
function shortAddr(a: string, n = 6): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
function plural(n: number): string {
return n === 1 ? 'chat' : 'chats';
}

22
client-app/eas.json Normal file
View File

@@ -0,0 +1,22 @@
{
"cli": {
"version": ">= 18.7.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"android": { "buildType": "apk" }
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

3
client-app/global.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,94 @@
/**
* Balance hook — uses the WebSocket gateway to receive instant updates when
* a tx involving the current address is committed, with HTTP polling as a
* graceful fallback for old nodes that don't expose /api/ws.
*
* Flow:
* 1. On mount: immediate HTTP fetch so the UI has a non-zero balance ASAP
* 2. Subscribe to `addr:<my_pubkey>` on the WS hub
* 3. On every `tx` event, re-fetch balance (cheap — one Badger read server-side)
* 4. If WS disconnects for >15s, fall back to 10-second polling until it reconnects
*/
import { useEffect, useCallback, useRef } from 'react';
import { getBalance } from '@/lib/api';
import { getWSClient } from '@/lib/ws';
import { useStore } from '@/lib/store';
const FALLBACK_POLL_INTERVAL = 10_000; // HTTP poll when WS is down
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
export function useBalance() {
const keyFile = useStore(s => s.keyFile);
const setBalance = useStore(s => s.setBalance);
const refresh = useCallback(async () => {
if (!keyFile) return;
try {
const bal = await getBalance(keyFile.pub_key);
setBalance(bal);
} catch {
// transient — next call will retry
}
}, [keyFile, setBalance]);
// --- fallback polling management ---
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const disconnectSinceRef = useRef<number | null>(null);
const disconnectTORef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startPolling = useCallback(() => {
if (pollTimerRef.current) return;
console.log('[useBalance] WS down for grace period — starting HTTP poll');
refresh();
pollTimerRef.current = setInterval(refresh, FALLBACK_POLL_INTERVAL);
}, [refresh]);
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
if (disconnectTORef.current) {
clearTimeout(disconnectTORef.current);
disconnectTORef.current = null;
}
disconnectSinceRef.current = null;
}, []);
useEffect(() => {
if (!keyFile) return;
const ws = getWSClient();
// Immediate HTTP fetch so the UI is not empty while the WS hello arrives.
refresh();
// Refresh balance whenever a tx for our address is committed.
const offTx = ws.subscribe('addr:' + keyFile.pub_key, (frame) => {
if (frame.event === 'tx') {
refresh();
}
});
// Manage fallback polling based on WS connection state.
const offConn = ws.onConnectionChange((ok) => {
if (ok) {
stopPolling();
refresh(); // catch up anything we missed while disconnected
} else if (disconnectTORef.current === null) {
disconnectSinceRef.current = Date.now();
disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING);
}
});
ws.connect();
return () => {
offTx();
offConn();
stopPolling();
};
}, [keyFile, refresh, startPolling, stopPolling]);
return { refresh };
}

View File

@@ -0,0 +1,52 @@
/**
* useConnectionStatus — объединённое состояние подключения клиента к сети.
*
* Определяет один из трёх стейтов:
* - 'offline' — нет интернета по данным NetInfo
* - 'connecting' — интернет есть, но WebSocket к ноде не подключён
* - 'online' — WebSocket к ноде активен
*
* Используется в headers (например Messages → "Connecting...",
* "Waiting for internet") и на profile-avatar'ах как индикатор
* живости.
*
* NetInfo использует connected + internetReachable для детекта
* настоящего Internet (не просто Wi-Fi SSID без доступа); fallback
* на `connected`-only когда internetReachable неопределён (некоторые
* корпоративные сети или Android в первые секунды).
*/
import { useEffect, useState } from 'react';
import NetInfo from '@react-native-community/netinfo';
import { getWSClient } from '@/lib/ws';
export type ConnectionStatus = 'online' | 'connecting' | 'offline';
export function useConnectionStatus(): ConnectionStatus {
const [wsLive, setWsLive] = useState(false);
const [hasNet, setHasNet] = useState(true);
// WS live-state: subscribe к его onConnectionChange.
useEffect(() => {
const ws = getWSClient();
setWsLive(ws.isConnected());
return ws.onConnectionChange(setWsLive);
}, []);
// Internet reachability: через NetInfo.
useEffect(() => {
const unsub = NetInfo.addEventListener((state) => {
// internetReachable = null значит "ещё не проверили" — считаем
// что есть, чтобы не ложно отображать "offline" на старте.
const reachable =
state.isInternetReachable === false ? false :
state.isConnected === false ? false :
true;
setHasNet(reachable);
});
return unsub;
}, []);
if (!hasNet) return 'offline';
if (wsLive) return 'online';
return 'connecting';
}

View File

@@ -0,0 +1,80 @@
/**
* Contacts + inbound request tracking.
*
* - Loads cached contacts from local storage on boot.
* - Subscribes to the address WS topic so a new CONTACT_REQUEST pulls the
* relay contact list immediately (sub-second UX).
* - Keeps a 30 s polling fallback for nodes without WS or while disconnected.
*/
import { useEffect, useCallback } from 'react';
import { fetchContactRequests } from '@/lib/api';
import { getWSClient } from '@/lib/ws';
import { loadContacts } from '@/lib/storage';
import { useStore } from '@/lib/store';
const FALLBACK_POLL_INTERVAL = 30_000;
export function useContacts() {
const keyFile = useStore(s => s.keyFile);
const setContacts = useStore(s => s.setContacts);
const setRequests = useStore(s => s.setRequests);
const contacts = useStore(s => s.contacts);
// Load cached contacts from local storage once
useEffect(() => {
loadContacts().then(setContacts);
}, [setContacts]);
const pollRequests = useCallback(async () => {
if (!keyFile) return;
try {
const raw = await fetchContactRequests(keyFile.pub_key);
// Filter out already-accepted contacts
const contactAddresses = new Set(contacts.map(c => c.address));
const requests = raw
.filter(r => r.status === 'pending' && !contactAddresses.has(r.requester_pub))
.map(r => ({
from: r.requester_pub,
// x25519Pub will be fetched from identity when user taps Accept
x25519Pub: '',
intro: r.intro ?? '',
timestamp: r.created_at,
txHash: r.tx_id,
}));
setRequests(requests);
} catch {
// Ignore transient network errors
}
}, [keyFile, contacts, setRequests]);
useEffect(() => {
if (!keyFile) return;
const ws = getWSClient();
// Initial load + low-frequency fallback poll (covers missed WS events,
// works even when the node has no WS endpoint).
pollRequests();
const interval = setInterval(pollRequests, FALLBACK_POLL_INTERVAL);
// Immediate refresh when a CONTACT_REQUEST / ACCEPT_CONTACT tx addressed
// to us lands on-chain. WS fan-out already filters to our address topic.
const off = ws.subscribe('addr:' + keyFile.pub_key, (frame) => {
if (frame.event === 'tx') {
const d = frame.data as { tx_type?: string } | undefined;
if (d?.tx_type === 'CONTACT_REQUEST' || d?.tx_type === 'ACCEPT_CONTACT') {
pollRequests();
}
}
});
ws.connect();
return () => {
clearInterval(interval);
off();
};
}, [keyFile, pollRequests]);
}

View File

@@ -0,0 +1,124 @@
/**
* useGlobalInbox — app-wide inbox listener.
*
* Подписан на WS-топик `inbox:<my_x25519>` при любом экране внутри
* (app)-группы. Когда приходит push с envelope, мы:
* 1. Декриптуем — если это наш контакт, добавляем в store.
* 2. Инкрементим unreadByContact[address].
* 3. Показываем local notification (от кого + счётчик).
*
* НЕ дублирует chat-detail'овский `useMessages` — тот делает initial
* HTTP-pull при открытии чата и слушает тот же топик (двойная подписка
* с фильтром по sender_pub). Оба держат в store консистентное состояние
* через `appendMessage` (который идемпотентен по id).
*
* Фильтрация "app backgrounded" не нужна: Expo notifications'handler
* показывает banner и в foreground, но при активном чате с этим
* контактом нотификация dismiss'ится автоматически через
* clearContactNotifications (вызывается при mount'е chats/[id]).
*/
import { useEffect, useRef } from 'react';
import { AppState } from 'react-native';
import { usePathname } from 'expo-router';
import { useStore } from '@/lib/store';
import { getWSClient } from '@/lib/ws';
import { decryptMessage } from '@/lib/crypto';
import { tryParsePostRef } from '@/lib/forwardPost';
import { fetchInbox } from '@/lib/api';
import { appendMessage } from '@/lib/storage';
import { randomId } from '@/lib/utils';
import { notifyIncoming } from './useNotifications';
export function useGlobalInbox() {
const keyFile = useStore(s => s.keyFile);
const contacts = useStore(s => s.contacts);
const appendMsg = useStore(s => s.appendMessage);
const incrementUnread = useStore(s => s.incrementUnread);
const pathname = usePathname();
const contactsRef = useRef(contacts);
const pathnameRef = useRef(pathname);
useEffect(() => { contactsRef.current = contacts; }, [contacts]);
useEffect(() => { pathnameRef.current = pathname; }, [pathname]);
useEffect(() => {
if (!keyFile?.x25519_pub) return;
const ws = getWSClient();
const handleEnvelopePull = async () => {
try {
const envelopes = await fetchInbox(keyFile.x25519_pub);
for (const env of envelopes) {
// Найти контакт по sender_pub — если не знакомый, игнорим
// (для MVP; в future можно показывать "unknown sender").
const c = contactsRef.current.find(
x => x.x25519Pub === env.sender_pub,
);
if (!c) continue;
let text = '';
try {
text = decryptMessage(
env.ciphertext,
env.nonce,
env.sender_pub,
keyFile.x25519_priv,
) ?? '';
} catch {
continue;
}
if (!text) continue;
// Стабильный id от сервера (sha256(nonce||ct)[:16]); fallback
// на nonce-префикс если вдруг env.id пустой.
const msgId = env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`;
const postRef = tryParsePostRef(text);
const msg = {
id: msgId,
from: env.sender_pub,
text: postRef ? '' : text,
timestamp: env.timestamp,
mine: false,
...(postRef && {
postRef: {
postID: postRef.post_id,
author: postRef.author,
excerpt: postRef.excerpt,
hasImage: postRef.has_image,
},
}),
};
appendMsg(c.address, msg);
await appendMessage(c.address, msg);
// Если пользователь прямо сейчас в этом чате — unread не инкрементим,
// notification не показываем.
const inThisChat =
pathnameRef.current === `/chats/${c.address}` ||
pathnameRef.current.startsWith(`/chats/${c.address}/`);
if (inThisChat && AppState.currentState === 'active') continue;
incrementUnread(c.address);
const unread = useStore.getState().unreadByContact[c.address] ?? 1;
notifyIncoming({
contactAddress: c.address,
senderName: c.username ? `@${c.username}` : (c.alias ?? 'New message'),
unreadCount: unread,
});
}
} catch {
/* silent — ошибки pull'а обрабатывает useMessages */
}
};
const off = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
if (frame.event !== 'inbox') return;
handleEnvelopePull();
});
return off;
}, [keyFile, appendMsg, incrementUnread]);
}

View File

@@ -0,0 +1,159 @@
/**
* Subscribe to the relay inbox via WebSocket and decrypt incoming envelopes
* for the active chat. Falls back to 30-second polling whenever the WS is
* not connected — preserves correctness on older nodes or flaky networks.
*
* Flow:
* 1. On mount: one HTTP fetch so we have whatever is already in the inbox
* 2. Subscribe to topic `inbox:<my_x25519>` — the node pushes a summary
* for each fresh envelope as soon as mailbox.Store() succeeds
* 3. On each push, pull the full envelope list (cheap — bounded by
* MailboxPerRecipientCap) and decrypt anything we haven't seen yet
* 4. If WS disconnects for > 15 seconds, start a 30 s HTTP poll until it
* reconnects
*/
import { useEffect, useCallback, useRef } from 'react';
import { fetchInbox } from '@/lib/api';
import { getWSClient } from '@/lib/ws';
import { decryptMessage } from '@/lib/crypto';
import { appendMessage, loadMessages } from '@/lib/storage';
import { useStore } from '@/lib/store';
import { tryParsePostRef } from '@/lib/forwardPost';
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
export function useMessages(contactX25519: string) {
const keyFile = useStore(s => s.keyFile);
const appendMsg = useStore(s => s.appendMessage);
// Подгружаем кэш сообщений из AsyncStorage при открытии чата.
// Релей держит envelope'ы всего 7 дней, поэтому без чтения кэша
// история старше недели пропадает при каждом рестарте приложения.
// appendMsg в store идемпотентен по id, поэтому безопасно гонять его
// для каждого кэшированного сообщения.
useEffect(() => {
if (!contactX25519) return;
let cancelled = false;
loadMessages(contactX25519).then(cached => {
if (cancelled) return;
for (const m of cached) appendMsg(contactX25519, m);
}).catch(() => { /* cache miss / JSON error — not fatal */ });
return () => { cancelled = true; };
}, [contactX25519, appendMsg]);
const pullAndDecrypt = useCallback(async () => {
if (!keyFile || !contactX25519) return;
try {
const envelopes = await fetchInbox(keyFile.x25519_pub);
for (const env of envelopes) {
// Only process messages from this contact
if (env.sender_pub !== contactX25519) continue;
const text = decryptMessage(
env.ciphertext,
env.nonce,
env.sender_pub,
keyFile.x25519_priv,
);
if (!text) continue;
// Detect forwarded feed posts — plaintext is a tiny JSON
// envelope (see lib/forwardPost.ts). Regular text messages
// stay as-is.
const postRef = tryParsePostRef(text);
const msg = {
id: env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`,
from: env.sender_pub,
text: postRef ? '' : text,
timestamp: env.timestamp,
mine: false,
...(postRef && {
postRef: {
postID: postRef.post_id,
author: postRef.author,
excerpt: postRef.excerpt,
hasImage: postRef.has_image,
},
}),
};
appendMsg(contactX25519, msg);
await appendMessage(contactX25519, msg);
}
} catch (e: any) {
// Шумные ошибки (404 = нет mailbox'а, Network request failed =
// нода недоступна) — ожидаемы в dev-среде и при offline-режиме,
// не спамим console. Остальное — логируем.
const msg = String(e?.message ?? e ?? '');
if (/→\s*404\b/.test(msg)) return;
if (/ 404\b/.test(msg)) return;
if (/Network request failed/i.test(msg)) return;
if (/Failed to fetch/i.test(msg)) return;
console.warn('[useMessages] pull error:', e);
}
}, [keyFile, contactX25519, appendMsg]);
// ── Fallback polling state ────────────────────────────────────────────
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const disconnectTORef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startPolling = useCallback(() => {
if (pollTimerRef.current) return;
console.log('[useMessages] WS down — starting HTTP poll fallback');
pullAndDecrypt();
pollTimerRef.current = setInterval(pullAndDecrypt, FALLBACK_POLL_INTERVAL);
}, [pullAndDecrypt]);
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
if (disconnectTORef.current) {
clearTimeout(disconnectTORef.current);
disconnectTORef.current = null;
}
}, []);
useEffect(() => {
if (!keyFile || !contactX25519) return;
const ws = getWSClient();
// Initial fetch — populate whatever landed before we mounted.
pullAndDecrypt();
// Subscribe to our x25519 inbox — the node emits on mailbox.Store.
// Topic filter: only envelopes for ME; we then filter by sender inside
// the handler so we only render messages in THIS chat.
const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
if (frame.event !== 'inbox') return;
const d = frame.data as { sender_pub?: string } | undefined;
// Optimisation: if the envelope is from a different peer, skip the
// whole refetch — we'd just drop it in the sender filter below anyway.
if (d?.sender_pub && d.sender_pub !== contactX25519) return;
pullAndDecrypt();
});
// Manage fallback polling based on WS connection state.
const offConn = ws.onConnectionChange((ok) => {
if (ok) {
stopPolling();
// Catch up anything we missed while disconnected.
pullAndDecrypt();
} else if (disconnectTORef.current === null) {
disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING);
}
});
ws.connect();
return () => {
offInbox();
offConn();
stopPolling();
};
}, [keyFile, contactX25519, pullAndDecrypt, startPolling, stopPolling]);
}

View File

@@ -0,0 +1,144 @@
/**
* useNotifications — bootstrap expo-notifications (permission + handler)
* и routing при tap'е на notification → открыть конкретный чат.
*
* ВАЖНО: expo-notifications в Expo Go (SDK 53+) валит error при САМОМ
* import'е модуля ("Android Push notifications ... removed from Expo Go").
* Поэтому мы НЕ делаем static `import * as Notifications from ...` —
* вместо этого lazy `require()` внутри функций, только если мы вне Expo Go.
* На dev-build / production APK всё работает штатно.
*
* Privacy: notification содержит ТОЛЬКО имя отправителя и счётчик
* непрочитанных. Тело сообщения НЕ показывается — для E2E-мессенджера
* это критично (push нотификации проходят через OS / APNs и могут
* логироваться).
*/
import { useEffect } from 'react';
import { Platform } from 'react-native';
import Constants, { ExecutionEnvironment } from 'expo-constants';
import { router } from 'expo-router';
// В Expo Go push-нотификации отключены с SDK 53. Любое обращение к
// expo-notifications (включая import) пишет error в консоль. Детектим
// среду один раз на module-load.
const IS_EXPO_GO =
Constants.executionEnvironment === ExecutionEnvironment.StoreClient;
/**
* Lazy-load expo-notifications. Возвращает модуль или null в Expo Go.
* Кешируем результат, чтобы не делать require повторно.
*/
let _cached: any | null | undefined = undefined;
function getNotifications(): any | null {
if (_cached !== undefined) return _cached;
if (IS_EXPO_GO) { _cached = null; return null; }
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
_cached = require('expo-notifications');
} catch {
_cached = null;
}
return _cached;
}
// Handler ставим лениво при первом обращении (а не на module-top'е),
// т.к. require сам по себе подтянет модуль — в Expo Go его не дергаем.
let _handlerInstalled = false;
function installHandler() {
if (_handlerInstalled) return;
const N = getNotifications();
if (!N) return;
N.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: true,
}),
});
_handlerInstalled = true;
}
export function useNotifications() {
useEffect(() => {
const N = getNotifications();
if (!N) return; // Expo Go — no-op
installHandler();
(async () => {
try {
const existing = await N.getPermissionsAsync();
if (existing.status !== 'granted' && existing.canAskAgain !== false) {
await N.requestPermissionsAsync();
}
if (Platform.OS === 'android') {
// Channel — обязателен для Android 8+ чтобы уведомления показывались.
await N.setNotificationChannelAsync('messages', {
name: 'Messages',
importance: N.AndroidImportance.HIGH,
vibrationPattern: [0, 200, 100, 200],
lightColor: '#1d9bf0',
sound: undefined,
});
}
} catch {
// fail-safe — notifications не критичны
}
})();
// Tap-to-open listener.
const sub = N.addNotificationResponseReceivedListener((resp: any) => {
const data = resp?.notification?.request?.content?.data as
{ contactAddress?: string } | undefined;
if (data?.contactAddress) {
router.push(`/(app)/chats/${data.contactAddress}` as never);
}
});
return () => { try { sub.remove(); } catch { /* ignore */ } };
}, []);
}
/**
* Показать локальное уведомление о новом сообщении. Вызывается из
* global-inbox-listener'а когда приходит envelope от peer'а.
*
* content не содержит текста — только "New message" как generic label
* (см. privacy-note в doc'е выше).
*/
export async function notifyIncoming(params: {
contactAddress: string;
senderName: string;
unreadCount: number;
}) {
const N = getNotifications();
if (!N) return; // Expo Go — no-op
const { contactAddress, senderName, unreadCount } = params;
try {
await N.scheduleNotificationAsync({
identifier: `inbox:${contactAddress}`, // replaces previous for same contact
content: {
title: senderName,
body: unreadCount === 1
? 'New message'
: `${unreadCount} new messages`,
data: { contactAddress },
},
trigger: null, // immediate
});
} catch {
// Fail silently — если OS не дала permission, notification не
// покажется. Не ломаем send-flow.
}
}
/** Dismiss notification для контакта (вызывается когда чат открыт). */
export async function clearContactNotifications(contactAddress: string) {
const N = getNotifications();
if (!N) return;
try {
await N.dismissNotificationAsync(`inbox:${contactAddress}`);
} catch { /* ignore */ }
}

View File

@@ -0,0 +1,61 @@
/**
* Auto-discover canonical system contracts from the node so the user doesn't
* have to paste contract IDs into settings by hand.
*
* Flow:
* 1. On app boot (and whenever nodeUrl changes), call GET /api/well-known-contracts
* 2. If the node advertises a `username_registry` and the user has not
* manually set `settings.contractId`, auto-populate it and persist.
* 3. A user-supplied contractId is never overwritten — so power users can
* still pin a non-canonical deployment from settings.
*/
import { useEffect } from 'react';
import { fetchWellKnownContracts } from '@/lib/api';
import { saveSettings } from '@/lib/storage';
import { useStore } from '@/lib/store';
export function useWellKnownContracts() {
const nodeUrl = useStore(s => s.settings.nodeUrl);
const contractId = useStore(s => s.settings.contractId);
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
useEffect(() => {
let cancelled = false;
async function run() {
if (!nodeUrl) return;
const res = await fetchWellKnownContracts();
if (cancelled || !res) return;
const registry = res.contracts['username_registry'];
if (!registry) return;
// Always keep the stored contractId in sync with what the node reports
// as canonical. If the user resets their chain or we migrate from a
// WASM contract to the native one, the stale contract_id cached in
// local storage would otherwise keep the client trying to call a
// contract that no longer exists on this chain.
//
// To still support intentional overrides: the UI's "advanced" section
// allows pasting a specific ID — and since that also writes to
// settings.contractId, the loop converges back to whatever the node
// says after a short delay. Operators who want a hard override should
// either run a patched node or pin the value with a wrapper config
// outside the app.
if (registry.contract_id !== contractId) {
const next = { ...settings, contractId: registry.contract_id };
setSettings({ contractId: registry.contract_id });
await saveSettings(next);
console.log('[well-known] synced username_registry =', registry.contract_id,
'(was:', contractId || '<empty>', ')');
}
}
run();
return () => { cancelled = true; };
// Re-run when the node URL changes (user switched networks) or when
// contractId is cleared.
}, [nodeUrl, contractId]); // eslint-disable-line react-hooks/exhaustive-deps
}

851
client-app/lib/api.ts Normal file
View File

@@ -0,0 +1,851 @@
/**
* DChain REST API client.
* All requests go to the configured node URL (e.g. http://192.168.1.10:8081).
*/
import type { Envelope, TxRecord, NetStats, Contact } from './types';
import { base64ToBytes, bytesToBase64, bytesToHex, hexToBytes } from './crypto';
// ─── Base ─────────────────────────────────────────────────────────────────────
let _nodeUrl = 'http://localhost:8081';
/**
* Listeners invoked AFTER _nodeUrl changes. The WS client registers here so
* that switching nodes in Settings tears down the old socket and re-dials
* the new one (without this, a user who pointed their app at node A would
* keep receiving A's events forever after flipping to B).
*/
const nodeUrlListeners = new Set<(url: string) => void>();
export function setNodeUrl(url: string) {
const normalised = url.replace(/\/$/, '');
if (_nodeUrl === normalised) return;
_nodeUrl = normalised;
for (const fn of nodeUrlListeners) {
try { fn(_nodeUrl); } catch { /* ignore — listeners are best-effort */ }
}
}
export function getNodeUrl(): string {
return _nodeUrl;
}
/** Register a callback for node-URL changes. Returns an unsubscribe fn. */
export function onNodeUrlChange(fn: (url: string) => void): () => void {
nodeUrlListeners.add(fn);
return () => { nodeUrlListeners.delete(fn); };
}
async function get<T>(path: string): Promise<T> {
const res = await fetch(`${_nodeUrl}${path}`);
if (!res.ok) throw new Error(`GET ${path}${res.status}`);
return res.json() as Promise<T>;
}
/**
* Enhanced error reporter for POST failures. The node's `jsonErr` writes
* `{"error": "..."}` as the response body; we parse that out so the UI layer
* can show a meaningful message instead of a raw status code.
*
* Rate-limit and timestamp-skew rejections produce specific strings the UI
* can translate to user-friendly Russian via matcher functions below.
*/
async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${_nodeUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
// Try to extract {"error":"..."} payload for a cleaner message.
let detail = text;
try {
const parsed = JSON.parse(text);
if (parsed?.error) detail = parsed.error;
} catch { /* keep raw text */ }
// Include HTTP status so `humanizeTxError` can branch on 429/400/etc.
throw new Error(`${res.status}: ${detail}`);
}
return res.json() as Promise<T>;
}
/**
* Turn a submission error from `post()` / `submitTx()` into a user-facing
* Russian message with actionable hints. Preserves the raw detail at the end
* so advanced users can still copy the original for support.
*/
export function humanizeTxError(e: unknown): string {
const raw = e instanceof Error ? e.message : String(e);
if (raw.startsWith('429')) {
return 'Too many requests to the node. Wait a couple of seconds and try again.';
}
if (raw.startsWith('400') && raw.includes('timestamp')) {
return 'Device clock is out of sync with the node. Check the time on your phone (±1 hour).';
}
if (raw.startsWith('400') && raw.includes('signature')) {
return 'Transaction signature is invalid. Try again; if this persists the client and node versions may be incompatible.';
}
if (raw.startsWith('400')) {
return `Node rejected transaction: ${raw.replace(/^400:\s*/, '')}`;
}
if (raw.startsWith('5')) {
return `Node error (${raw}). Please try again later.`;
}
// Network-level
if (raw.toLowerCase().includes('network request failed')) {
return 'Cannot reach the node. Check the URL in settings and that the server is online.';
}
return raw;
}
// ─── Chain API ────────────────────────────────────────────────────────────────
export async function getNetStats(): Promise<NetStats> {
return get<NetStats>('/api/netstats');
}
interface AddrResponse {
balance_ut: number;
balance: string;
transactions: Array<{
id: string;
type: string;
from: string;
to?: string;
amount_ut: number;
fee_ut: number;
time: string; // ISO-8601 e.g. "2025-01-01T12:00:00Z"
block_index: number;
}>;
tx_count: number;
has_more: boolean;
}
export async function getBalance(pubkey: string): Promise<number> {
const data = await get<AddrResponse>(`/api/address/${pubkey}`);
return data.balance_ut ?? 0;
}
/**
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction JSON.
* Key facts:
* - `payload` is base64-encoded JSON bytes (Go []byte → base64 in JSON)
* - `signature` is base64-encoded Ed25519 sig (Go []byte → base64 in JSON)
* - `timestamp` is RFC3339 string (Go time.Time → string in JSON)
* - There is NO nonce field; dedup is by `id`
*/
export interface RawTx {
id: string; // "tx-<nanoseconds>" or sha256-based
type: string; // "TRANSFER", "CONTACT_REQUEST", etc.
from: string; // hex Ed25519 pub key
to: string; // hex Ed25519 pub key (empty string if N/A)
amount: number; // µT (uint64)
fee: number; // µT (uint64)
memo?: string; // optional
payload: string; // base64(json.Marshal(TypeSpecificPayload))
signature: string; // base64(ed25519.Sign(canonical_bytes, priv))
timestamp: string; // RFC3339 e.g. "2025-01-01T12:00:00Z"
}
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
console.log('[submitTx] →', {
id: tx.id,
type: tx.type,
from: tx.from.slice(0, 12) + '…',
to: tx.to ? tx.to.slice(0, 12) + '…' : '',
amount: tx.amount,
fee: tx.fee,
timestamp: tx.timestamp,
transport: 'auto',
});
// Try the WebSocket path first: no HTTP round-trip, and we get a proper
// submit_ack correlated back to our tx id. Falls through to HTTP if WS is
// unavailable (old node, disconnected, timeout, etc.) so legacy setups
// keep working.
try {
// Lazy import avoids a circular dep with lib/ws.ts (which itself
// imports getNodeUrl from this module).
const { getWSClient } = await import('./ws');
const ws = getWSClient();
if (ws.isConnected()) {
try {
const res = await ws.submitTx(tx);
console.log('[submitTx] ← accepted via WS', res);
return { id: res.id || tx.id, status: 'accepted' };
} catch (e) {
console.warn('[submitTx] WS path failed, falling back to HTTP:', e);
}
}
} catch { /* circular import edge case — ignore and use HTTP */ }
try {
const res = await post<{ id: string; status: string }>('/api/tx', tx);
console.log('[submitTx] ← accepted via HTTP', res);
return res;
} catch (e) {
console.warn('[submitTx] ← rejected', e);
throw e;
}
}
/**
* Full transaction detail as returned by GET /api/tx/{id}. Matches the
* explorer's txDetail wire format. Payload is JSON-decoded when the
* node recognises the tx type, otherwise payload_hex is set.
*/
export interface TxDetail {
id: string;
type: string;
memo?: string;
from: string;
from_addr?: string;
to?: string;
to_addr?: string;
amount_ut: number;
amount: string;
fee_ut: number;
fee: string;
time: string; // ISO-8601 UTC
block_index: number;
block_hash: string;
block_time: string; // ISO-8601 UTC
gas_used?: number;
payload?: unknown;
payload_hex?: string;
signature_hex?: string;
}
/** Fetch full tx detail by hash/id. Returns null on 404. */
export async function getTxDetail(txID: string): Promise<TxDetail | null> {
try {
return await get<TxDetail>(`/api/tx/${txID}`);
} catch (e: any) {
if (/→\s*404\b/.test(String(e?.message))) return null;
throw e;
}
}
export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord[]> {
const data = await get<AddrResponse>(`/api/address/${pubkey}?limit=${limit}`);
return (data.transactions ?? []).map(tx => ({
hash: tx.id,
type: tx.type,
from: tx.from,
to: tx.to,
amount: tx.amount_ut,
fee: tx.fee_ut,
// Convert ISO-8601 string → unix seconds
timestamp: tx.time ? Math.floor(new Date(tx.time).getTime() / 1000) : 0,
status: 'confirmed' as const,
}));
}
// ─── Relay API ────────────────────────────────────────────────────────────────
//
// Endpoints are mounted at the ROOT of the node HTTP server (not under /api):
// POST /relay/broadcast — publish pre-sealed envelope (proper E2E)
// GET /relay/inbox — fetch envelopes addressed to <pub>
//
// Why /relay/broadcast, not /relay/send?
// /relay/send takes plaintext (msg_b64) и SEAL'ит его ключом релей-ноды —
// это ломает end-to-end шифрование (получатель не сможет расшифровать
// своим ключом). Для E2E всегда используем /relay/broadcast с уже
// запечатанным на клиенте envelope'ом.
/**
* Shape of envelope item returned by GET /relay/inbox (server item type).
* Go `[]byte` поля сериализуются как base64 в JSON — поэтому `nonce` и
* `ciphertext` приходят base64, а не hex. Мы декодируем их в hex для
* совместимости с crypto.ts (decryptMessage принимает hex).
*/
interface InboxItemWire {
id: string;
sender_pub: string;
recipient_pub: string;
fee_ut?: number;
sent_at: number;
sent_at_human?: string;
nonce: string; // base64
ciphertext: string; // base64
}
interface InboxResponseWire {
pub: string;
count: number;
has_more: boolean;
items: InboxItemWire[];
}
/**
* Клиент собирает envelope через encryptMessage и шлёт на /relay/broadcast.
* Серверный формат: `{envelope: <relay.Envelope JSON>}`. Nonce/ciphertext
* там — base64 (Go []byte), а у нас в crypto.ts — hex, так что на wire
* конвертим hex→bytes→base64.
*/
export async function sendEnvelope(params: {
senderPub: string; // X25519 hex
recipientPub: string; // X25519 hex
nonce: string; // hex
ciphertext: string; // hex
senderEd25519Pub?: string; // optional — для будущих fee-релеев
}): Promise<{ id: string; status: string }> {
const sentAt = Math.floor(Date.now() / 1000);
const nonceB64 = bytesToBase64(hexToBytes(params.nonce));
const ctB64 = bytesToBase64(hexToBytes(params.ciphertext));
// envelope.id — 16 байт, hex. Сервер только проверяет что поле не
// пустое и использует его как ключ mailbox'а. Первые 16 байт nonce
// уже криптографически-случайны (nacl.randomBytes), так что берём их.
const id = bytesToHex(hexToBytes(params.nonce).slice(0, 16));
return post<{ id: string; status: string }>('/relay/broadcast', {
envelope: {
id,
sender_pub: params.senderPub,
recipient_pub: params.recipientPub,
sender_ed25519_pub: params.senderEd25519Pub ?? '',
fee_ut: 0,
fee_sig: null,
nonce: nonceB64,
ciphertext: ctB64,
sent_at: sentAt,
},
});
}
/**
* Fetch envelopes адресованные нам из relay-почтовика.
* Server: `GET /relay/inbox?pub=<x25519hex>` → `{pub, count, has_more, items}`.
* Нормализуем item'ы к clientскому Envelope type: sent_at → timestamp,
* base64 nonce/ciphertext → hex.
*/
export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519PubHex}`);
const items = Array.isArray(resp?.items) ? resp.items : [];
return items.map((it): Envelope => ({
id: it.id,
sender_pub: it.sender_pub,
recipient_pub: it.recipient_pub,
nonce: bytesToHex(base64ToBytes(it.nonce)),
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
timestamp: it.sent_at ?? 0,
}));
}
// ─── Contact requests (on-chain) ─────────────────────────────────────────────
/**
* Maps blockchain.ContactInfo returned by GET /relay/contacts?pub=...
* The response shape is { pub, count, contacts: ContactInfo[] }.
*/
export interface ContactRequestRaw {
requester_pub: string; // Ed25519 pubkey of requester
requester_addr: string; // DChain address (DC…)
status: string; // "pending" | "accepted" | "blocked"
intro: string; // plaintext intro message (may be empty)
fee_ut: number; // anti-spam fee paid in µT
tx_id: string; // transaction ID
created_at: number; // unix seconds
}
export async function fetchContactRequests(edPubHex: string): Promise<ContactRequestRaw[]> {
const data = await get<{ contacts: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPubHex}`);
return data.contacts ?? [];
}
// ─── Identity API ─────────────────────────────────────────────────────────────
export interface IdentityInfo {
pub_key: string;
address: string;
x25519_pub: string; // hex Curve25519 key; empty string if not published
nickname: string;
registered: boolean;
}
/**
* Relay registration info for a node pub key, as returned by
* /api/relays (which comes back as an array of RegisteredRelayInfo).
* We don't wrap the individual lookup on the server — just filter the
* full list client-side. It's bounded (N nodes in the network) and
* cached heavily enough that this is cheaper than a new endpoint.
*/
export interface RegisteredRelayInfo {
pub_key: string;
address: string;
relay: {
x25519_pub_key: string;
fee_per_msg_ut: number;
multiaddr?: string;
};
last_heartbeat?: number; // unix seconds
}
/** GET /api/relays — all relay nodes registered on-chain. */
export async function getRelays(): Promise<RegisteredRelayInfo[]> {
try {
return await get<RegisteredRelayInfo[]>('/api/relays');
} catch {
return [];
}
}
/** Find relay entry for a specific pub key. null if the address isn't a relay. */
export async function getRelayFor(pubKey: string): Promise<RegisteredRelayInfo | null> {
const all = await getRelays();
return all.find(r => r.pub_key === pubKey) ?? null;
}
/** Fetch identity info for any pubkey or DC address. Returns null on 404. */
export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> {
try {
return await get<IdentityInfo>(`/api/identity/${pubkeyOrAddr}`);
} catch {
return null;
}
}
// ─── Contract API ─────────────────────────────────────────────────────────────
/**
* Response shape from GET /api/contracts/{id}/state/{key}.
* The node handler (node/api_contract.go:handleContractState) returns either:
* { value_b64: null, value_hex: null, ... } when the key is missing
* or
* { value_b64: "...", value_hex: "...", value_u64?: 0 } when the key exists.
*/
interface ContractStateResponse {
contract_id: string;
key: string;
value_b64: string | null;
value_hex: string | null;
value_u64?: number;
}
/**
* Decode a hex string (lowercase/uppercase) back to the original string value
* it represents. The username registry contract stores values as plain ASCII
* bytes (pubkey hex strings / username strings), so `value_hex` on the wire
* is the hex-encoding of UTF-8 bytes. We hex-decode to bytes, then interpret
* those bytes as UTF-8.
*/
function hexToUtf8(hex: string): string {
if (hex.length % 2 !== 0) return '';
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
// TextDecoder is available in Hermes / RN's JS runtime.
try {
return new TextDecoder('utf-8').decode(bytes);
} catch {
// Fallback for environments without TextDecoder.
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return s;
}
}
/** username → address (hex pubkey). Returns null if unregistered. */
export async function resolveUsername(contractId: string, username: string): Promise<string | null> {
try {
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/name:${username}`);
if (!data.value_hex) return null;
const decoded = hexToUtf8(data.value_hex).trim();
return decoded || null;
} catch {
return null;
}
}
/** address (hex pubkey) → username. Returns null if this address hasn't registered a name. */
export async function reverseResolve(contractId: string, address: string): Promise<string | null> {
try {
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/addr:${address}`);
if (!data.value_hex) return null;
const decoded = hexToUtf8(data.value_hex).trim();
return decoded || null;
} catch {
return null;
}
}
// ─── Well-known contracts ─────────────────────────────────────────────────────
/**
* Per-entry shape returned by GET /api/well-known-contracts.
* Matches node/api_well_known.go:WellKnownContract.
*/
export interface WellKnownContract {
contract_id: string;
name: string;
version?: string;
deployed_at: number;
}
/**
* Response from GET /api/well-known-contracts.
* `contracts` is keyed by ABI name (e.g. "username_registry").
*/
export interface WellKnownResponse {
count: number;
contracts: Record<string, WellKnownContract>;
}
/**
* Fetch the node's view of canonical system contracts so the client doesn't
* have to force the user to paste contract IDs into settings.
*
* The node returns the earliest-deployed contract per ABI name; this means
* every peer in the same chain reports the same mapping.
*
* Returns `null` on failure (old node, network hiccup, endpoint missing).
*/
export async function fetchWellKnownContracts(): Promise<WellKnownResponse | null> {
try {
return await get<WellKnownResponse>('/api/well-known-contracts');
} catch {
return null;
}
}
// ─── Node version / update-check ─────────────────────────────────────────────
//
// The three calls below let the client:
// 1. fetchNodeVersion() — see what tag/commit/features the connected node
// exposes. Used on first boot + on every chain-switch so we can warn if
// a required feature is missing.
// 2. checkNodeVersion(required) — thin wrapper that returns {supported,
// missing} by diffing a client-expected feature list against the node's.
// 3. fetchUpdateCheck() — ask the node whether its operator has a newer
// release available from their configured release source (Gitea). For
// messenger UX this is purely informational ("the node you're on is N
// versions behind"), never used to update the node automatically.
/** The shape returned by GET /api/well-known-version. */
export interface NodeVersionInfo {
node_version: string;
protocol_version: number;
features: string[];
chain_id?: string;
build?: {
tag: string;
commit: string;
date: string;
dirty: string;
};
}
/** Client-expected protocol version. Bumped only when wire-protocol breaks. */
export const CLIENT_PROTOCOL_VERSION = 1;
/**
* Minimum feature set this client build relies on. A node missing any of
* these is considered "unsupported" — caller should surface an upgrade
* prompt to the user instead of silently failing on the first feature call.
*/
export const CLIENT_REQUIRED_FEATURES = [
'chain_id',
'feed_v2', // social feed (v2.0.0) — PostCard, timeline, forYou
'identity_registry',
'media_scrub', // server-side EXIF strip — we rely on this for privacy
'onboarding_api',
'relay_broadcast', // /relay/broadcast for E2E envelopes (not /relay/send)
'relay_mailbox',
'ws_submit_tx',
];
/** GET /api/well-known-version. Returns null on failure (old node, network hiccup). */
export async function fetchNodeVersion(): Promise<NodeVersionInfo | null> {
try {
return await get<NodeVersionInfo>('/api/well-known-version');
} catch {
return null;
}
}
/**
* Check whether the connected node supports this client's required features
* and protocol version. Returns a decision blob the UI can render directly.
*
* { supported: true } → everything fine
* { supported: false, reason: "...", ... } → show update prompt
* { supported: null, reason: "unreachable" } → couldn't reach the endpoint,
* likely old node — assume OK
* but warn quietly.
*/
export async function checkNodeVersion(
required: string[] = CLIENT_REQUIRED_FEATURES,
): Promise<{
supported: boolean | null;
reason?: string;
missing?: string[];
info?: NodeVersionInfo;
}> {
const info = await fetchNodeVersion();
if (!info) {
return { supported: null, reason: 'unreachable' };
}
if (info.protocol_version !== CLIENT_PROTOCOL_VERSION) {
return {
supported: false,
reason: `protocol v${info.protocol_version} but client expects v${CLIENT_PROTOCOL_VERSION}`,
info,
};
}
const have = new Set(info.features || []);
const missing = required.filter((f) => !have.has(f));
if (missing.length > 0) {
return {
supported: false,
reason: `node missing features: ${missing.join(', ')}`,
missing,
info,
};
}
return { supported: true, info };
}
/** The shape returned by GET /api/update-check. */
export interface UpdateCheckResponse {
current: { tag: string; commit: string; date: string; dirty: string };
latest?: { tag: string; commit?: string; url?: string; published_at?: string };
update_available: boolean;
checked_at: string;
source?: string;
}
/**
* GET /api/update-check. Returns null when:
* - the node operator hasn't configured DCHAIN_UPDATE_SOURCE_URL (503),
* - upstream Gitea call failed (502),
* - request errored out.
* All three are non-fatal for the client; the UI just doesn't render the
* "update available" banner.
*/
export async function fetchUpdateCheck(): Promise<UpdateCheckResponse | null> {
try {
return await get<UpdateCheckResponse>('/api/update-check');
} catch {
return null;
}
}
// ─── Transaction builder helpers ─────────────────────────────────────────────
import { signBase64 } from './crypto';
/** Minimum blockchain tx fee paid to the block validator (matches blockchain.MinFee = 1000 µT). */
const MIN_TX_FEE = 1000;
const _encoder = new TextEncoder();
/** RFC3339 timestamp with second precision — matches Go time.Time JSON output. */
function rfc3339Now(): string {
const d = new Date();
d.setMilliseconds(0);
// toISOString() gives "2025-01-01T12:00:00.000Z" → replace ".000Z" with "Z"
return d.toISOString().replace('.000Z', 'Z');
}
/** Unique transaction ID (nanoseconds-like using Date.now + random). */
function newTxID(): string {
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
}
/**
* Canonical bytes for signing — must match identity.txSignBytes in Go exactly.
*
* Go struct field order: id, type, from, to, amount, fee, payload, timestamp.
* JS JSON.stringify preserves insertion order, so we rely on that here.
*/
function txCanonicalBytes(tx: {
id: string; type: string; from: string; to: string;
amount: number; fee: number; payload: string; timestamp: string;
}): Uint8Array {
const s = JSON.stringify({
id: tx.id,
type: tx.type,
from: tx.from,
to: tx.to,
amount: tx.amount,
fee: tx.fee,
payload: tx.payload,
timestamp: tx.timestamp,
});
return _encoder.encode(s);
}
/** Encode a JS string (UTF-8) to base64. */
function strToBase64(s: string): string {
return bytesToBase64(_encoder.encode(s));
}
export function buildTransferTx(params: {
from: string;
to: string;
amount: number;
fee: number;
privKey: string;
memo?: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = params.memo ? { memo: params.memo } : {};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'TRANSFER', from: params.from, to: params.to,
amount: params.amount, fee: params.fee, payload, timestamp,
});
return {
id, type: 'TRANSFER', from: params.from, to: params.to,
amount: params.amount, fee: params.fee,
memo: params.memo,
payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* CONTACT_REQUEST transaction.
*
* blockchain.Transaction fields:
* Amount = contactFee — anti-spam fee, paid directly to recipient (>= 5000 µT)
* Fee = MIN_TX_FEE — blockchain tx fee to the block validator (1000 µT)
* Payload = ContactRequestPayload { intro? } as base64 JSON bytes
*/
export function buildContactRequestTx(params: {
from: string; // sender Ed25519 pubkey
to: string; // recipient Ed25519 pubkey
contactFee: number; // anti-spam amount paid to recipient (>= 5000 µT)
intro?: string; // optional plaintext intro message (≤ 280 chars)
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
// Payload matches ContactRequestPayload{Intro: "..."} in Go
const payloadObj = params.intro ? { intro: params.intro } : {};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* ACCEPT_CONTACT transaction.
* AcceptContactPayload is an empty struct in Go — no fields needed.
*/
export function buildAcceptContactTx(params: {
from: string; // acceptor Ed25519 pubkey (us — the recipient of the request)
to: string; // requester Ed25519 pubkey
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({})); // AcceptContactPayload{}
const canonical = txCanonicalBytes({
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
// ─── Contract call ────────────────────────────────────────────────────────────
/** Minimum base fee for CALL_CONTRACT (matches blockchain.MinCallFee). */
const MIN_CALL_FEE = 1000;
/**
* CALL_CONTRACT transaction.
*
* Payload shape (CallContractPayload):
* { contract_id, method, args_json?, gas_limit }
*
* `amount` is the payment attached to the call and made available to the
* contract as `tx.Amount`. Whether it's collected depends on the contract
* — e.g. username_registry.register requires exactly 10_000 µT. Contracts
* that don't need payment should be called with `amount: 0` (default).
*
* The on-chain tx envelope carries `amount` openly, so the explorer shows
* the exact cost of a call rather than hiding it in a contract-internal
* debit — this was the UX motivation for this field.
*
* `fee` is the NETWORK fee paid to the block validator (not the contract).
* `gas` costs are additional and billed at the live gas price.
*/
export function buildCallContractTx(params: {
from: string;
contractId: string;
method: string;
args?: unknown[]; // JSON-serializable arguments
amount?: number; // µT attached to the call (default 0)
gasLimit?: number; // default 1_000_000
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const amount = params.amount ?? 0;
const argsJson = params.args && params.args.length > 0
? JSON.stringify(params.args)
: '';
const payloadObj = {
contract_id: params.contractId,
method: params.method,
args_json: argsJson,
gas_limit: params.gasLimit ?? 1_000_000,
};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'CALL_CONTRACT', from: params.from, to: '',
amount, fee: MIN_CALL_FEE, payload, timestamp,
});
return {
id, type: 'CALL_CONTRACT', from: params.from, to: '',
amount, fee: MIN_CALL_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* Flat registration fee for a username, in µT.
*
* The native username_registry charges a single flat fee (10 000 µT = 0.01 T)
* per register() call regardless of name length, replacing the earlier
* length-based formula. Flat pricing is easier to communicate and the
* 4-char minimum (enforced both in the client UI and the on-chain contract)
* already removes the squatting pressure that tiered pricing mitigated.
*/
export const USERNAME_REGISTRATION_FEE = 10_000;
/** Minimum/maximum allowed username length. Match blockchain/native_username.go. */
export const MIN_USERNAME_LENGTH = 4;
export const MAX_USERNAME_LENGTH = 32;
/** @deprecated Kept for backward compatibility; always returns the flat fee. */
export function usernameRegistrationFee(_name: string): number {
return USERNAME_REGISTRATION_FEE;
}

168
client-app/lib/crypto.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* Cryptographic operations for DChain messenger.
*
* Ed25519 — transaction signing (via TweetNaCl sign)
* X25519 — Diffie-Hellman key exchange for NaCl box
* NaCl box — authenticated encryption for relay messages
*/
import nacl from 'tweetnacl';
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
import { getRandomBytes } from 'expo-crypto';
import type { KeyFile } from './types';
// ─── PRNG ─────────────────────────────────────────────────────────────────────
// TweetNaCl looks for window.crypto which doesn't exist in React Native/Hermes.
// Wire nacl to expo-crypto which uses the platform's secure RNG natively.
nacl.setPRNG((output: Uint8Array, length: number) => {
const bytes = getRandomBytes(length);
for (let i = 0; i < length; i++) output[i] = bytes[i];
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
export function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) throw new Error('odd hex length');
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
// ─── Key generation ───────────────────────────────────────────────────────────
/**
* Generate a new identity: Ed25519 signing keys + X25519 encryption keys.
* Returns a KeyFile compatible with the Go node format.
*/
export function generateKeyFile(): KeyFile {
// Ed25519 for signing / blockchain identity
const signKP = nacl.sign.keyPair();
// X25519 for NaCl box encryption
// nacl.box.keyPair() returns Curve25519 keys
const boxKP = nacl.box.keyPair();
return {
pub_key: bytesToHex(signKP.publicKey),
priv_key: bytesToHex(signKP.secretKey),
x25519_pub: bytesToHex(boxKP.publicKey),
x25519_priv: bytesToHex(boxKP.secretKey),
};
}
// ─── NaCl box encryption ──────────────────────────────────────────────────────
/**
* Encrypt a plaintext message using NaCl box.
* Sender uses their X25519 secret key + recipient's X25519 public key.
* Returns { nonce, ciphertext } as hex strings.
*/
export function encryptMessage(
plaintext: string,
senderSecretHex: string,
recipientPubHex: string,
): { nonce: string; ciphertext: string } {
const nonce = nacl.randomBytes(nacl.box.nonceLength);
const message = decodeUTF8(plaintext);
const secretKey = hexToBytes(senderSecretHex);
const publicKey = hexToBytes(recipientPubHex);
const box = nacl.box(message, nonce, publicKey, secretKey);
return {
nonce: bytesToHex(nonce),
ciphertext: bytesToHex(box),
};
}
/**
* Decrypt a NaCl box.
* Recipient uses their X25519 secret key + sender's X25519 public key.
*/
export function decryptMessage(
ciphertextHex: string,
nonceHex: string,
senderPubHex: string,
recipientSecHex: string,
): string | null {
try {
const ciphertext = hexToBytes(ciphertextHex);
const nonce = hexToBytes(nonceHex);
const senderPub = hexToBytes(senderPubHex);
const secretKey = hexToBytes(recipientSecHex);
const plain = nacl.box.open(ciphertext, nonce, senderPub, secretKey);
if (!plain) return null;
return encodeUTF8(plain);
} catch {
return null;
}
}
// ─── Ed25519 signing ──────────────────────────────────────────────────────────
/**
* Sign arbitrary data with the Ed25519 private key.
* Returns signature as hex.
*/
export function sign(data: Uint8Array, privKeyHex: string): string {
const secretKey = hexToBytes(privKeyHex);
const sig = nacl.sign.detached(data, secretKey);
return bytesToHex(sig);
}
/**
* Sign arbitrary data with the Ed25519 private key.
* Returns signature as base64 — this is the format the Go blockchain node
* expects ([]byte fields are base64 in JSON).
*/
export function signBase64(data: Uint8Array, privKeyHex: string): string {
const secretKey = hexToBytes(privKeyHex);
const sig = nacl.sign.detached(data, secretKey);
return bytesToBase64(sig);
}
/** Encode bytes as base64. Works on Hermes (btoa is available since RN 0.71). */
export function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Decode base64 → bytes. Accepts both standard and URL-safe variants (the
* node's /relay/inbox returns `[]byte` fields marshalled via Go's default
* json.Marshal which uses standard base64).
*/
export function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
/**
* Verify an Ed25519 signature.
*/
export function verify(data: Uint8Array, sigHex: string, pubKeyHex: string): boolean {
try {
return nacl.sign.detached.verify(data, hexToBytes(sigHex), hexToBytes(pubKeyHex));
} catch {
return false;
}
}
// ─── Address helpers ──────────────────────────────────────────────────────────
/** Truncate a long hex address for display: 8...8 */
export function shortAddr(hex: string, chars = 8): string {
if (hex.length <= chars * 2 + 3) return hex;
return `${hex.slice(0, chars)}${hex.slice(-chars)}`;
}

67
client-app/lib/dates.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Date / time форматирование для UI мессенджера.
*
* Все функции принимают **unix-seconds** (совместимо с `Message.timestamp`,
* который тоже в секундах). Для ms-таймштампов делаем нормализацию внутри.
*/
// English short month names ("Jun 17, 2025").
const MONTHS_SHORT = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
function sameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/**
* Day-bucket label for chat separators.
* "Today" / "Yesterday" / "Jun 17, 2025"
*
* @param ts unix-seconds
*/
export function dateBucket(ts: number): string {
const d = new Date(ts * 1000);
const now = new Date();
const yday = new Date(); yday.setDate(now.getDate() - 1);
if (sameDay(d, now)) return 'Today';
if (sameDay(d, yday)) return 'Yesterday';
return `${MONTHS_SHORT[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
/**
* Короткое relative-time под углом bubble ("29m", "14m", "3h", "2d", "12:40").
*
* @param ts unix-seconds
*/
export function relTime(ts: number): string {
const now = Date.now();
const diff = now - ts * 1000;
if (diff < 60_000) return 'now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
if (diff < 24 * 3_600_000) return `${Math.floor(diff / 3_600_000)}h`;
if (diff < 7 * 24 * 3_600_000) return `${Math.floor(diff / (24 * 3_600_000))}d`;
const d = new Date(ts * 1000);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/**
* Похоже на relTime, но принимает как unix-seconds, так и unix-ms.
* Используется в chat-list tiles (там timestamp бывает в ms от addedAt).
*/
export function formatWhen(ts: number): string {
// Heuristic: > 1e12 → уже в ms, иначе seconds.
const sec = ts > 1e12 ? Math.floor(ts / 1000) : ts;
return relTime(sec);
}
/** "HH:MM" — одна и та же локаль, без дня. */
export function formatHM(ts: number): string {
const d = new Date(ts * 1000);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}

498
client-app/lib/feed.ts Normal file
View File

@@ -0,0 +1,498 @@
/**
* Feed client — HTTP wrappers + tx builders for the v2.0.0 social feed.
*
* Flow for publishing a post:
* 1. Build the post body (text + optional pre-compressed attachment).
* 2. Sign "publish:<post_id>:<sha256(body)>:<ts>" with the author's
* Ed25519 key.
* 3. POST /feed/publish — server verifies sig, scrubs metadata from
* any image attachment, stores the body, returns
* { post_id, content_hash, size, estimated_fee_ut, hashtags }.
* 4. Submit CREATE_POST tx on-chain with THE RETURNED content_hash +
* size + hosting_relay. Fee = server's estimate (server credits
* the full amount to the hosting relay).
*
* For reads we hit /feed/timeline, /feed/foryou, /feed/trending,
* /feed/author/{pub}, /feed/hashtag/{tag}. All return a list of
* FeedPostItem (chain metadata enriched with body + stats).
*/
import { getNodeUrl, submitTx, type RawTx } from './api';
import {
bytesToBase64, bytesToHex, hexToBytes, signBase64, sign as signHex,
} from './crypto';
// ─── Types (mirrors node/api_feed.go shapes) ──────────────────────────────
/** Single post as returned by /feed/author, /feed/timeline, etc. */
export interface FeedPostItem {
post_id: string;
author: string; // hex Ed25519
content: string;
content_type?: string; // "text/plain" | "text/markdown"
hashtags?: string[];
reply_to?: string;
quote_of?: string;
created_at: number; // unix seconds
size: number;
hosting_relay: string;
views: number;
likes: number;
has_attachment: boolean;
}
export interface PostStats {
post_id: string;
views: number;
likes: number;
liked_by_me?: boolean;
}
export interface PublishResponse {
post_id: string;
hosting_relay: string;
content_hash: string; // hex sha256 over scrubbed bytes
size: number;
hashtags: string[];
estimated_fee_ut: number;
}
export interface TimelineResponse {
count: number;
posts: FeedPostItem[];
}
// ─── HTTP helpers ─────────────────────────────────────────────────────────
async function getJSON<T>(path: string): Promise<T> {
const res = await fetch(`${getNodeUrl()}${path}`);
if (!res.ok) {
throw new Error(`GET ${path}${res.status}`);
}
return res.json() as Promise<T>;
}
async function postJSON<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${getNodeUrl()}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
let detail = text;
try {
const parsed = JSON.parse(text);
if (parsed?.error) detail = parsed.error;
} catch { /* keep raw */ }
throw new Error(`POST ${path}${res.status}: ${detail}`);
}
return res.json() as Promise<T>;
}
// ─── Publish flow ─────────────────────────────────────────────────────────
/**
* Compute a post_id from author + nanoseconds-ish entropy + content prefix.
* Must match the server-side hash trick (`sha256(author-nanos-content)[:16]`).
* Details don't matter — server only checks the id is non-empty. We just
* need uniqueness across the author's own posts.
*/
async function computePostID(author: string, content: string): Promise<string> {
const { digestStringAsync, CryptoDigestAlgorithm, CryptoEncoding } =
await import('expo-crypto');
const seed = `${author}-${Date.now()}${Math.floor(Math.random() * 1e9)}-${content.slice(0, 64)}`;
const hex = await digestStringAsync(
CryptoDigestAlgorithm.SHA256,
seed,
{ encoding: CryptoEncoding.HEX },
);
return hex.slice(0, 32); // 16 bytes = 32 hex chars
}
/**
* sha256 of a UTF-8 string (optionally with appended bytes for attachments).
* Returns hex. Uses expo-crypto for native speed.
*/
async function sha256Hex(content: string, attachment?: Uint8Array): Promise<string> {
const { digest, CryptoDigestAlgorithm } = await import('expo-crypto');
const encoder = new TextEncoder();
const contentBytes = encoder.encode(content);
const combined = new Uint8Array(
contentBytes.length + (attachment ? attachment.length : 0),
);
combined.set(contentBytes, 0);
if (attachment) combined.set(attachment, contentBytes.length);
const buf = await digest(CryptoDigestAlgorithm.SHA256, combined);
// ArrayBuffer → hex
const view = new Uint8Array(buf);
return bytesToHex(view);
}
export interface PublishParams {
author: string; // hex Ed25519 pubkey
privKey: string; // hex Ed25519 privkey
content: string;
attachment?: Uint8Array;
attachmentMIME?: string; // "image/jpeg" | "image/png" | "video/mp4" | ...
replyTo?: string;
quoteOf?: string;
}
/**
* Publish a post: POSTs /feed/publish with a signed body. Returns the
* server's response so the caller can submit a matching CREATE_POST tx.
*
* Client is expected to have compressed the attachment FIRST (see
* `lib/media.ts`) — this function does not re-compress, only signs and
* uploads. Server will scrub metadata regardless.
*/
export async function publishPost(p: PublishParams): Promise<PublishResponse> {
const postID = await computePostID(p.author, p.content);
const clientHash = await sha256Hex(p.content, p.attachment);
const ts = Math.floor(Date.now() / 1000);
// Sign: "publish:<post_id>:<raw_content_hash>:<ts>"
const encoder = new TextEncoder();
const msg = encoder.encode(`publish:${postID}:${clientHash}:${ts}`);
const sig = signBase64(msg, p.privKey);
const body = {
post_id: postID,
author: p.author,
content: p.content,
content_type: 'text/plain',
attachment_b64: p.attachment ? bytesToBase64(p.attachment) : undefined,
attachment_mime: p.attachmentMIME ?? undefined,
reply_to: p.replyTo,
quote_of: p.quoteOf,
sig,
ts,
};
return postJSON<PublishResponse>('/feed/publish', body);
}
/**
* Full publish flow: POST /feed/publish → on-chain CREATE_POST tx.
* Returns the final post_id (same as server response; echoed for UX).
*/
export async function publishAndCommit(p: PublishParams): Promise<string> {
const pub = await publishPost(p);
const tx = buildCreatePostTx({
from: p.author,
privKey: p.privKey,
postID: pub.post_id,
contentHash: pub.content_hash,
size: pub.size,
hostingRelay: pub.hosting_relay,
fee: pub.estimated_fee_ut,
replyTo: p.replyTo,
quoteOf: p.quoteOf,
});
await submitTx(tx);
return pub.post_id;
}
// ─── Read endpoints ───────────────────────────────────────────────────────
export async function fetchPost(postID: string): Promise<FeedPostItem | null> {
try {
return await getJSON<FeedPostItem>(`/feed/post/${postID}`);
} catch (e: any) {
if (/→\s*404\b/.test(String(e?.message))) return null;
if (/→\s*410\b/.test(String(e?.message))) return null;
throw e;
}
}
export async function fetchStats(postID: string, me?: string): Promise<PostStats | null> {
try {
const qs = me ? `?me=${me}` : '';
return await getJSON<PostStats>(`/feed/post/${postID}/stats${qs}`);
} catch {
return null;
}
}
/**
* Increment the off-chain view counter. Fire-and-forget — failures here
* are not fatal to the UX, so we swallow errors.
*/
export async function bumpView(postID: string): Promise<void> {
try {
await postJSON<unknown>(`/feed/post/${postID}/view`, undefined);
} catch { /* ignore */ }
}
export async function fetchAuthorPosts(
pub: string, opts: { limit?: number; before?: number } = {},
): Promise<FeedPostItem[]> {
const limit = opts.limit ?? 30;
const qs = opts.before
? `?limit=${limit}&before=${opts.before}`
: `?limit=${limit}`;
const resp = await getJSON<TimelineResponse>(`/feed/author/${pub}${qs}`);
return resp.posts ?? [];
}
export async function fetchTimeline(
followerPub: string, opts: { limit?: number; before?: number } = {},
): Promise<FeedPostItem[]> {
const limit = opts.limit ?? 30;
let qs = `?follower=${followerPub}&limit=${limit}`;
if (opts.before) qs += `&before=${opts.before}`;
const resp = await getJSON<TimelineResponse>(`/feed/timeline${qs}`);
return resp.posts ?? [];
}
export async function fetchForYou(pub: string, limit = 30): Promise<FeedPostItem[]> {
const resp = await getJSON<TimelineResponse>(`/feed/foryou?pub=${pub}&limit=${limit}`);
return resp.posts ?? [];
}
export async function fetchTrending(windowHours = 24, limit = 30): Promise<FeedPostItem[]> {
const resp = await getJSON<TimelineResponse>(`/feed/trending?window=${windowHours}&limit=${limit}`);
return resp.posts ?? [];
}
export async function fetchHashtag(tag: string, limit = 30): Promise<FeedPostItem[]> {
const cleanTag = tag.replace(/^#/, '');
const resp = await getJSON<TimelineResponse>(`/feed/hashtag/${encodeURIComponent(cleanTag)}?limit=${limit}`);
return resp.posts ?? [];
}
// ─── Transaction builders ─────────────────────────────────────────────────
//
// These match the blockchain.Event* payloads 1-to-1 and produce already-
// signed RawTx objects ready for submitTx.
/** RFC3339 second-precision timestamp — matches Go time.Time default JSON. */
function rfc3339Now(): string {
const d = new Date();
d.setMilliseconds(0);
return d.toISOString().replace('.000Z', 'Z');
}
function newTxID(): string {
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
}
const _encoder = new TextEncoder();
function txCanonicalBytes(tx: {
id: string; type: string; from: string; to: string;
amount: number; fee: number; payload: string; timestamp: string;
}): Uint8Array {
return _encoder.encode(JSON.stringify({
id: tx.id,
type: tx.type,
from: tx.from,
to: tx.to,
amount: tx.amount,
fee: tx.fee,
payload: tx.payload,
timestamp: tx.timestamp,
}));
}
function strToBase64(s: string): string {
return bytesToBase64(_encoder.encode(s));
}
/**
* CREATE_POST tx. content_hash is the HEX sha256 returned by /feed/publish;
* must be converted to base64 bytes for the on-chain payload (Go []byte →
* base64 in JSON).
*/
export function buildCreatePostTx(params: {
from: string;
privKey: string;
postID: string;
contentHash: string; // hex from server
size: number;
hostingRelay: string;
fee: number;
replyTo?: string;
quoteOf?: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = {
post_id: params.postID,
content_hash: bytesToBase64(hexToBytes(params.contentHash)),
size: params.size,
hosting_relay: params.hostingRelay,
reply_to: params.replyTo ?? '',
quote_of: params.quoteOf ?? '',
};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'CREATE_POST', from: params.from, to: '',
amount: 0, fee: params.fee, payload, timestamp,
});
return {
id, type: 'CREATE_POST', from: params.from, to: '',
amount: 0, fee: params.fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
export function buildDeletePostTx(params: {
from: string;
privKey: string;
postID: string;
fee?: number;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
const fee = params.fee ?? 1000;
const canonical = txCanonicalBytes({
id, type: 'DELETE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
});
return {
id, type: 'DELETE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
export function buildFollowTx(params: {
from: string;
privKey: string;
target: string;
fee?: number;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64('{}');
const fee = params.fee ?? 1000;
const canonical = txCanonicalBytes({
id, type: 'FOLLOW', from: params.from, to: params.target,
amount: 0, fee, payload, timestamp,
});
return {
id, type: 'FOLLOW', from: params.from, to: params.target,
amount: 0, fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
export function buildUnfollowTx(params: {
from: string;
privKey: string;
target: string;
fee?: number;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64('{}');
const fee = params.fee ?? 1000;
const canonical = txCanonicalBytes({
id, type: 'UNFOLLOW', from: params.from, to: params.target,
amount: 0, fee, payload, timestamp,
});
return {
id, type: 'UNFOLLOW', from: params.from, to: params.target,
amount: 0, fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
export function buildLikePostTx(params: {
from: string;
privKey: string;
postID: string;
fee?: number;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
const fee = params.fee ?? 1000;
const canonical = txCanonicalBytes({
id, type: 'LIKE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
});
return {
id, type: 'LIKE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
export function buildUnlikePostTx(params: {
from: string;
privKey: string;
postID: string;
fee?: number;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
const fee = params.fee ?? 1000;
const canonical = txCanonicalBytes({
id, type: 'UNLIKE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
});
return {
id, type: 'UNLIKE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
// ─── High-level helpers (combine build + submit) ─────────────────────────
export async function likePost(params: { from: string; privKey: string; postID: string }) {
await submitTx(buildLikePostTx(params));
}
export async function unlikePost(params: { from: string; privKey: string; postID: string }) {
await submitTx(buildUnlikePostTx(params));
}
export async function followUser(params: { from: string; privKey: string; target: string }) {
await submitTx(buildFollowTx(params));
}
export async function unfollowUser(params: { from: string; privKey: string; target: string }) {
await submitTx(buildUnfollowTx(params));
}
export async function deletePost(params: { from: string; privKey: string; postID: string }) {
await submitTx(buildDeletePostTx(params));
}
// ─── Formatting helpers ───────────────────────────────────────────────────
/** Convert µT to display token amount (0.xxx T). */
export function formatFee(feeUT: number): string {
const t = feeUT / 1_000_000;
if (t < 0.001) return `${feeUT} µT`;
return `${t.toFixed(4)} T`;
}
/** Relative time formatter, Twitter-like ("5m", "3h", "Dec 15"). */
export function formatRelativeTime(unixSeconds: number): string {
const now = Date.now() / 1000;
const delta = now - unixSeconds;
if (delta < 60) return 'just now';
if (delta < 3600) return `${Math.floor(delta / 60)}m`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h`;
if (delta < 7 * 86400) return `${Math.floor(delta / 86400)}d`;
const d = new Date(unixSeconds * 1000);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
/** Compact count formatter ("1.2K", "3.4M"). */
export function formatCount(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}K`;
return `${(n / 1_000_000).toFixed(1)}M`;
}
// Prevent unused-import lint when nothing else touches signHex.
export const _feedSignRaw = signHex;

View File

@@ -0,0 +1,145 @@
/**
* Forward a feed post into a direct chat as a "post reference" message.
*
* What the receiver sees
* ----------------------
* A special chat bubble rendering a compact card:
* [avatar] @author "post excerpt…" [📷 if has image]
*
* Tapping the card opens the full post detail. Design is modelled on
* VK/Twitter's "shared post" embed: visually distinct from a plain
* message so the user sees at a glance that this came from the feed,
* not from the sender directly.
*
* Transport
* ---------
* Same encrypted envelope as a normal chat message. The payload is
* plaintext JSON with a discriminator:
*
* { "kind": "post_ref", "post_id": "...", "author": "...",
* "excerpt": "first 120 chars of body", "has_image": true }
*
* The receiver's useMessages / useGlobalInbox hooks detect the JSON
* shape after decryption and assign it to Message.postRef for rendering.
* Plain-text messages stay wrapped in the same envelope format — the
* only difference is whether the decrypted body parses as our JSON
* schema.
*/
import { encryptMessage } from './crypto';
import { sendEnvelope } from './api';
import { appendMessage } from './storage';
import { useStore } from './store';
import { randomId } from './utils';
import type { Contact, Message } from './types';
import type { FeedPostItem } from './feed';
const POST_REF_MARKER = 'dchain-post-ref';
const EXCERPT_MAX = 120;
export interface PostRefPayload {
kind: typeof POST_REF_MARKER;
post_id: string;
author: string;
excerpt: string;
has_image: boolean;
}
/** Serialise a post ref for the wire. */
export function encodePostRef(post: FeedPostItem): string {
const payload: PostRefPayload = {
kind: POST_REF_MARKER,
post_id: post.post_id,
author: post.author,
excerpt: truncate(post.content, EXCERPT_MAX),
has_image: !!post.has_attachment,
};
return JSON.stringify(payload);
}
/**
* Try to parse an incoming plaintext message as a post reference.
* Returns null if the payload isn't the expected shape — the caller
* then treats it as a normal text message.
*/
export function tryParsePostRef(plaintext: string): PostRefPayload | null {
const trimmed = plaintext.trim();
if (!trimmed.startsWith('{')) return null;
try {
const parsed = JSON.parse(trimmed) as Partial<PostRefPayload>;
if (parsed.kind !== POST_REF_MARKER) return null;
if (!parsed.post_id || !parsed.author) return null;
return {
kind: POST_REF_MARKER,
post_id: String(parsed.post_id),
author: String(parsed.author),
excerpt: String(parsed.excerpt ?? ''),
has_image: !!parsed.has_image,
};
} catch {
return null;
}
}
/**
* Forward `post` to each of the given contacts as a post-ref message.
* Creates a fresh envelope per recipient (can't fan-out a single
* ciphertext — each recipient's x25519 key seals differently) and
* drops a mirrored Message into our local chat history so the user
* sees the share in their own view too.
*
* Contacts without an x25519 public key are skipped with a warning
* instead of failing the whole batch.
*/
export async function forwardPostToContacts(params: {
post: FeedPostItem;
contacts: Contact[];
keyFile: { pub_key: string; priv_key: string; x25519_pub: string; x25519_priv: string };
}): Promise<{ ok: number; failed: number }> {
const { post, contacts, keyFile } = params;
const appendMsg = useStore.getState().appendMessage;
const body = encodePostRef(post);
let ok = 0, failed = 0;
for (const c of contacts) {
if (!c.x25519Pub) { failed++; continue; }
try {
const { nonce, ciphertext } = encryptMessage(
body, keyFile.x25519_priv, c.x25519Pub,
);
await sendEnvelope({
senderPub: keyFile.x25519_pub,
recipientPub: c.x25519Pub,
senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext,
});
// Mirror into local history so the sender sees "you shared this".
const mirrored: Message = {
id: randomId(),
from: keyFile.x25519_pub,
text: '', // postRef carries all the content
timestamp: Math.floor(Date.now() / 1000),
mine: true,
postRef: {
postID: post.post_id,
author: post.author,
excerpt: truncate(post.content, EXCERPT_MAX),
hasImage: !!post.has_attachment,
},
};
appendMsg(c.address, mirrored);
await appendMessage(c.address, mirrored);
ok++;
} catch {
failed++;
}
}
return { ok, failed };
}
function truncate(s: string, n: number): string {
if (!s) return '';
if (s.length <= n) return s;
return s.slice(0, n).trimEnd() + '…';
}

101
client-app/lib/storage.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Persistent storage for keys and app settings.
* On mobile: expo-secure-store for key material, AsyncStorage for settings.
* On web: falls back to localStorage (dev only).
*/
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { KeyFile, Contact, NodeSettings } from './types';
// ─── Keys ─────────────────────────────────────────────────────────────────────
const KEYFILE_KEY = 'dchain_keyfile';
const CONTACTS_KEY = 'dchain_contacts';
const SETTINGS_KEY = 'dchain_settings';
const CHATS_KEY = 'dchain_chats';
/** Save the key file in secure storage (encrypted on device). */
export async function saveKeyFile(kf: KeyFile): Promise<void> {
await SecureStore.setItemAsync(KEYFILE_KEY, JSON.stringify(kf));
}
/** Load key file. Returns null if not set. */
export async function loadKeyFile(): Promise<KeyFile | null> {
const raw = await SecureStore.getItemAsync(KEYFILE_KEY);
if (!raw) return null;
return JSON.parse(raw) as KeyFile;
}
/** Delete key file (logout / factory reset). */
export async function deleteKeyFile(): Promise<void> {
await SecureStore.deleteItemAsync(KEYFILE_KEY);
}
// ─── Node settings ─────────────────────────────────────────────────────────────
const DEFAULT_SETTINGS: NodeSettings = {
nodeUrl: 'http://localhost:8081',
contractId: '',
};
export async function loadSettings(): Promise<NodeSettings> {
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
if (!raw) return DEFAULT_SETTINGS;
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
}
export async function saveSettings(s: Partial<NodeSettings>): Promise<void> {
const current = await loadSettings();
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...current, ...s }));
}
// ─── Contacts ─────────────────────────────────────────────────────────────────
export async function loadContacts(): Promise<Contact[]> {
const raw = await AsyncStorage.getItem(CONTACTS_KEY);
if (!raw) return [];
return JSON.parse(raw) as Contact[];
}
export async function saveContact(c: Contact): Promise<void> {
const contacts = await loadContacts();
const idx = contacts.findIndex(x => x.address === c.address);
if (idx >= 0) contacts[idx] = c;
else contacts.push(c);
await AsyncStorage.setItem(CONTACTS_KEY, JSON.stringify(contacts));
}
export async function deleteContact(address: string): Promise<void> {
const contacts = await loadContacts();
await AsyncStorage.setItem(
CONTACTS_KEY,
JSON.stringify(contacts.filter(c => c.address !== address)),
);
}
// ─── Message cache (per-chat local store) ────────────────────────────────────
export interface CachedMessage {
id: string;
from: string;
text: string;
timestamp: number;
mine: boolean;
}
export async function loadMessages(chatId: string): Promise<CachedMessage[]> {
const raw = await AsyncStorage.getItem(`${CHATS_KEY}_${chatId}`);
if (!raw) return [];
return JSON.parse(raw) as CachedMessage[];
}
export async function appendMessage(chatId: string, msg: CachedMessage): Promise<void> {
const msgs = await loadMessages(chatId);
// Deduplicate by id
if (msgs.find(m => m.id === msg.id)) return;
msgs.push(msg);
// Keep last 500 messages per chat
const trimmed = msgs.slice(-500);
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
}

128
client-app/lib/store.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Global app state via Zustand.
* Keeps runtime state; persistent data lives in storage.ts.
*/
import { create } from 'zustand';
import type { KeyFile, Contact, Chat, Message, ContactRequest, NodeSettings } from './types';
interface AppState {
// Identity
keyFile: KeyFile | null;
username: string | null;
setKeyFile: (kf: KeyFile | null) => void;
setUsername: (u: string | null) => void;
// Node settings
settings: NodeSettings;
setSettings: (s: Partial<NodeSettings>) => void;
// Contacts
contacts: Contact[];
setContacts: (contacts: Contact[]) => void;
upsertContact: (c: Contact) => void;
// Chats (derived from contacts + messages)
chats: Chat[];
setChats: (chats: Chat[]) => void;
// Active chat messages
messages: Record<string, Message[]>; // key: contactAddress
setMessages: (chatId: string, msgs: Message[]) => void;
appendMessage: (chatId: string, msg: Message) => void;
// Contact requests (pending)
requests: ContactRequest[];
setRequests: (reqs: ContactRequest[]) => void;
// Balance
balance: number;
setBalance: (b: number) => void;
// Loading / error states
loading: boolean;
setLoading: (v: boolean) => void;
error: string | null;
setError: (e: string | null) => void;
// Nonce cache (to avoid refetching)
nonce: number;
setNonce: (n: number) => void;
// Per-contact unread counter (reset on chat open, bumped on peer msg arrive).
// Keyed by contactAddress (Ed25519 pubkey hex).
unreadByContact: Record<string, number>;
incrementUnread: (contactAddress: string) => void;
clearUnread: (contactAddress: string) => void;
/** Bootstrap state: `true` after initial loadKeyFile + loadSettings done. */
booted: boolean;
setBooted: (b: boolean) => void;
}
export const useStore = create<AppState>((set, get) => ({
keyFile: null,
username: null,
setKeyFile: (kf) => set({ keyFile: kf }),
setUsername: (u) => set({ username: u }),
settings: {
nodeUrl: 'http://localhost:8081',
contractId: '',
},
setSettings: (s) => set(state => ({ settings: { ...state.settings, ...s } })),
contacts: [],
setContacts: (contacts) => set({ contacts }),
upsertContact: (c) => set(state => {
const idx = state.contacts.findIndex(x => x.address === c.address);
if (idx >= 0) {
const updated = [...state.contacts];
updated[idx] = c;
return { contacts: updated };
}
return { contacts: [...state.contacts, c] };
}),
chats: [],
setChats: (chats) => set({ chats }),
messages: {},
setMessages: (chatId, msgs) => set(state => ({
messages: { ...state.messages, [chatId]: msgs },
})),
appendMessage: (chatId, msg) => set(state => {
const current = state.messages[chatId] ?? [];
if (current.find(m => m.id === msg.id)) return {};
return { messages: { ...state.messages, [chatId]: [...current, msg] } };
}),
requests: [],
setRequests: (reqs) => set({ requests: reqs }),
balance: 0,
setBalance: (b) => set({ balance: b }),
loading: false,
setLoading: (v) => set({ loading: v }),
error: null,
setError: (e) => set({ error: e }),
nonce: 0,
setNonce: (n) => set({ nonce: n }),
unreadByContact: {},
incrementUnread: (addr) => set(state => ({
unreadByContact: {
...state.unreadByContact,
[addr]: (state.unreadByContact[addr] ?? 0) + 1,
},
})),
clearUnread: (addr) => set(state => {
if (!state.unreadByContact[addr]) return {};
const next = { ...state.unreadByContact };
delete next[addr];
return { unreadByContact: next };
}),
booted: false,
setBooted: (b) => set({ booted: b }),
}));

165
client-app/lib/types.ts Normal file
View File

@@ -0,0 +1,165 @@
// ─── Key material ────────────────────────────────────────────────────────────
export interface KeyFile {
pub_key: string; // hex Ed25519 public key (32 bytes)
priv_key: string; // hex Ed25519 private key (64 bytes)
x25519_pub: string; // hex X25519 public key (32 bytes)
x25519_priv: string; // hex X25519 private key (32 bytes)
}
// ─── Contact ─────────────────────────────────────────────────────────────────
/**
* Тип беседы в v2.0.0 — только direct (1:1 E2E чат). Каналы убраны в
* пользу публичной ленты (см. lib/feed.ts). Поле `kind` осталось ради
* обратной совместимости со старыми записями в AsyncStorage; новые
* контакты не пишут его.
*/
export type ContactKind = 'direct' | 'group';
export interface Contact {
address: string; // Ed25519 pubkey hex — blockchain address
x25519Pub: string; // X25519 pubkey hex — encryption key
username?: string; // @name from registry contract
alias?: string; // local nickname
addedAt: number; // unix ms
/** Legacy field (kept for backward compat with existing AsyncStorage). */
kind?: ContactKind;
/** Количество непрочитанных — опционально, проставляется WS read-receipt'ами. */
unread?: number;
}
// ─── Messages ─────────────────────────────────────────────────────────────────
export interface Envelope {
/** sha256(nonce||ciphertext)[:16] hex — stable server-assigned id. */
id: string;
sender_pub: string; // X25519 hex
recipient_pub: string; // X25519 hex
nonce: string; // hex 24 bytes
ciphertext: string; // hex NaCl box
timestamp: number; // unix seconds (server's sent_at, normalised client-side)
}
/**
* Вложение к сообщению. MVP — хранится как URI на локальной файловой
* системе клиента (expo-image-picker / expo-document-picker / expo-av
* возвращают именно такие URI). Wire-формат для передачи attachment'ов
* через relay-envelope ещё не финализирован — пока этот тип для UI'а и
* локального отображения.
*
* Формат по kind:
* image — width/height опциональны (image-picker их отдаёт)
* video — same + duration в секундах
* voice — duration в секундах, нет дизайна превью кроме waveform-stub
* file — name + size в байтах, тип через mime
*/
export type AttachmentKind = 'image' | 'video' | 'voice' | 'file';
export interface Attachment {
kind: AttachmentKind;
uri: string; // локальный file:// URI или https:// (incoming decoded)
mime?: string; // 'image/jpeg', 'application/pdf', …
name?: string; // имя файла (для file)
size?: number; // байты (для file)
width?: number; // image/video
height?: number; // image/video
duration?: number; // seconds (video/voice)
/** Для kind='video' — рендерить как круглое видео-сообщение (Telegram-style). */
circle?: boolean;
}
export interface Message {
id: string;
from: string; // X25519 pubkey of sender
text: string;
timestamp: number;
mine: boolean;
/** true если сообщение было отредактировано. Показываем "Edited" в углу. */
edited?: boolean;
/**
* Для mine=true — true если получатель его прочитал.
* UI: пустая галочка = отправлено, filled = прочитано.
* Для mine=false не используется.
*/
read?: boolean;
/** Одно вложение. Multi-attach пока не поддерживается — будет массивом. */
attachment?: Attachment;
/**
* Если сообщение — ответ на другое, здесь лежит ссылка + short preview
* того оригинала. id используется для scroll-to + highlight; text/author
* — для рендера "quoted"-блока внутри текущего bubble'а без запроса
* исходного сообщения (копия замороженная в момент ответа).
*/
replyTo?: {
id: string;
text: string;
author: string; // @username / alias / "you"
};
/**
* Ссылка на пост из ленты. Если присутствует — сообщение рендерится как
* карточка-превью поста (аватар автора, хэндл, текст-excerpt, картинка
* если есть). Тап на карточку → открывается полный пост. Сценарий — юзер
* нажал Share в ленте и отправил пост в этот чат/ЛС.
*
* Содержимое (автор, excerpt) дублируется тут, чтобы карточку можно было
* рендерить оффлайн / когда у хостящей релей-ноды пропал пост — чат
* остаётся читаемым независимо от жизни ленты.
*/
postRef?: {
postID: string;
author: string; // Ed25519 hex — для чипа имени в карточке
excerpt: string; // первые 120 символов тела поста
hasImage?: boolean;
};
}
// ─── Chat ────────────────────────────────────────────────────────────────────
export interface Chat {
contactAddress: string; // Ed25519 pubkey hex
contactX25519: string; // X25519 pubkey hex
username?: string;
alias?: string;
lastMessage?: string;
lastTime?: number;
unread: number;
}
// ─── Contact request ─────────────────────────────────────────────────────────
export interface ContactRequest {
from: string; // Ed25519 pubkey hex
x25519Pub: string; // X25519 pubkey hex; empty until fetched from identity
username?: string;
intro: string; // plaintext intro (stored on-chain)
timestamp: number;
txHash: string;
}
// ─── Transaction ─────────────────────────────────────────────────────────────
export interface TxRecord {
hash: string;
type: string;
from: string;
to?: string;
amount?: number;
fee: number;
timestamp: number;
status: 'confirmed' | 'pending';
}
// ─── Node info ───────────────────────────────────────────────────────────────
export interface NetStats {
total_blocks: number;
total_txs: number;
peer_count: number;
chain_id: string;
}
export interface NodeSettings {
nodeUrl: string;
contractId: string; // username_registry contract
}

53
client-app/lib/utils.ts Normal file
View File

@@ -0,0 +1,53 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { router } from 'expo-router';
/**
* Navigate back, or fall back to a sensible default route if there's
* no screen to pop to.
*
* Without this, an opened route entered via deep link / direct push
* (profile, feed/[id], etc.) would emit the "action 'GO_BACK' was not
* handled by any navigator" dev warning and do nothing — user ends up
* stuck. Default fallback is the chats list (root of the app).
*/
export function safeBack(fallback: string = '/(app)/chats'): void {
if (router.canGoBack()) {
router.back();
} else {
router.replace(fallback as never);
}
}
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Format µT amount to human-readable string */
export function formatAmount(microTokens: number | undefined | null): string {
if (microTokens == null) return '—';
if (microTokens >= 1_000_000) return `${(microTokens / 1_000_000).toFixed(2)} T`;
if (microTokens >= 1_000) return `${(microTokens / 1_000).toFixed(1)} mT`;
return `${microTokens} µT`;
}
/** Format unix seconds to relative time */
export function relativeTime(unixSeconds: number | undefined | null): string {
if (!unixSeconds) return '';
const diff = Date.now() / 1000 - unixSeconds;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return new Date(unixSeconds * 1000).toLocaleDateString();
}
/** Format unix seconds to HH:MM */
export function formatTime(unixSeconds: number | undefined | null): string {
if (!unixSeconds) return '';
return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
/** Generate a random nonce string */
export function randomId(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}

401
client-app/lib/ws.ts Normal file
View File

@@ -0,0 +1,401 @@
/**
* DChain WebSocket client — replaces balance / inbox / contacts polling with
* server-push. Matches `node/ws.go` exactly.
*
* Usage:
* const ws = getWSClient();
* ws.connect(); // idempotent
* const off = ws.subscribe('addr:ab12…', ev => { ... });
* // later:
* off(); // unsubscribe + stop handler
* ws.disconnect();
*
* Features:
* - Auto-reconnect with exponential backoff (1s → 30s cap).
* - Re-subscribes all topics after a reconnect.
* - `hello` frame exposes chain_id + tip_height for connection state UI.
* - Degrades silently if the endpoint returns 501 (old node without WS).
*/
import { getNodeUrl, onNodeUrlChange } from './api';
import { sign } from './crypto';
export type WSEventName =
| 'hello'
| 'block'
| 'tx'
| 'contract_log'
| 'inbox'
| 'typing'
| 'pong'
| 'error'
| 'subscribed'
| 'submit_ack'
| 'lag';
export interface WSFrame {
event: WSEventName;
data?: unknown;
topic?: string;
msg?: string;
chain_id?: string;
tip_height?: number;
/** Server-issued nonce in the hello frame; client signs it for auth. */
auth_nonce?: string;
// submit_ack fields
id?: string;
status?: 'accepted' | 'rejected';
reason?: string;
}
type Handler = (frame: WSFrame) => void;
class WSClient {
private ws: WebSocket | null = null;
private url: string | null = null;
private reconnectMs: number = 1000;
private closing: boolean = false;
/** topic → set of handlers interested in frames for this topic */
private handlers: Map<string, Set<Handler>> = new Map();
/** topics we want the server to push — replayed on every reconnect */
private wantedTopics: Set<string> = new Set();
private connectionListeners: Set<(ok: boolean, err?: string) => void> = new Set();
private helloInfo: { chainId?: string; tipHeight?: number; authNonce?: string } = {};
/**
* Credentials used for auto-auth on every (re)connect. The signer runs on
* each hello frame so scoped subscriptions (addr:*, inbox:*) are accepted.
* Without these, subscribe requests to scoped topics get rejected by the
* server; global topics (blocks, tx, …) still work unauthenticated.
*/
private authCreds: { pubKey: string; privKey: string } | null = null;
/** Current connection state (read-only for UI). */
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
getHelloInfo(): { chainId?: string; tipHeight?: number } {
return this.helloInfo;
}
/** Subscribe to a connection-state listener — fires on connect/disconnect. */
onConnectionChange(cb: (ok: boolean, err?: string) => void): () => void {
this.connectionListeners.add(cb);
return () => this.connectionListeners.delete(cb) as unknown as void;
}
private fireConnectionChange(ok: boolean, err?: string) {
for (const cb of this.connectionListeners) {
try { cb(ok, err); } catch { /* noop */ }
}
}
/**
* Register the Ed25519 keypair used for auto-auth. The signer runs on each
* (re)connect against the server-issued nonce so the connection is bound
* to this identity. Pass null to disable auth (only global topics will
* work — useful for observers).
*/
setAuthCreds(creds: { pubKey: string; privKey: string } | null): void {
this.authCreds = creds;
// If we're already connected, kick off auth immediately.
if (creds && this.isConnected() && this.helloInfo.authNonce) {
this.sendAuth(this.helloInfo.authNonce);
}
}
/** Idempotent connect. Call once on app boot. */
connect(): void {
const base = getNodeUrl();
const newURL = base.replace(/^http/, 'ws') + '/api/ws';
if (this.ws) {
const state = this.ws.readyState;
// Already pointing at this URL and connected / connecting — nothing to do.
if (this.url === newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
return;
}
// URL changed (operator flipped nodes in settings) — tear down and
// re-dial. Existing subscriptions live in wantedTopics and will be
// replayed after the new onopen fires.
if (this.url !== newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
try { this.ws.close(); } catch { /* noop */ }
this.ws = null;
}
}
this.closing = false;
this.url = newURL;
try {
this.ws = new WebSocket(this.url);
} catch (e: any) {
this.fireConnectionChange(false, e?.message ?? 'ws construct failed');
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
this.reconnectMs = 1000; // reset backoff
this.fireConnectionChange(true);
// Replay all wanted subscriptions.
for (const topic of this.wantedTopics) {
this.sendRaw({ op: 'subscribe', topic });
}
};
this.ws.onmessage = (ev) => {
let frame: WSFrame;
try {
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
} catch {
return;
}
if (frame.event === 'hello') {
this.helloInfo = {
chainId: frame.chain_id,
tipHeight: frame.tip_height,
authNonce: frame.auth_nonce,
};
// Auto-authenticate if credentials are set. The server binds this
// connection to the signed pubkey so scoped subscriptions (addr:*,
// inbox:*) get through. On reconnect a new nonce is issued, so the
// auth dance repeats transparently.
if (this.authCreds && frame.auth_nonce) {
this.sendAuth(frame.auth_nonce);
}
}
// Dispatch to all handlers for any topic that could match this frame.
// We use a simple predicate: look at the frame to decide which topics it
// was fanned out to, then fire every matching handler.
for (const topic of this.topicsForFrame(frame)) {
const set = this.handlers.get(topic);
if (!set) continue;
for (const h of set) {
try { h(frame); } catch (e) { console.warn('[ws] handler error', e); }
}
}
};
this.ws.onerror = (e: any) => {
this.fireConnectionChange(false, 'ws error');
};
this.ws.onclose = () => {
this.ws = null;
this.fireConnectionChange(false);
if (!this.closing) this.scheduleReconnect();
};
}
disconnect(): void {
this.closing = true;
if (this.ws) {
try { this.ws.close(); } catch { /* noop */ }
this.ws = null;
}
}
/**
* Subscribe to a topic. Returns an `off()` function that unsubscribes AND
* removes the handler. If multiple callers subscribe to the same topic,
* the server is only notified on the first and last caller.
*/
subscribe(topic: string, handler: Handler): () => void {
let set = this.handlers.get(topic);
if (!set) {
set = new Set();
this.handlers.set(topic, set);
}
set.add(handler);
// Notify server only on the first handler for this topic.
if (!this.wantedTopics.has(topic)) {
this.wantedTopics.add(topic);
if (this.isConnected()) {
this.sendRaw({ op: 'subscribe', topic });
} else {
this.connect(); // lazy-connect on first subscribe
}
}
return () => {
const s = this.handlers.get(topic);
if (!s) return;
s.delete(handler);
if (s.size === 0) {
this.handlers.delete(topic);
this.wantedTopics.delete(topic);
if (this.isConnected()) {
this.sendRaw({ op: 'unsubscribe', topic });
}
}
};
}
/** Force a keepalive ping. Useful for debugging. */
ping(): void {
this.sendRaw({ op: 'ping' });
}
/**
* Send a typing indicator to another user. Recipient is their X25519 pubkey
* (the one used for inbox encryption). Ephemeral — no ack, no retry; just
* fire and forget. Call on each keystroke but throttle to once per 2-3s
* at the caller side so we don't flood the WS with frames.
*/
sendTyping(recipientX25519: string): void {
if (!this.isConnected()) return;
try {
this.ws!.send(JSON.stringify({ op: 'typing', to: recipientX25519 }));
} catch { /* best-effort */ }
}
/**
* Submit a signed transaction over the WebSocket and resolve once the
* server returns a `submit_ack`. Saves the HTTP round-trip on every tx
* and gives the UI immediate accept/reject feedback.
*
* Rejects if:
* - WS is not connected (caller should fall back to HTTP)
* - Server returns `status: "rejected"` — `reason` is surfaced as error msg
* - No ack within `timeoutMs` (default 10 s)
*/
submitTx(tx: unknown, timeoutMs = 10_000): Promise<{ id: string }> {
if (!this.isConnected()) {
return Promise.reject(new Error('WS not connected'));
}
const reqId = 's_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
return new Promise((resolve, reject) => {
const off = this.subscribe('$system', (frame) => {
if (frame.event !== 'submit_ack' || frame.id !== reqId) return;
off();
clearTimeout(timer);
if (frame.status === 'accepted') {
// `msg` carries the server-confirmed tx id.
resolve({ id: typeof frame.msg === 'string' ? frame.msg : '' });
} else {
reject(new Error(frame.reason || 'submit_tx rejected'));
}
});
const timer = setTimeout(() => {
off();
reject(new Error('submit_tx timeout (' + timeoutMs + 'ms)'));
}, timeoutMs);
try {
this.ws!.send(JSON.stringify({ op: 'submit_tx', tx, id: reqId }));
} catch (e: any) {
off();
clearTimeout(timer);
reject(new Error('WS send failed: ' + (e?.message ?? 'unknown')));
}
});
}
// ── internals ───────────────────────────────────────────────────────────
private scheduleReconnect(): void {
if (this.closing) return;
const delay = Math.min(this.reconnectMs, 30_000);
this.reconnectMs = Math.min(this.reconnectMs * 2, 30_000);
setTimeout(() => {
if (!this.closing) this.connect();
}, delay);
}
private sendRaw(cmd: { op: string; topic?: string }): void {
if (!this.isConnected()) return;
try { this.ws!.send(JSON.stringify(cmd)); } catch { /* noop */ }
}
/**
* Sign the server nonce with our Ed25519 private key and send the `auth`
* op. The server binds this connection to `authCreds.pubKey`; subsequent
* subscribe requests to `addr:<pubKey>` / `inbox:<my_x25519>` are accepted.
*/
private sendAuth(nonce: string): void {
if (!this.authCreds || !this.isConnected()) return;
try {
const bytes = new TextEncoder().encode(nonce);
const sig = sign(bytes, this.authCreds.privKey);
this.ws!.send(JSON.stringify({
op: 'auth',
pubkey: this.authCreds.pubKey,
sig,
}));
} catch (e) {
console.warn('[ws] auth send failed:', e);
}
}
/**
* Given an incoming frame, enumerate every topic that handlers could have
* subscribed to and still be interested. This mirrors the fan-out logic in
* node/ws.go:EmitBlock / EmitTx / EmitContractLog.
*/
private topicsForFrame(frame: WSFrame): string[] {
switch (frame.event) {
case 'block':
return ['blocks'];
case 'tx': {
const d = frame.data as { from?: string; to?: string } | undefined;
const topics = ['tx'];
if (d?.from) topics.push('addr:' + d.from);
if (d?.to && d.to !== d.from) topics.push('addr:' + d.to);
return topics;
}
case 'contract_log': {
const d = frame.data as { contract_id?: string } | undefined;
const topics = ['contract_log'];
if (d?.contract_id) topics.push('contract:' + d.contract_id);
return topics;
}
case 'inbox': {
// Node fans inbox events to `inbox` + `inbox:<recipient_x25519>`;
// we mirror that here so both firehose listeners and address-scoped
// subscribers see the event.
const d = frame.data as { recipient_pub?: string } | undefined;
const topics = ['inbox'];
if (d?.recipient_pub) topics.push('inbox:' + d.recipient_pub);
return topics;
}
case 'typing': {
// Server fans to `typing:<to>` only (the recipient).
const d = frame.data as { to?: string } | undefined;
return d?.to ? ['typing:' + d.to] : [];
}
// Control-plane events — no topic fan-out; use a pseudo-topic so UI
// can listen for them via subscribe('$system', ...).
case 'hello':
case 'pong':
case 'error':
case 'subscribed':
case 'submit_ack':
case 'lag':
return ['$system'];
default:
return [];
}
}
}
let _singleton: WSClient | null = null;
/**
* Return the app-wide WebSocket client. Safe to call from any component;
* `.connect()` is idempotent.
*
* On first creation we register a node-URL listener so flipping the node
* in Settings tears down the existing socket and dials the new one — the
* user's active subscriptions (addr:*, inbox:*) replay automatically.
*/
export function getWSClient(): WSClient {
if (!_singleton) {
_singleton = new WSClient();
onNodeUrlChange(() => {
// Fire and forget — connect() is idempotent and handles stale URLs.
_singleton!.connect();
});
}
return _singleton;
}

View File

@@ -0,0 +1,6 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });

1
client-app/nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="nativewind/types" />

11482
client-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
client-app/package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "dchain-messenger",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint ."
},
"dependencies": {
"@expo/metro-runtime": "~6.1.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "11.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "~54.0.0",
"expo-asset": "~12.0.12",
"expo-audio": "~1.1.1",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.8",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.11",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-video": "~3.0.16",
"expo-web-browser": "~15.0.10",
"nativewind": "^4.1.23",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/react": "~19.1.0",
"babel-preset-expo": "~54.0.10",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,35 @@
/** @type {import('tailwindcss').Config} */
//
// DChain messenger — dark-first palette inspired by the X-style Messages screen.
//
// `bg` is true black for OLED. `surface` / `surface2` lift for tiles,
// inputs and pressed states. `accent` is the icy blue used by the
// composer FAB and active filter pills.
module.exports = {
content: [
'./app/**/*.{js,jsx,ts,tsx}',
'./components/**/*.{js,jsx,ts,tsx}',
'./hooks/**/*.{js,jsx,ts,tsx}',
'./lib/**/*.{js,jsx,ts,tsx}',
],
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: {
bg: '#000000',
bg2: '#0a0a0a',
surface: '#111111',
surface2: '#1a1a1a',
line: '#1f1f1f',
text: '#ffffff',
subtext: '#8b8b8b',
muted: '#5a5a5a',
accent: '#1d9bf0',
ok: '#3ba55d',
warn: '#f0b35a',
err: '#f4212e',
},
},
},
plugins: [],
};

9
client-app/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
}
}
}

View File

@@ -29,6 +29,8 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime"
"runtime/debug"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -41,6 +43,7 @@ import (
"go-blockchain/consensus" "go-blockchain/consensus"
"go-blockchain/economy" "go-blockchain/economy"
"go-blockchain/identity" "go-blockchain/identity"
"go-blockchain/media"
"go-blockchain/node" "go-blockchain/node"
"go-blockchain/node/version" "go-blockchain/node/version"
"go-blockchain/p2p" "go-blockchain/p2p"
@@ -77,6 +80,10 @@ func main() {
registerRelay := flag.Bool("register-relay", envBoolOr("DCHAIN_REGISTER_RELAY", false), "submit REGISTER_RELAY tx on startup (env: DCHAIN_REGISTER_RELAY)") registerRelay := flag.Bool("register-relay", envBoolOr("DCHAIN_REGISTER_RELAY", false), "submit REGISTER_RELAY tx on startup (env: DCHAIN_REGISTER_RELAY)")
relayFee := flag.Uint64("relay-fee", envUint64Or("DCHAIN_RELAY_FEE", 1_000), "relay fee per message in µT (env: DCHAIN_RELAY_FEE)") relayFee := flag.Uint64("relay-fee", envUint64Or("DCHAIN_RELAY_FEE", 1_000), "relay fee per message in µT (env: DCHAIN_RELAY_FEE)")
mailboxDB := flag.String("mailbox-db", envOr("DCHAIN_MAILBOX_DB", "./mailboxdata"), "BadgerDB directory for relay mailbox (env: DCHAIN_MAILBOX_DB)") mailboxDB := flag.String("mailbox-db", envOr("DCHAIN_MAILBOX_DB", "./mailboxdata"), "BadgerDB directory for relay mailbox (env: DCHAIN_MAILBOX_DB)")
feedDB := flag.String("feed-db", envOr("DCHAIN_FEED_DB", "./feeddata"), "BadgerDB directory for social-feed post bodies (env: DCHAIN_FEED_DB)")
feedTTLDays := flag.Int("feed-ttl-days", int(envUint64Or("DCHAIN_FEED_TTL_DAYS", 30)), "how long feed posts are retained before auto-eviction (env: DCHAIN_FEED_TTL_DAYS)")
mediaSidecarURL := flag.String("media-sidecar-url", envOr("DCHAIN_MEDIA_SIDECAR_URL", ""), "URL of the media scrubber sidecar (FFmpeg-based video/audio re-encoder). Empty = images only (env: DCHAIN_MEDIA_SIDECAR_URL)")
allowUnscrubbedVideo := flag.Bool("allow-unscrubbed-video", envBoolOr("DCHAIN_ALLOW_UNSCRUBBED_VIDEO", false), "accept video uploads without server-side metadata scrubbing (only when no sidecar is configured). DANGEROUS — leaves EXIF/GPS/author tags intact (env: DCHAIN_ALLOW_UNSCRUBBED_VIDEO)")
govContractID := flag.String("governance-contract", envOr("DCHAIN_GOVERNANCE_CONTRACT", ""), "governance contract ID for dynamic chain parameters (env: DCHAIN_GOVERNANCE_CONTRACT)") govContractID := flag.String("governance-contract", envOr("DCHAIN_GOVERNANCE_CONTRACT", ""), "governance contract ID for dynamic chain parameters (env: DCHAIN_GOVERNANCE_CONTRACT)")
joinSeedURL := flag.String("join", envOr("DCHAIN_JOIN", ""), "bootstrap from a running node: comma-separated HTTP URLs (env: DCHAIN_JOIN)") joinSeedURL := flag.String("join", envOr("DCHAIN_JOIN", ""), "bootstrap from a running node: comma-separated HTTP URLs (env: DCHAIN_JOIN)")
// Observer mode: the node participates in the P2P network, applies // Observer mode: the node participates in the P2P network, applies
@@ -109,6 +116,16 @@ func main() {
// only for intentional migrations (e.g. importing data from another chain // only for intentional migrations (e.g. importing data from another chain
// into this network) — very dangerous. // into this network) — very dangerous.
allowGenesisMismatch := flag.Bool("allow-genesis-mismatch", false, "skip the safety check that aborts when the local genesis hash differs from the seed's. Use only for explicit chain migration.") allowGenesisMismatch := flag.Bool("allow-genesis-mismatch", false, "skip the safety check that aborts when the local genesis hash differs from the seed's. Use only for explicit chain migration.")
// ── Resource caps ───────────────────────────────────────────────────────
// All four accept 0 meaning "no limit". Enforcement model:
// * CPU — runtime.GOMAXPROCS(n): Go runtime won't use more than n OS threads for Go code.
// * RAM — debug.SetMemoryLimit: soft limit, the GC works harder as the heap approaches it.
// * Feed disk — hard refuse of new post bodies once the cap is crossed (existing posts keep serving).
// * Chain disk — warn-only periodic check; we can't hard-reject new blocks without breaking consensus.
maxCPU := flag.Int("max-cpu", int(envUint64Or("DCHAIN_MAX_CPU", 0)), "max CPU cores the node may use (GOMAXPROCS). 0 = all (env: DCHAIN_MAX_CPU)")
maxRAMMB := flag.Uint64("max-ram-mb", envUint64Or("DCHAIN_MAX_RAM_MB", 0), "soft Go heap limit in MiB (GOMEMLIMIT). 0 = unlimited (env: DCHAIN_MAX_RAM_MB)")
feedDiskMB := flag.Uint64("feed-disk-limit-mb", envUint64Or("DCHAIN_FEED_DISK_LIMIT_MB", 0), "disk quota for post bodies in MiB; new posts are refused with 507 once crossed. 0 = unlimited (env: DCHAIN_FEED_DISK_LIMIT_MB)")
chainDiskMB := flag.Uint64("chain-disk-limit-mb", envUint64Or("DCHAIN_CHAIN_DISK_LIMIT_MB", 0), "advisory disk cap for the chain DB dir in MiB; exceeding it logs a loud WARN every minute. 0 = unlimited (env: DCHAIN_CHAIN_DISK_LIMIT_MB)")
showVersion := flag.Bool("version", false, "print version info and exit") showVersion := flag.Bool("version", false, "print version info and exit")
flag.Parse() flag.Parse()
@@ -123,6 +140,10 @@ func main() {
// so subsequent logs inherit the format. // so subsequent logs inherit the format.
setupLogging(*logFormat) setupLogging(*logFormat)
// Apply CPU / RAM caps before anything else spins up so the runtime
// picks them up at first goroutine/heap allocation.
applyResourceCaps(*maxCPU, *maxRAMMB)
// Wire API access-control. A non-empty token gates writes; adding // Wire API access-control. A non-empty token gates writes; adding
// --api-private also gates reads. Logged up-front so the operator // --api-private also gates reads. Logged up-front so the operator
// sees what mode they're in. // sees what mode they're in.
@@ -634,6 +655,27 @@ func main() {
go mailbox.RunGC() go mailbox.RunGC()
log.Printf("[NODE] relay mailbox: %s", *mailboxDB) log.Printf("[NODE] relay mailbox: %s", *mailboxDB)
// --- Feed mailbox (social-feed post bodies, v2.0.0) ---
feedTTL := time.Duration(*feedTTLDays) * 24 * time.Hour
feedQuotaBytes := int64(*feedDiskMB) * 1024 * 1024
feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL, feedQuotaBytes)
if err != nil {
log.Fatalf("[NODE] feed mailbox: %v", err)
}
defer feedMailbox.Close()
if feedQuotaBytes > 0 {
log.Printf("[NODE] feed mailbox: %s (TTL %d days, disk quota %d MiB)", *feedDB, *feedTTLDays, *feedDiskMB)
} else {
log.Printf("[NODE] feed mailbox: %s (TTL %d days, no disk quota)", *feedDB, *feedTTLDays)
}
// Advisory chain-disk watcher. We can't refuse new blocks (consensus
// would stall), so instead we walk the chain DB dir every minute and
// log a loud WARN if the operator's budget is exceeded. Zero = disabled.
if *chainDiskMB > 0 {
go watchChainDisk(*dbPath, int64(*chainDiskMB)*1024*1024)
}
// Push-notify bus consumers whenever a fresh envelope lands in the // Push-notify bus consumers whenever a fresh envelope lands in the
// mailbox. Clients subscribed to `inbox:<my_x25519>` (via WS) get the // mailbox. Clients subscribed to `inbox:<my_x25519>` (via WS) get the
// event immediately so they no longer need to poll /relay/inbox. // event immediately so they no longer need to poll /relay/inbox.
@@ -854,8 +896,6 @@ func main() {
GetNFT: chain.NFT, GetNFT: chain.NFT,
GetNFTs: chain.NFTs, GetNFTs: chain.NFTs,
NFTsByOwner: chain.NFTsByOwner, NFTsByOwner: chain.NFTsByOwner,
GetChannel: chain.Channel,
GetChannelMembers: chain.ChannelMembers,
Events: sseHub, Events: sseHub,
WS: wsHub, WS: wsHub,
// Onboarding: expose libp2p peers + chain_id so new nodes/clients can // Onboarding: expose libp2p peers + chain_id so new nodes/clients can
@@ -920,6 +960,36 @@ func main() {
ContactRequests: func(pubKey string) ([]blockchain.ContactInfo, error) { ContactRequests: func(pubKey string) ([]blockchain.ContactInfo, error) {
return chain.ContactRequests(pubKey) return chain.ContactRequests(pubKey)
}, },
ResolveX25519: func(ed25519PubHex string) string {
info, err := chain.Identity(ed25519PubHex)
if err != nil || info == nil {
return ""
}
return info.X25519PubKey
},
}
// Media scrubber — strips EXIF/GPS/author/camera metadata from every
// uploaded image in-process, and forwards video/audio to the FFmpeg
// sidecar when configured. Mandatory for all /feed/publish traffic.
scrubber := media.NewScrubber(media.SidecarConfig{URL: *mediaSidecarURL})
if *mediaSidecarURL != "" {
log.Printf("[NODE] media sidecar: %s", *mediaSidecarURL)
} else {
log.Printf("[NODE] media sidecar: not configured (images scrubbed in-process; video/audio %s)",
map[bool]string{true: "stored unscrubbed (DANGEROUS)", false: "rejected"}[*allowUnscrubbedVideo])
}
feedConfig := node.FeedConfig{
Mailbox: feedMailbox,
HostingRelayPub: id.PubKeyHex(),
Scrubber: scrubber,
AllowUnscrubbedVideo: *allowUnscrubbedVideo,
GetPost: chain.Post,
LikeCount: chain.LikeCount,
HasLiked: chain.HasLiked,
PostsByAuthor: chain.PostsByAuthor,
Following: chain.Following,
} }
go func() { go func() {
@@ -942,6 +1012,7 @@ func main() {
if err := stats.ListenAndServe(*statsAddr, statsQuery, func(mux *http.ServeMux) { if err := stats.ListenAndServe(*statsAddr, statsQuery, func(mux *http.ServeMux) {
node.RegisterExplorerRoutes(mux, explorerQuery, routeFlags) node.RegisterExplorerRoutes(mux, explorerQuery, routeFlags)
node.RegisterRelayRoutes(mux, relayConfig) node.RegisterRelayRoutes(mux, relayConfig)
node.RegisterFeedRoutes(mux, feedConfig)
// POST /api/governance/link — link deployed contracts at runtime. // POST /api/governance/link — link deployed contracts at runtime.
// Body: {"governance": "<id>"} // Body: {"governance": "<id>"}
@@ -1248,11 +1319,38 @@ type keyJSON struct {
} }
func loadOrCreateIdentity(keyFile string) *identity.Identity { func loadOrCreateIdentity(keyFile string) *identity.Identity {
if data, err := os.ReadFile(keyFile); err == nil { // Key-file handling has a silent-failure mode that cost a genesis
// validator 21M tokens in the wild: if the file exists but we can't
// read it (e.g. mounted read-only under a different UID), ReadFile
// returns an error, we fall through to "generate", and the operator
// ends up with an ephemeral key whose pubkey doesn't match what's in
// keys/node.json on disk. Genesis allocation then lands on the
// ephemeral key that vanishes on restart.
//
// Distinguish "file doesn't exist" (normal — first boot, create)
// from "file exists but unreadable" (operator error — fail loudly).
if info, err := os.Stat(keyFile); err == nil {
// File is there. Any read failure now is an operator problem,
// not a bootstrap case.
_ = info
data, err := os.ReadFile(keyFile)
if err != nil {
log.Fatalf("[NODE] key file %s exists but can't be read: %v\n"+
"\thint: check file perms (should be readable by the node user) "+
"and that the mount isn't unexpectedly read-only.",
keyFile, err)
}
var kj keyJSON var kj keyJSON
if err := json.Unmarshal(data, &kj); err == nil { if err := json.Unmarshal(data, &kj); err != nil {
if id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv); err == nil { log.Fatalf("[NODE] key file %s is not valid JSON: %v", keyFile, err)
// If the file is missing X25519 keys, backfill and re-save. }
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 == "" { if kj.X25519Pub == "" {
kj.X25519Pub = id.X25519PubHex() kj.X25519Pub = id.X25519PubHex()
kj.X25519Priv = id.X25519PrivHex() kj.X25519Priv = id.X25519PrivHex()
@@ -1262,9 +1360,12 @@ func loadOrCreateIdentity(keyFile string) *identity.Identity {
} }
log.Printf("[NODE] loaded identity from %s", keyFile) log.Printf("[NODE] loaded identity from %s", keyFile)
return id return id
} else if !os.IsNotExist(err) {
// Something other than "file not found" — permission on the
// containing directory, broken symlink, etc. Also fail loudly.
log.Fatalf("[NODE] stat %s: %v", keyFile, err)
} }
} // File genuinely doesn't exist — first boot. Generate + save.
}
id, err := identity.Generate() id, err := identity.Generate()
if err != nil { if err != nil {
log.Fatalf("generate identity: %v", err) log.Fatalf("generate identity: %v", err)
@@ -1277,7 +1378,9 @@ func loadOrCreateIdentity(keyFile string) *identity.Identity {
} }
data, _ := json.MarshalIndent(kj, "", " ") data, _ := json.MarshalIndent(kj, "", " ")
if err := os.WriteFile(keyFile, data, 0600); err != nil { if err := os.WriteFile(keyFile, data, 0600); err != nil {
log.Printf("[NODE] warning: could not save key: %v", err) log.Printf("[NODE] warning: could not save key to %s: %v "+
"(ephemeral key in use — this node's identity will change on restart!)",
keyFile, err)
} else { } else {
log.Printf("[NODE] new identity saved to %s", keyFile) log.Printf("[NODE] new identity saved to %s", keyFile)
} }
@@ -1397,6 +1500,61 @@ func shortKeys(keys []string) []string {
// "text" (default) is handler-default human-readable format, same as bare // "text" (default) is handler-default human-readable format, same as bare
// log.Printf. "json" emits one JSON object per line with `time/level/msg` // log.Printf. "json" emits one JSON object per line with `time/level/msg`
// + any key=value attrs — what Loki/ELK ingest natively. // + any key=value attrs — what Loki/ELK ingest natively.
// applyResourceCaps wires the --max-cpu and --max-ram-mb flags into the Go
// runtime. Both are soft-ish: CPU clamps GOMAXPROCS (Go scheduler won't use
// more OS threads for Go code, though blocking syscalls can still spawn
// more); RAM sets GOMEMLIMIT (the GC tightens its collection schedule as
// the heap approaches the cap but cannot *force* a kernel OOM-free). Use
// container limits (cgroup / Docker --memory / --cpus) alongside these
// for a real ceiling — this is "please play nice", not "hard sandbox".
func applyResourceCaps(maxCPU int, maxRAMMB uint64) {
if maxCPU > 0 {
prev := runtime.GOMAXPROCS(maxCPU)
log.Printf("[NODE] CPU cap: GOMAXPROCS %d → %d", prev, maxCPU)
}
if maxRAMMB > 0 {
bytes := int64(maxRAMMB) * 1024 * 1024
debug.SetMemoryLimit(bytes)
log.Printf("[NODE] RAM cap: GOMEMLIMIT = %d MiB (soft, GC-enforced)", maxRAMMB)
}
}
// watchChainDisk periodically walks the chain BadgerDB directory and logs
// a WARN line whenever its size exceeds `limitBytes`. Runs forever — the
// process lifetime bounds it. We deliberately do *not* stop block
// production when the cap is crossed: a validator that refuses to apply
// blocks stalls consensus for everyone on the chain, which is worse than
// using more disk than the operator wanted. Treat this as a monitoring
// signal, e.g. feed it to Prometheus via an alertmanager scrape.
func watchChainDisk(dir string, limitBytes int64) {
tick := time.NewTicker(60 * time.Second)
defer tick.Stop()
for ; ; <-tick.C {
used := dirSize(dir)
if used > limitBytes {
log.Printf("[NODE] WARN chain disk over quota: %d MiB used > %d MiB limit at %s",
used>>20, limitBytes>>20, dir)
}
}
}
// dirSize returns the total byte size of all regular files under root,
// recursively. Errors on individual entries are ignored — this is an
// advisory metric, not a filesystem audit.
func dirSize(root string) int64 {
var total int64
_ = filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total
}
func setupLogging(format string) { func setupLogging(format string) {
var handler slog.Handler var handler slog.Handler
switch strings.ToLower(format) { switch strings.ToLower(format) {

View File

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

View File

@@ -0,0 +1,35 @@
# media-sidecar — FFmpeg-based metadata scrubber for DChain node.
#
# Build: docker build -t dchain/media-sidecar -f docker/media-sidecar/Dockerfile .
# Run: docker run -p 8090:8090 dchain/media-sidecar
# Compose: see docker-compose.yml; node points DCHAIN_MEDIA_SIDECAR_URL at it.
#
# Stage 1 — build a tiny static Go binary.
FROM golang:1.25-alpine AS build
WORKDIR /src
# Copy only what we need (the sidecar main is self-contained, no module
# deps on the rest of the repo, so this is a cheap, cache-friendly build).
COPY docker/media-sidecar/main.go ./main.go
RUN go mod init dchain-media-sidecar 2>/dev/null || true
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/media-sidecar ./main.go
# Stage 2 — runtime with ffmpeg. Alpine has a lean ffmpeg build (~90 MB
# total image, most of it codecs we actually need).
FROM alpine:3.19
RUN apk add --no-cache ffmpeg ca-certificates \
&& addgroup -S dchain && adduser -S -G dchain dchain
COPY --from=build /out/media-sidecar /usr/local/bin/media-sidecar
USER dchain
EXPOSE 8090
# Pin sensible defaults; operator overrides via docker-compose env.
ENV LISTEN_ADDR=:8090 \
FFMPEG_BIN=ffmpeg \
MAX_INPUT_MB=32 \
JOB_TIMEOUT_SECS=60
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:8090/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/media-sidecar"]

View File

@@ -0,0 +1,201 @@
// Media scrubber sidecar — tiny HTTP service that re-encodes video/audio
// through ffmpeg with all metadata stripped. Runs alongside the DChain
// node in docker-compose; the node calls it via DCHAIN_MEDIA_SIDECAR_URL.
//
// Contract (matches media.Scrubber in the node):
//
// POST /scrub/video Content-Type: video/* body: raw bytes
// → 200, Content-Type: video/mp4, body: cleaned bytes
// POST /scrub/audio Content-Type: audio/* body: raw bytes
// → 200, Content-Type: audio/ogg, body: cleaned bytes
//
// ffmpeg flags of note:
//
// -map_metadata -1 drop ALL metadata streams (title, author, encoder,
// GPS location atoms, XMP blocks, etc.)
// -map 0:v -map 0:a keep only video and audio streams — dumps attached
// pictures, subtitles, data channels that might carry
// hidden info
// -movflags +faststart
// put MOOV atom at the front so clients can start
// playback before the full download lands
// -c:v libx264 -crf 28 -preset fast
// h264 with aggressive-but-not-painful CRF; ~70-80%
// size reduction on phone-camera source
// -c:a libopus -b:a 64k
// opus at 64 kbps is transparent for speech, fine
// for music at feed quality
//
// Environment:
//
// LISTEN_ADDR default ":8090"
// FFMPEG_BIN default "ffmpeg" (must be in PATH)
// MAX_INPUT_MB default 32 — reject anything larger pre-ffmpeg
// JOB_TIMEOUT_SECS default 60
//
// The service is deliberately dumb: no queuing, no DB, no state. If you
// need higher throughput, run N replicas behind a TCP load balancer.
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"strconv"
"time"
)
func main() {
addr := envOr("LISTEN_ADDR", ":8090")
ffmpegBin := envOr("FFMPEG_BIN", "ffmpeg")
maxInputMB := envInt("MAX_INPUT_MB", 32)
jobTimeoutSecs := envInt("JOB_TIMEOUT_SECS", 60)
// Fail fast if ffmpeg is missing — easier to debug at container start
// than to surface cryptic errors per-request.
if _, err := exec.LookPath(ffmpegBin); err != nil {
log.Fatalf("ffmpeg not found in PATH (looked for %q): %v", ffmpegBin, err)
}
srv := &server{
ffmpegBin: ffmpegBin,
maxInputSize: int64(maxInputMB) * 1024 * 1024,
jobTimeout: time.Duration(jobTimeoutSecs) * time.Second,
}
mux := http.NewServeMux()
mux.HandleFunc("/scrub/video", srv.scrubVideo)
mux.HandleFunc("/scrub/audio", srv.scrubAudio)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
})
log.Printf("media-sidecar: listening on %s, ffmpeg=%s, max_input=%d MiB, timeout=%ds",
addr, ffmpegBin, maxInputMB, jobTimeoutSecs)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("ListenAndServe: %v", err)
}
}
type server struct {
ffmpegBin string
maxInputSize int64
jobTimeout time.Duration
}
func (s *server) scrubVideo(w http.ResponseWriter, r *http.Request) {
body, err := s.readLimited(r)
if err != nil {
httpErr(w, err.Error(), http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), s.jobTimeout)
defer cancel()
// Video path: re-encode with metadata strip, H.264 CRF 28, opus audio.
// Output format is MP4 (widest client compatibility).
args := []string{
"-hide_banner", "-loglevel", "error",
"-i", "pipe:0",
"-map", "0:v", "-map", "0:a?",
"-map_metadata", "-1",
"-c:v", "libx264", "-preset", "fast", "-crf", "28",
"-c:a", "libopus", "-b:a", "64k",
"-movflags", "+faststart+frag_keyframe",
"-f", "mp4",
"pipe:1",
}
out, ffErr, err := s.runFFmpeg(ctx, args, body)
if err != nil {
log.Printf("video scrub failed: %v | stderr=%s", err, ffErr)
httpErr(w, "ffmpeg failed: "+err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
_, _ = w.Write(out)
}
func (s *server) scrubAudio(w http.ResponseWriter, r *http.Request) {
body, err := s.readLimited(r)
if err != nil {
httpErr(w, err.Error(), http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), s.jobTimeout)
defer cancel()
args := []string{
"-hide_banner", "-loglevel", "error",
"-i", "pipe:0",
"-vn", "-map", "0:a",
"-map_metadata", "-1",
"-c:a", "libopus", "-b:a", "64k",
"-f", "ogg",
"pipe:1",
}
out, ffErr, err := s.runFFmpeg(ctx, args, body)
if err != nil {
log.Printf("audio scrub failed: %v | stderr=%s", err, ffErr)
httpErr(w, "ffmpeg failed: "+err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "audio/ogg")
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
_, _ = w.Write(out)
}
func (s *server) runFFmpeg(ctx context.Context, args []string, input []byte) ([]byte, string, error) {
cmd := exec.CommandContext(ctx, s.ffmpegBin, args...)
cmd.Stdin = bytes.NewReader(input)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return nil, stderr.String(), err
}
return stdout.Bytes(), stderr.String(), nil
}
func (s *server) readLimited(r *http.Request) ([]byte, error) {
if r.Method != http.MethodPost {
return nil, fmt.Errorf("method not allowed")
}
limited := io.LimitReader(r.Body, s.maxInputSize+1)
buf, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if int64(len(buf)) > s.maxInputSize {
return nil, fmt.Errorf("input exceeds %d bytes", s.maxInputSize)
}
return buf, nil
}
func httpErr(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write([]byte(msg))
}
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func envInt(k string, d int) int {
v := os.Getenv(k)
if v == "" {
return d
}
n, err := strconv.Atoi(v)
if err != nil {
return d
}
return n
}

View File

@@ -14,7 +14,8 @@ Swagger UI на `/swagger`. Эти два эндпоинта можно выкл
|---------|----------| |---------|----------|
| [chain.md](chain.md) | Блоки, транзакции, балансы, адреса, netstats, validators | | [chain.md](chain.md) | Блоки, транзакции, балансы, адреса, netstats, validators |
| [contracts.md](contracts.md) | Деплой, вызов, state, логи контрактов | | [contracts.md](contracts.md) | Деплой, вызов, state, логи контрактов |
| [relay.md](relay.md) | Relay-mailbox: отправка/приём encrypted envelopes | | [relay.md](relay.md) | Relay-mailbox: 1:1 E2E-шифрованные envelopes |
| [feed.md](feed.md) | Социальная лента: публикация постов, лайки, подписки, рекомендации, хэштеги (v2.0.0) |
## Discovery / metadata (always on) ## Discovery / metadata (always on)

279
docs/api/feed.md Normal file
View File

@@ -0,0 +1,279 @@
# Feed API (v2.0.0)
REST API для социальной ленты: публикация постов, лента подписок,
рекомендации, трендинг, хэштеги. Введено в **v2.0.0**, заменило
channel-модель.
## Архитектура
Лента устроена гибридно — **метаданные on-chain**, **тела постов
off-chain** в relay feed-mailbox:
```
┌─────────────────────┐ ┌──────────────────────────┐
│ on-chain state │ │ feed mailbox (BadgerDB)│
│ │ │ │
│ post:<id> → │ │ feedpost:<id> → │
│ {author, size, │ POST │ {content, attachment, │
│ content_hash, │ /feed/publish │ hashtags, type, ts} │
│ hosting_relay, │───────────────────►│ │
│ reply_to?, │ │ feedview:<id> → │
│ deleted?} │ │ uint64 (counter) │
│ │ │ │
│ follow:<A>:<B> │ │ feedtag:<tag>:<ts>:<id> │
│ like:<post>:<liker>│ │ (hashtag index) │
│ likecount:<post> │ │ │
└─────────────────────┘ └──────────────────────────┘
```
Разделение решает два противоречия: on-chain-состояние обеспечивает
provable авторство + экономические стимулы (оплата за байт); off-chain
тела позволяют хранить мегабайты картинок без раздувания истории
блоков. Hosting relay получает плату автора (через CREATE_POST tx);
другие ноды могут реплицировать через gossipsub (роадмап).
## Оплата постов
Автор поста платит `BasePostFee + Size × PostByteFee` µT за публикацию.
Полный fee кредитуется на баланс hosting-релея.
| Константа | Значение | Примечание |
|-----------|----------|-----------|
| `BasePostFee` | 1 000 µT (0.001 T) | Совпадает с MinFee (block-validation floor) |
| `PostByteFee` | 1 µT/byte | Размер = контент + attachment + ~128 B метаданных |
| `MaxPostSize` | 256 KiB | Hard cap, больше — 413 на `/feed/publish` |
Пример:
- 200-байтовый текстовый пост → ~1 328 µT (~0.0013 T)
- Текст + 30 KiB WebP-картинка → ~31 848 µT (~0.032 T)
- Видео 200 KiB → ~205 128 µT (~0.21 T)
## Server-side metadata scrubbing
**Обязательная** для всех аттачментов на `/feed/publish`:
- **Изображения** (JPEG/PNG/GIF/WebP): decode → downscale до 1080 px
→ re-encode как JPEG Q=75 через stdlib. EXIF/GPS/ICC/XMP/MakerNote
стираются by construction (stdlib JPEG encoder не пишет их).
- **Видео/аудио**: переадресуется во внешний **FFmpeg-сайдкар**
(контейнер `docker/media-sidecar/`). Если сайдкар не настроен через
`DCHAIN_MEDIA_SIDECAR_URL` и оператор не выставил
`DCHAIN_ALLOW_UNSCRUBBED_VIDEO`, видео-аплоад отклоняется с 503.
- **MIME mismatch**: если magic-байты не соответствуют заявленному
`Content-Type` — 400. Защита от PDF'а, замаскированного под картинку.
## Endpoints
### `POST /feed/publish`
Загружает тело поста, запускает скраббинг, сохраняет в feed-mailbox,
возвращает метаданные для последующей on-chain CREATE_POST tx.
**Request:**
```json
{
"post_id": "<hex 16 B>",
"author": "<ed25519_hex>",
"content": "Text of the post",
"content_type": "text/plain",
"attachment_b64": "<base64 image/video/audio>",
"attachment_mime": "image/jpeg",
"reply_to": "<optional parent post_id>",
"quote_of": "<optional quoted post_id>",
"sig": "<base64 Ed25519 sig>",
"ts": 1710000000
}
```
`sig = Ed25519.Sign(author_priv, "publish:<post_id>:<raw_content_hash_hex>:<ts>")`
где `raw_content_hash = sha256(content + raw_attachment_bytes)`
клиентский хэш **до** серверного скраба.
`ts` в пределах ±5 минут от серверного времени (anti-replay).
**Response:**
```json
{
"post_id": "<echo>",
"hosting_relay": "<ed25519_hex of this node>",
"content_hash": "<hex sha256 of post-scrub bytes>",
"size": 1234,
"hashtags": ["golang", "dchain"],
"estimated_fee_ut": 2234
}
```
Клиент затем собирает CREATE_POST tx с этими полями и сабмитит на
`/api/tx`. См. `buildCreatePostTx` в клиентской библиотеке.
### `GET /feed/post/{id}`
Тело поста со статами.
**Response:**
```json
{
"post_id": "...",
"author": "...",
"content": "...",
"content_type": "text/plain",
"hashtags": ["golang"],
"created_at": 1710000000,
"reply_to": "",
"quote_of": "",
"attachment": "<base64 bytes>",
"attachment_mime": "image/jpeg"
}
```
**Errors:** 404 если не найден; 410 если on-chain soft-deleted.
### `GET /feed/post/{id}/attachment`
Сырые байты аттачмента с корректным `Content-Type`. Используется
нативным image-loader'ом клиента:
```html
<img src="http://node/feed/post/abc/attachment" />
```
`Cache-Control: public, max-age=3600, immutable` — content-addressed
ресурс, кэш безопасен.
### `POST /feed/post/{id}/view`
Инкремент off-chain счётчика просмотров. Fire-and-forget, не требует
подписи (per-view tx был бы нереалистичен).
**Response:** `{"post_id": "...", "views": 42}`
### `GET /feed/post/{id}/stats?me=<pub>`
Агрегат для отображения. `me` опционален — если передан, в ответе
будет `liked_by_me`.
**Response:**
```json
{
"post_id": "...",
"views": 127,
"likes": 42,
"liked_by_me": true
}
```
### `GET /feed/author/{pub}?limit=N&before=<ts>`
Посты конкретного автора, newest-first.
Пагинация: передавайте `before=<created_at>` самого старого уже
загруженного поста, чтобы получить следующую страницу.
**Response:**
```json
{"author": "<pub>", "count": 20, "posts": [FeedPostItem]}
```
### `GET /feed/timeline?follower=<pub>&limit=N&before=<ts>`
Лента подписок: merged посты всех, на кого подписан follower, newest-
first. Требует, чтобы on-chain FOLLOW-транзакции уже скоммитились.
Пагинация через `before` идентично `/feed/author`.
### `GET /feed/trending?window=24&limit=N`
Топ постов за последние N часов, ранжированы по `likes × 3 + views`.
`window` — часы (1..168), по умолчанию 24.
Пагинация **не поддерживается** — это топ-срез, не список.
### `GET /feed/foryou?pub=<pub>&limit=N`
Рекомендации. Простая эвристика v2.0.0:
1. Кандидаты: посты последних 48 часов
2. Исключить: авторы, на которых уже подписан; уже лайкнутые; свои
3. Ранжировать: `likes × 3 + views + 1` (seed чтобы свежее вылезало)
Никаких ML — база для `v2.2.0 Feed algorithm` (half-life decay,
mutual-follow boost, hashtag-affinity).
### `GET /feed/hashtag/{tag}?limit=N`
Посты, помеченные хэштегом `#tag`. Tag case-insensitive.
Хэштеги авто-индексируются на `/feed/publish` из текста (regex
`#[A-Za-z0-9_\p{L}]{1,40}`, dedup, cap 8 per post).
## On-chain события
Клиент сабмитит эти транзакции через обычный `/api/tx` endpoint.
### `CREATE_POST`
```json
{
"type": "CREATE_POST",
"fee": <estimated_fee_ut>,
"payload": {
"post_id": "...",
"content_hash": "<base64 32-byte sha256>",
"size": 1234,
"hosting_relay": "<ed25519_hex>",
"reply_to": "",
"quote_of": ""
}
}
```
Валидации on-chain: `size > 0 && size <= MaxPostSize`,
`fee >= BasePostFee + size × PostByteFee`, `reply_to` и `quote_of`
взаимно исключают друг друга. Дубликат `post_id` отклоняется.
### `DELETE_POST`
```json
{"type": "DELETE_POST", "fee": 1000, "payload": {"post_id": "..."}}
```
Только автор может удалить. Soft-delete (post остаётся в chain, но
помечен `deleted=true`; читатели получают 410).
### `FOLLOW` / `UNFOLLOW`
```json
{"type": "FOLLOW", "to": "<target_ed25519>", "fee": 1000, "payload": {}}
```
Двусторонний on-chain индекс: `follow:<A>:<B>` + `followin:<B>:<A>`.
`UNFOLLOW` зеркально удаляет оба ключа.
### `LIKE_POST` / `UNLIKE_POST`
```json
{"type": "LIKE_POST", "fee": 1000, "payload": {"post_id": "..."}}
```
Дубль-лайк отклоняется. Кэшированный counter `likecount:<post>`
хранит O(1) счётчик.
## Pricing / economics (резюме)
| Операция | Cost | Куда уходит |
|----------|------|-------------|
| `CREATE_POST` | 1000 + size×1 µT | Hosting relay |
| `DELETE_POST` | 1000 µT | Block validator |
| `FOLLOW` / `UNFOLLOW` | 1000 µT | Block validator |
| `LIKE_POST` / `UNLIKE_POST` | 1000 µT | Block validator |
| `/feed/post/{id}/view` | 0 | Off-chain counter |
Причина такого разделения: основные бизнес-действия (публикация) едут
на «хостера контента», а мелкие социальные tx (лайк/подписка) — на
валидатора сети, как обычные tx.
## Клиентские вспомогательные функции
В `client-app/lib/feed.ts`:
- `publishPost(params)` — подпись + POST /feed/publish
- `publishAndCommit(params)` — publish + CREATE_POST tx в одну операцию
- `fetchTimeline`, `fetchForYou`, `fetchTrending`, `fetchAuthorPosts`,
`fetchHashtag`, `fetchPost`, `fetchStats`, `bumpView`
- `buildCreatePostTx`, `buildDeletePostTx`, `buildFollowTx`,
`buildUnfollowTx`, `buildLikePostTx`, `buildUnlikePostTx`
- `likePost`, `unlikePost`, `followUser`, `unfollowUser`, `deletePost`
(build + submitTx bundled)

View File

@@ -1,28 +1,83 @@
# Relay API # Relay API
REST API для работы с шифрованными сообщениями через relay-сеть. REST API для работы с зашифрованными 1:1-сообщениями через relay-сеть.
Сообщения шифруются E2E с использованием NaCl (X25519 + XSalsa20-Poly1305). Relay хранит зашифрованные конверты и доставляет их получателям. Сообщения шифруются E2E с использованием NaCl (X25519 + XSalsa20-Poly1305).
Relay хранит зашифрованные конверты до 7 дней (настраиваемо через
`DCHAIN_MAILBOX_TTL_DAYS`) и доставляет их получателям по запросу или
через WebSocket push.
## Отправить сообщение > **Это API для 1:1 чатов.** Для публичных постов и ленты — см.
> [`feed.md`](feed.md).
### `POST /relay/send` Server-side guarantees (v1.0.x hardening):
- `envelope.ID` пересчитывается канонически (`sha256(nonce||ct)[:16]`)
на `mailbox.Store` — защита от content-replay.
- `envelope.SentAt` переписывается серверным `time.Now().Unix()` — клиент
не может back-date/future-date сообщения.
- `/relay/broadcast` и `/relay/inbox/*` обёрнуты в rate-limiter
(`withSubmitTxGuards` / `withReadLimit`); одиночный атакующий не может
через FIFO-eviction выбросить реальные сообщения получателя.
- `DELETE /relay/inbox/{id}` требует Ed25519-подпись владельца, связанную
с x25519-ключом через identity-реестр.
Зашифровать и отправить сообщение получателю. ## Опубликовать envelope
### `POST /relay/broadcast` — **рекомендованный E2E-путь**
Клиент запечатывает сообщение через NaCl `box` своим X25519-privkey на
публичный ключ получателя → отправляет готовый envelope. Сервер
проверяет размер, канонизирует id/timestamp, сохраняет в mailbox и
гошепит пирам.
**Request body:**
```json
{
"envelope": {
"id": "<hex>",
"sender_pub": "<x25519_hex>",
"recipient_pub": "<x25519_hex>",
"sender_ed25519_pub": "<ed25519_hex>",
"fee_ut": 0,
"fee_sig": null,
"nonce": "<base64 24B>",
"ciphertext": "<base64 NaCl box>",
"sent_at": 1710000000
}
}
```
**Response:** `{"id": "<canonical_id>", "status": "broadcast"}`
> Поле `envelope.id` сервер **перезапишет** на
> `hex(sha256(nonce||ciphertext)[:16])`; клиент может прислать любое
> непустое значение.
```bash
curl -X POST http://localhost:8081/relay/broadcast \
-H "Content-Type: application/json" \
-d @envelope.json
```
---
## Отправить plaintext через релей (legacy / non-E2E)
### `POST /relay/send` — **⚠ НЕ E2E**
Нода запечатывает сообщение **своим** X25519-ключом. Получатель не
сможет расшифровать без знания privkey релея, и отправитель не
аутентифицируется. Оставлено для backward-compat; в продакшн-чатах
используйте `/relay/broadcast`.
**Request body:** **Request body:**
```json ```json
{ {
"recipient_pub": "<x25519_hex>", "recipient_pub": "<x25519_hex>",
"msg_b64": "<base64_encoded_message>" "msg_b64": "<base64_encoded_plaintext>"
} }
``` ```
| Поле | Тип | Описание |
|------|-----|---------|
| `recipient_pub` | string | X25519 public key получателя (hex) |
| `msg_b64` | string | Сообщение в base64 |
```bash ```bash
MSG=$(echo -n "Hello, Bob!" | base64) MSG=$(echo -n "Hello, Bob!" | base64)
curl -X POST http://localhost:8081/relay/send \ curl -X POST http://localhost:8081/relay/send \
@@ -30,22 +85,15 @@ curl -X POST http://localhost:8081/relay/send \
-d "{\"recipient_pub\":\"$BOB_X25519\",\"msg_b64\":\"$MSG\"}" -d "{\"recipient_pub\":\"$BOB_X25519\",\"msg_b64\":\"$MSG\"}"
``` ```
**Response:** **Response:** `{"id": "env-abc123", "recipient_pub": "...", "status": "sent"}`
```json
{
"id": "env-abc123",
"recipient_pub": "...",
"status": "sent"
}
```
--- ---
## Broadcast конверта ## Broadcast конверта (legacy alias)
### `POST /relay/broadcast` ### `POST /relay/broadcast`
Опубликовать pre-sealed конверт (для light-клиентов, которые шифруют на своей стороне). См. выше. Это основной E2E-путь.
**Request body:** **Request body:**
```json ```json
@@ -132,18 +180,35 @@ curl "http://localhost:8081/relay/inbox/count?pub=$MY_X25519"
--- ---
### `DELETE /relay/inbox/{envID}?pub=<hex>` ### `DELETE /relay/inbox/{envID}?pub=<x25519hex>`
Удалить сообщение из inbox. Удалить сообщение из inbox. **Требует подписи** Ed25519-ключа, чья
identity связана с `?pub=<x25519>` через on-chain identity-реестр —
защита от grief-DELETE любым знающим ваш pubkey.
**Request body:**
```json
{
"ed25519_pub": "<hex>",
"sig": "<base64 Ed25519 signature>",
"ts": 1710000000
}
```
Где `sig = Ed25519.Sign(priv, "inbox-delete:<envID>:<x25519pub>:<ts>")`.
`ts` должен быть в пределах ±5 минут от серверного времени (anti-replay).
```bash ```bash
curl -X DELETE "http://localhost:8081/relay/inbox/env-abc123?pub=$MY_X25519" # подпись: printf 'inbox-delete:env-abc123:%s:%d' "$MY_X25519" "$TS" | sign
curl -X DELETE "http://localhost:8081/relay/inbox/env-abc123?pub=$MY_X25519" \
-H "Content-Type: application/json" \
-d "{\"ed25519_pub\":\"$MY_ED25519\",\"sig\":\"$SIG\",\"ts\":$TS}"
``` ```
**Response:** **Response:** `{"id": "env-abc123", "status": "deleted"}`
```json
{"id": "env-abc123", "status": "deleted"} **Errors:** `403` если подпись/идентичность не совпадает; `503` если
``` на ноде не настроен identity-resolver (DCHAIN_DISABLE_INBOX_DELETE).
--- ---

View File

@@ -2,16 +2,18 @@
## Обзор ## Обзор
DChain — это L1-блокчейн для децентрализованного мессенджера. Архитектура разделена на четыре слоя: DChain — это L1-блокчейн для децентрализованного мессенджера + социальной ленты. Архитектура разделена на четыре слоя:
``` ```
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ L3 Application — messaging, usernames, auctions, escrow │ L3 Application — messaging, social feed, usernames,
│ auctions, escrow │
│ (Smart contracts: username_registry, governance, auction, │ │ (Smart contracts: username_registry, governance, auction, │
│ escrow; deployed on-chain via DEPLOY_CONTRACT) │ │ escrow; native feed events; deployed on-chain) │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ L2 Transport — relay mailbox, E2E NaCl encryption │ L2 Transport — relay mailbox (1:1 E2E) +
(relay/, mailbox, GossipSub envelopes, RELAY_PROOF tx) feed mailbox (public posts + attachments)
│ (relay/, media scrubber, GossipSub envelopes, RELAY_PROOF) │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ L1 Chain — PBFT consensus, WASM VM, BadgerDB │ │ L1 Chain — PBFT consensus, WASM VM, BadgerDB │
│ (blockchain/, consensus/, vm/, identity/) │ │ (blockchain/, consensus/, vm/, identity/) │
@@ -189,6 +191,50 @@ Alice node1 (relay) Bob
--- ---
## Социальная лента (v2.0.0)
Публичные посты в Twitter/VK-стиле. Заменила channel-модель (каналы
удалены полностью). Живёт гибридно — метаданные on-chain, тела в relay
feed-mailbox:
```
Alice node1 (hosting relay) Bob
│ │ │
│─ POST /feed/publish ──────▶│ │
│ (body + EXIF-scrub) │ │
│ │ store in feed-mailbox │
│ │ (TTL 30 days) │
│ │ │
│◀── response: content_hash, │ │
│ estimated_fee_ut, id │ │
│ │ │
│─ CREATE_POST tx ──────────▶│── PBFT commit ──────────▶ │
│ (fee = 1000 + size × 1) │ │
│ │ on-chain: post:<id> │
│ │ │
│ │ │─ GET /feed/timeline
│ │◀── merge followed authors │ (from chain)
│ │ │
│ │── GET /feed/post/{id} ───▶│
│ │ (body from mailbox) │
```
**Экономика:**
- Автор платит `BasePostFee(1000) + size × PostByteFee(1)` µT
- Вся плата уходит `hosting_relay` пубкею — компенсация за хранение
- `MaxPostSize = 256 KiB` — hard cap, защищает мейлбокс от abuse
**Метаданные:**
- EXIF/GPS/camera-info удаляется обязательно на сервере (in-proc для
изображений, через FFmpeg-сайдкар для видео)
- Лайки / follows — on-chain транзакции (provable + anti-Sybil fee)
- Просмотры — off-chain counter в relay (on-chain было бы абсурдно)
- Хэштеги — авто-индекс при publish, inverted-index в BadgerDB
Детали — [api/feed.md](api/feed.md).
---
## Динамические валидаторы ## Динамические валидаторы
Сет валидаторов хранится on-chain в `validator:<pubkey>`. Любой текущий валидатор может добавить или убрать другого (ADD_VALIDATOR / REMOVE_VALIDATOR tx). После commit такого блока PBFT-движок перезагружает сет без рестарта ноды. Сет валидаторов хранится on-chain в `validator:<pubkey>`. Любой текущий валидатор может добавить или убрать другого (ADD_VALIDATOR / REMOVE_VALIDATOR tx). После commit такого блока PBFT-движок перезагружает сет без рестарта ноды.

View File

@@ -147,8 +147,12 @@ txchron:<block20d>:<seq04d> → tx_id (recent-tx index)
balance:<pubkey> → uint64 (µT) balance:<pubkey> → uint64 (µT)
stake:<pubkey> → uint64 (µT) stake:<pubkey> → uint64 (µT)
id:<pubkey> → JSON RegisterKeyPayload id:<pubkey> → JSON RegisterKeyPayload
chan:<channelID> → JSON CreateChannelPayload post:<postID> → JSON PostRecord (v2.0.0 social feed)
chan-member:<ch>:<pub> → "" postbyauthor:<pub>:<ts>:<id> → "" (chrono index, newest-first scan)
follow:<A>:<B> → "" (A follows B)
followin:<B>:<A> → "" (reverse index for Followers())
like:<postID>:<liker> → "" (presence)
likecount:<postID> → uint64 (cached counter, O(1) reads)
contract:<contractID> → JSON ContractRecord contract:<contractID> → JSON ContractRecord
cstate:<contractID>:<key> → bytes cstate:<contractID>:<key> → bytes
clog:<ct>:<block>:<seq> → JSON ContractLogEntry clog:<ct>:<block>:<seq> → JSON ContractLogEntry

View File

@@ -44,10 +44,10 @@ curl -s http://localhost:8080/api/well-known-version | jq .
}, },
"protocol_version": 1, "protocol_version": 1,
"features": [ "features": [
"access_token", "chain_id", "channels_v1", "contract_logs", "access_token", "chain_id", "contract_logs",
"fan_out", "identity_registry", "native_username_registry", "fan_out", "feed_v2", "identity_registry", "media_scrub",
"onboarding_api", "payment_channels", "relay_mailbox", "native_username_registry", "onboarding_api", "payment_channels",
"ws_submit_tx" "relay_broadcast", "relay_mailbox", "ws_submit_tx"
], ],
"chain_id": "dchain-ddb9a7e37fc8" "chain_id": "dchain-ddb9a7e37fc8"
} }

21
go.mod
View File

@@ -1,6 +1,6 @@
module go-blockchain module go-blockchain
go 1.21 go 1.25.0
require ( require (
github.com/dgraph-io/badger/v4 v4.2.0 github.com/dgraph-io/badger/v4 v4.2.0
@@ -9,7 +9,12 @@ require (
github.com/libp2p/go-libp2p-pubsub v0.10.0 github.com/libp2p/go-libp2p-pubsub v0.10.0
github.com/multiformats/go-multiaddr v0.12.3 github.com/multiformats/go-multiaddr v0.12.3
github.com/tetratelabs/wazero v1.7.3 github.com/tetratelabs/wazero v1.7.3
golang.org/x/crypto v0.18.0 golang.org/x/crypto v0.49.0
)
require (
golang.org/x/image v0.39.0
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
) )
require ( require (
@@ -114,12 +119,12 @@ require (
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect go.uber.org/zap v1.26.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/mod v0.13.0 // indirect golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.17.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.4.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.14.0 // indirect golang.org/x/tools v0.43.0 // indirect
gonum.org/v1/gonum v0.13.0 // indirect gonum.org/v1/gonum v0.13.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
lukechampine.com/blake3 v1.2.1 // indirect lukechampine.com/blake3 v1.2.1 // indirect

36
go.sum
View File

@@ -123,8 +123,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
@@ -443,11 +443,13 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -459,8 +461,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -479,8 +481,8 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -494,8 +496,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -517,15 +519,17 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -545,8 +549,8 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

332
media/scrub.go Normal file
View File

@@ -0,0 +1,332 @@
// Package media contains metadata scrubbing and re-compression helpers for
// files uploaded to the social feed.
//
// Why this exists
// ---------------
// Every image file carries an EXIF block that can leak:
// - GPS coordinates where the photo was taken
// - Camera model, serial number, lens
// - Original timestamp (even if the user clears their clock)
// - Software name / version
// - Author / copyright fields
// - A small embedded thumbnail that may leak even after cropping
//
// Videos and audio have analogous containers (MOV/MP4 atoms, ID3 tags,
// Matroska tags). For a social feed that prides itself on privacy we
// can't trust the client to have stripped all of it — we scrub again
// on the server before persisting the file to the feed mailbox.
//
// Strategy
// --------
// Images: decode → strip any ICC profile → re-encode with the stdlib
// JPEG/PNG encoders. These encoders DO NOT emit EXIF, so re-encoding is
// a complete scrub by construction. Output is JPEG (quality 75) unless
// the input is a lossless PNG small enough to keep as PNG.
//
// Videos: require an external ffmpeg worker (the "media sidecar") —
// cannot do this in pure Go without a huge CGo footprint. A tiny HTTP
// contract (see docs/media-sidecar.md) lets node operators plug in
// compressO-like services behind an env var. If the sidecar is not
// configured, videos are stored as-is with a LOG WARNING — the operator
// decides whether to accept that risk.
//
// Magic-byte detection: the claimed Content-Type must match what's
// actually in the bytes; mismatches are rejected (prevents a PDF
// labelled as image/jpeg from bypassing the scrubber).
package media
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"net/http"
"strings"
"time"
// Register decoders for the formats we accept.
_ "image/gif"
_ "golang.org/x/image/webp"
)
// Errors returned by scrubber.
var (
// ErrUnsupportedMIME is returned when the caller claims a MIME we
// don't know how to scrub.
ErrUnsupportedMIME = errors.New("unsupported media type")
// ErrMIMEMismatch is returned when the bytes don't match the claimed
// MIME — blocks a crafted upload from bypassing the scrubber.
ErrMIMEMismatch = errors.New("actual bytes don't match claimed content-type")
// ErrSidecarUnavailable is returned when video scrubbing was required
// but no external worker is configured and the operator policy does
// not allow unscrubbed video storage.
ErrSidecarUnavailable = errors.New("media sidecar required for video scrubbing but not configured")
)
// ── Image scrubbing ────────────────────────────────────────────────────────
// ImageMaxDim caps the larger dimension of a stored image. 1080px is the
// "full-HD-ish" sweet spot — larger rarely matters on a phone feed and
// drops file size dramatically. The client is expected to have downscaled
// already (expo-image-manipulator), but we re-apply the cap server-side
// as a defence-in-depth and to guarantee uniform storage cost.
const ImageMaxDim = 1080
// ImageJPEGQuality is the re-encode quality for JPEG output. 75 balances
// perceived quality with size — below 60 artifacts become visible, above
// 85 we're paying for noise we can't see.
const ImageJPEGQuality = 75
// ScrubImage decodes src, removes all metadata (by way of re-encoding
// with the stdlib JPEG encoder), optionally downscales to ImageMaxDim,
// and returns the clean JPEG bytes + the canonical MIME the caller
// should store.
//
// claimedMIME is what the client said the file is; if the bytes don't
// match, ErrMIMEMismatch is returned. Accepts image/jpeg, image/png,
// image/gif, image/webp on input; output is always image/jpeg (one less
// branch in the reader, and no decoder has to touch EXIF).
func ScrubImage(src []byte, claimedMIME string) (out []byte, outMIME string, err error) {
actualMIME := detectMIME(src)
if !isImageMIME(actualMIME) {
return nil, "", fmt.Errorf("%w: %s", ErrUnsupportedMIME, actualMIME)
}
if claimedMIME != "" && !mimesCompatible(claimedMIME, actualMIME) {
return nil, "", fmt.Errorf("%w: claimed %s, actual %s",
ErrMIMEMismatch, claimedMIME, actualMIME)
}
img, _, err := image.Decode(bytes.NewReader(src))
if err != nil {
return nil, "", fmt.Errorf("decode image: %w", err)
}
// Downscale if needed. We use a draw-based nearest-neighbour style
// approach via stdlib to avoid pulling in x/image/draw unless we need
// higher-quality resampling. For feed thumbnails nearest is fine since
// content is typically downsampled already.
if bounds := img.Bounds(); bounds.Dx() > ImageMaxDim || bounds.Dy() > ImageMaxDim {
img = downscale(img, ImageMaxDim)
}
// Re-encode as JPEG. stdlib's jpeg.Encode writes ZERO metadata —
// no EXIF, no ICC, no XMP, no MakerNote. That's the scrub.
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: ImageJPEGQuality}); err != nil {
return nil, "", fmt.Errorf("encode jpeg: %w", err)
}
return buf.Bytes(), "image/jpeg", nil
}
// downscale returns a new image whose larger dimension equals maxDim,
// preserving aspect ratio. Uses stdlib image.NewRGBA + a nearest-neighbour
// copy loop — good enough for feed images that are already compressed.
func downscale(src image.Image, maxDim int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
var nw, nh int
if w >= h {
nw = maxDim
nh = h * maxDim / w
} else {
nh = maxDim
nw = w * maxDim / h
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
for y := 0; y < nh; y++ {
sy := b.Min.Y + y*h/nh
for x := 0; x < nw; x++ {
sx := b.Min.X + x*w/nw
dst.Set(x, y, src.At(sx, sy))
}
}
return dst
}
// pngEncoder is kept for callers that explicitly want lossless output
// (future — not used by ScrubImage which always produces JPEG).
var pngEncoder = png.Encoder{CompressionLevel: png.BestCompression}
// ── MIME detection & validation ────────────────────────────────────────────
// detectMIME inspects magic bytes to figure out what the data actually is,
// independent of what the caller claimed. Matches the subset of types
// stdlib http.DetectContentType handles, refined for our use.
func detectMIME(data []byte) string {
if len(data) == 0 {
return ""
}
// http.DetectContentType handles most formats correctly (JPEG, PNG,
// GIF, WebP, MP4, WebM, MP3, OGG). We only refine when needed.
return strings.SplitN(http.DetectContentType(data), ";", 2)[0]
}
func isImageMIME(m string) bool {
switch m {
case "image/jpeg", "image/png", "image/gif", "image/webp":
return true
}
return false
}
func isVideoMIME(m string) bool {
switch m {
case "video/mp4", "video/webm", "video/quicktime":
return true
}
return false
}
func isAudioMIME(m string) bool {
switch m {
case "audio/mpeg", "audio/ogg", "audio/webm", "audio/wav", "audio/mp4":
return true
}
return false
}
// mimesCompatible tolerates small aliases (image/jpg vs image/jpeg, etc.)
// so a misspelled client header doesn't cause a 400. Claimed MIME is
// the caller's; actual is from magic bytes — we trust magic bytes when
// they disagree with a known-silly alias.
func mimesCompatible(claimed, actual string) bool {
claimed = strings.ToLower(strings.TrimSpace(claimed))
if claimed == actual {
return true
}
aliases := map[string]string{
"image/jpg": "image/jpeg",
"image/x-png": "image/png",
"video/mov": "video/quicktime",
}
if canon, ok := aliases[claimed]; ok && canon == actual {
return true
}
return false
}
// ── Video scrubbing (sidecar) ──────────────────────────────────────────────
// SidecarConfig describes how to reach an external media scrubber worker
// (typically a tiny FFmpeg-wrapper HTTP service running alongside the
// node — see docs/media-sidecar.md). Leaving URL empty disables sidecar
// use; callers then decide whether to fall back to "store as-is and warn"
// or to reject video uploads entirely.
type SidecarConfig struct {
// URL is the base URL of the sidecar. Expected routes:
//
// POST /scrub/video body: raw bytes → returns scrubbed bytes
// POST /scrub/audio body: raw bytes → returns scrubbed bytes
//
// Both MUST strip metadata (-map_metadata -1 in ffmpeg terms) and
// re-encode with a sane bitrate cap (default: H.264 CRF 28 for
// video, libopus 96k for audio). See the reference implementation
// at docker/media-sidecar/ in this repo.
URL string
// Timeout guards against a hung sidecar. 30s is enough for a 5 MB
// video on modest hardware; larger inputs should be pre-compressed
// by the client.
Timeout time.Duration
// MaxInputBytes caps what we forward to the sidecar (protects
// against an attacker tying up the sidecar on a 1 GB upload).
MaxInputBytes int64
}
// Scrubber bundles image + sidecar capabilities. Create once at node
// startup and reuse.
type Scrubber struct {
sidecar SidecarConfig
http *http.Client
}
// NewScrubber returns a Scrubber. sidecar.URL may be empty (image-only
// mode) — in that case ScrubVideo / ScrubAudio return ErrSidecarUnavailable.
func NewScrubber(sidecar SidecarConfig) *Scrubber {
if sidecar.Timeout == 0 {
sidecar.Timeout = 30 * time.Second
}
if sidecar.MaxInputBytes == 0 {
sidecar.MaxInputBytes = 16 * 1024 * 1024 // 16 MiB input → client should have shrunk
}
return &Scrubber{
sidecar: sidecar,
http: &http.Client{
Timeout: sidecar.Timeout,
},
}
}
// Scrub picks the right strategy based on the actual MIME of the bytes.
// Returns the cleaned payload and the canonical MIME to store under.
func (s *Scrubber) Scrub(ctx context.Context, src []byte, claimedMIME string) ([]byte, string, error) {
actual := detectMIME(src)
if claimedMIME != "" && !mimesCompatible(claimedMIME, actual) {
return nil, "", fmt.Errorf("%w: claimed %s, actual %s",
ErrMIMEMismatch, claimedMIME, actual)
}
switch {
case isImageMIME(actual):
// Images handled in-process, no sidecar needed.
return ScrubImage(src, claimedMIME)
case isVideoMIME(actual):
return s.scrubViaSidecar(ctx, "/scrub/video", src, actual)
case isAudioMIME(actual):
return s.scrubViaSidecar(ctx, "/scrub/audio", src, actual)
default:
return nil, "", fmt.Errorf("%w: %s", ErrUnsupportedMIME, actual)
}
}
// scrubViaSidecar POSTs src to the configured sidecar route and returns
// the response bytes. Errors:
// - ErrSidecarUnavailable if sidecar.URL is empty
// - wrapping the HTTP error otherwise
func (s *Scrubber) scrubViaSidecar(ctx context.Context, path string, src []byte, actual string) ([]byte, string, error) {
if s.sidecar.URL == "" {
return nil, "", ErrSidecarUnavailable
}
if int64(len(src)) > s.sidecar.MaxInputBytes {
return nil, "", fmt.Errorf("input exceeds sidecar max %d bytes", s.sidecar.MaxInputBytes)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(s.sidecar.URL, "/")+path, bytes.NewReader(src))
if err != nil {
return nil, "", fmt.Errorf("build sidecar request: %w", err)
}
req.Header.Set("Content-Type", actual)
resp, err := s.http.Do(req)
if err != nil {
return nil, "", fmt.Errorf("call sidecar: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, "", fmt.Errorf("sidecar returned %d: %s", resp.StatusCode, string(body))
}
// Limit the reply we buffer — an evil sidecar could try to amplify.
const maxReply = 64 * 1024 * 1024 // 64 MiB hard cap
out, err := io.ReadAll(io.LimitReader(resp.Body, maxReply))
if err != nil {
return nil, "", fmt.Errorf("read sidecar reply: %w", err)
}
respMIME := resp.Header.Get("Content-Type")
if respMIME == "" {
respMIME = actual
}
return out, strings.SplitN(respMIME, ";", 2)[0], nil
}
// IsSidecarConfigured reports whether video/audio scrubbing is available.
// Callers can use this to decide whether to accept video attachments or
// reject them with a clear "this node doesn't support video" message.
func (s *Scrubber) IsSidecarConfigured() bool {
return s.sidecar.URL != ""
}

149
media/scrub_test.go Normal file
View File

@@ -0,0 +1,149 @@
package media
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"testing"
)
// TestScrubImageRemovesEXIF: our scrubber re-encodes via stdlib JPEG, which
// does not preserve EXIF by construction. We verify that a crafted input
// carrying an EXIF marker produces an output without one.
func TestScrubImageRemovesEXIF(t *testing.T) {
// Build a JPEG that explicitly contains an APP1 EXIF segment.
// Structure: JPEG SOI + APP1 with "Exif\x00\x00" header + real image data.
var base bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
for y := 0; y < 8; y++ {
for x := 0; x < 8; x++ {
img.Set(x, y, color.RGBA{uint8(x * 32), uint8(y * 32), 128, 255})
}
}
if err := jpeg.Encode(&base, img, &jpeg.Options{Quality: 80}); err != nil {
t.Fatalf("encode base: %v", err)
}
input := injectEXIF(t, base.Bytes())
if !bytes.Contains(input, []byte("Exif\x00\x00")) {
t.Fatalf("test setup broken: EXIF not injected")
}
// Also drop an identifiable string in the EXIF payload so we can prove
// it's gone.
if !bytes.Contains(input, []byte("SECRETGPS")) {
t.Fatalf("test setup broken: EXIF marker not injected")
}
cleaned, mime, err := ScrubImage(input, "image/jpeg")
if err != nil {
t.Fatalf("ScrubImage: %v", err)
}
if mime != "image/jpeg" {
t.Errorf("mime: got %q, want image/jpeg", mime)
}
// Verify the scrubbed output doesn't contain our canary string.
if bytes.Contains(cleaned, []byte("SECRETGPS")) {
t.Errorf("EXIF canary survived scrub — metadata not stripped")
}
// Verify the output doesn't contain the EXIF segment marker.
if bytes.Contains(cleaned, []byte("Exif\x00\x00")) {
t.Errorf("EXIF header string survived scrub")
}
// Output must still be a valid JPEG.
if _, err := jpeg.Decode(bytes.NewReader(cleaned)); err != nil {
t.Errorf("scrubbed output is not a valid JPEG: %v", err)
}
}
// injectEXIF splices a synthetic APP1 EXIF segment after the JPEG SOI.
// Segment layout: FF E1 <len_hi> <len_lo> "Exif\0\0" + arbitrary payload.
// The payload is NOT valid TIFF — that's fine; stdlib JPEG decoder skips
// unknown APP1 segments rather than aborting.
func injectEXIF(t *testing.T, src []byte) []byte {
t.Helper()
if len(src) < 2 || src[0] != 0xFF || src[1] != 0xD8 {
t.Fatalf("not a JPEG")
}
payload := []byte("Exif\x00\x00" + "SECRETGPS-51.5074N-0.1278W-Canon-EOS-R5")
segmentLen := len(payload) + 2 // +2 = 2 bytes of len field itself
var seg bytes.Buffer
seg.Write([]byte{0xFF, 0xE1})
seg.WriteByte(byte(segmentLen >> 8))
seg.WriteByte(byte(segmentLen & 0xff))
seg.Write(payload)
out := make([]byte, 0, len(src)+seg.Len())
out = append(out, src[:2]...) // SOI
out = append(out, seg.Bytes()...)
out = append(out, src[2:]...)
return out
}
// TestScrubImageMIMEMismatch: rejects bytes that don't match claimed MIME.
func TestScrubImageMIMEMismatch(t *testing.T) {
var buf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
jpeg.Encode(&buf, img, nil)
// Claim it's a PNG.
_, _, err := ScrubImage(buf.Bytes(), "image/png")
if err == nil {
t.Fatalf("expected ErrMIMEMismatch, got nil")
}
}
// TestScrubImageDownscale: images over ImageMaxDim are shrunk.
func TestScrubImageDownscale(t *testing.T) {
// Make a 2000×1000 image — larger dim 2000 > 1080.
img := image.NewRGBA(image.Rect(0, 0, 2000, 1000))
for y := 0; y < 1000; y++ {
for x := 0; x < 2000; x++ {
img.Set(x, y, color.RGBA{128, 64, 200, 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
t.Fatalf("encode: %v", err)
}
cleaned, _, err := ScrubImage(buf.Bytes(), "image/jpeg")
if err != nil {
t.Fatalf("ScrubImage: %v", err)
}
decoded, err := jpeg.Decode(bytes.NewReader(cleaned))
if err != nil {
t.Fatalf("decode scrubbed: %v", err)
}
b := decoded.Bounds()
if b.Dx() > ImageMaxDim || b.Dy() > ImageMaxDim {
t.Errorf("not downscaled: got %dx%d, want max %d", b.Dx(), b.Dy(), ImageMaxDim)
}
// Aspect ratio roughly preserved (2:1 → 1080:540 with rounding slack).
if b.Dx() != ImageMaxDim {
t.Errorf("larger dim: got %d, want %d", b.Dx(), ImageMaxDim)
}
}
// TestDetectMIME: a few magic-byte cases to ensure magic detection works.
func TestDetectMIME(t *testing.T) {
cases := []struct {
data []byte
want string
}{
{[]byte("\xff\xd8\xff\xe0garbage"), "image/jpeg"},
{[]byte("\x89PNG\r\n\x1a\n..."), "image/png"},
{[]byte("GIF89a..."), "image/gif"},
{[]byte{}, ""},
}
for _, tc := range cases {
got := detectMIME(tc.data)
if got != tc.want {
t.Errorf("detectMIME(%q): got %q want %q", string(tc.data[:min(len(tc.data), 12)]), got, tc.want)
}
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -1,102 +0,0 @@
// Package node — channel endpoints.
//
// `/api/channels/:id/members` returns every Ed25519 pubkey registered as a
// channel member together with their current X25519 pubkey (from the
// identity registry). Clients sealing a message to a channel iterate this
// list and call relay.Seal once per recipient — that's the "fan-out"
// group-messaging model (R1 in the roadmap).
//
// Why enrich with X25519 here rather than making the client do it?
// - One HTTP round trip vs N. At 10+ members the latency difference is
// significant over mobile networks.
// - The server already holds the identity state; no extra DB hops.
// - Clients get a stable, already-joined view — if a member hasn't
// published an X25519 key yet, we return them with `x25519_pub_key=""`
// so the caller knows to skip or retry later.
package node
import (
"fmt"
"net/http"
"strings"
"go-blockchain/blockchain"
"go-blockchain/wallet"
)
func registerChannelAPI(mux *http.ServeMux, q ExplorerQuery) {
// GET /api/channels/{id} → channel metadata
// GET /api/channels/{id}/members → enriched member list
//
// One HandleFunc deals with both by sniffing the path suffix.
mux.HandleFunc("/api/channels/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/channels/")
path = strings.Trim(path, "/")
if path == "" {
jsonErr(w, fmt.Errorf("channel id required"), 400)
return
}
switch {
case strings.HasSuffix(path, "/members"):
id := strings.TrimSuffix(path, "/members")
handleChannelMembers(w, q, id)
default:
handleChannelInfo(w, q, path)
}
})
}
func handleChannelInfo(w http.ResponseWriter, q ExplorerQuery, channelID string) {
if q.GetChannel == nil {
jsonErr(w, fmt.Errorf("channel queries not configured"), 503)
return
}
ch, err := q.GetChannel(channelID)
if err != nil {
jsonErr(w, err, 500)
return
}
if ch == nil {
jsonErr(w, fmt.Errorf("channel %s not found", channelID), 404)
return
}
jsonOK(w, ch)
}
func handleChannelMembers(w http.ResponseWriter, q ExplorerQuery, channelID string) {
if q.GetChannelMembers == nil {
jsonErr(w, fmt.Errorf("channel queries not configured"), 503)
return
}
pubs, err := q.GetChannelMembers(channelID)
if err != nil {
jsonErr(w, err, 500)
return
}
out := make([]blockchain.ChannelMember, 0, len(pubs))
for _, pub := range pubs {
member := blockchain.ChannelMember{
PubKey: pub,
Address: wallet.PubKeyToAddress(pub),
}
// Best-effort X25519 lookup — skip silently on miss so a member
// who hasn't published their identity yet doesn't prevent the
// whole list from returning. The sender will just skip them on
// fan-out and retry later (after that member does register).
if q.IdentityInfo != nil {
if info, err := q.IdentityInfo(pub); err == nil && info != nil {
member.X25519PubKey = info.X25519Pub
}
}
out = append(out, member)
}
jsonOK(w, map[string]any{
"channel_id": channelID,
"count": len(out),
"members": out,
})
}

View File

@@ -38,6 +38,20 @@ func queryInt(r *http.Request, key string, def int) int {
return n return n
} }
// queryInt64 reads a non-negative int64 query param — typically a unix
// timestamp cursor for pagination. Returns def when missing or invalid.
func queryInt64(r *http.Request, key string, def int64) int64 {
s := r.URL.Query().Get(key)
if s == "" {
return def
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || n < 0 {
return def
}
return n
}
// queryIntMin0 parses a query param as a non-negative integer; returns 0 if absent or invalid. // queryIntMin0 parses a query param as a non-negative integer; returns 0 if absent or invalid.
func queryIntMin0(r *http.Request, key string) int { func queryIntMin0(r *http.Request, key string) int {
s := r.URL.Query().Get(key) s := r.URL.Query().Get(key)

775
node/api_feed.go Normal file
View File

@@ -0,0 +1,775 @@
package node
// Feed HTTP endpoints (v2.0.0).
//
// Mount points:
//
// POST /feed/publish — store a post body (authenticated)
// GET /feed/post/{id} — fetch a post body
// GET /feed/post/{id}/stats — {views, likes, liked_by_me?} aggregate
// POST /feed/post/{id}/view — increment off-chain view counter
// GET /feed/author/{pub} — ?limit=N, posts by an author
// GET /feed/timeline — ?follower=<pub>&limit=N, merged feed of follows
// GET /feed/trending — ?window=h&limit=N, top by likes + views
// GET /feed/foryou — ?pub=<pub>&limit=N, recommendations
// GET /feed/hashtag/{tag} — posts matching a hashtag
//
// Publish flow:
// 1. Client POSTs {content, attachment, post_id, author, sig, ts}.
// 2. Node verifies sig (Ed25519 over canonical bytes), hashes body,
// stores in FeedMailbox, returns hosting_relay + content_hash + size.
// 3. Client then submits on-chain CREATE_POST tx with that metadata.
// Node charges the fee (base + size×byte_fee) and credits the relay.
// 4. Subsequent GET /feed/post/{id} serves the stored body to anyone.
//
// Why the split? On-chain metadata gives us provable authorship + the
// pay-for-storage incentive; off-chain body storage keeps the block
// history small. If the hosting relay dies, the on-chain record stays
// (with a "body unavailable" fallback on the reader side) — authors can
// re-publish to another relay.
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"sort"
"strings"
"time"
"go-blockchain/blockchain"
"go-blockchain/identity"
"go-blockchain/media"
"go-blockchain/relay"
)
// FeedConfig wires feed HTTP endpoints to the relay mailbox and the
// chain for read-after-write queries.
type FeedConfig struct {
Mailbox *relay.FeedMailbox
// HostingRelayPub is this node's Ed25519 pubkey — returned from
// /feed/publish so the client knows who to put in CREATE_POST tx.
HostingRelayPub string
// Scrubber strips metadata from image/video/audio attachments before
// they are stored. MUST be non-nil; a zero Scrubber (NewScrubber with
// empty sidecar URL) still handles images in-process — only video/audio
// require sidecar config.
Scrubber *media.Scrubber
// AllowUnscrubbedVideo controls server behaviour when a video upload
// arrives and no sidecar is configured. false (default) → reject; true
// → store as-is with a warning log. Set via --allow-unscrubbed-video
// flag on the node. Leave false in production.
AllowUnscrubbedVideo bool
// Chain lookups (nil-safe; endpoints degrade gracefully).
GetPost func(postID string) (*blockchain.PostRecord, error)
LikeCount func(postID string) (uint64, error)
HasLiked func(postID, likerPub string) (bool, error)
PostsByAuthor func(authorPub string, beforeTs int64, limit int) ([]*blockchain.PostRecord, error)
Following func(followerPub string) ([]string, error)
}
// RegisterFeedRoutes wires feed endpoints onto mux. Writes are rate-limited
// via withSubmitTxGuards; reads via withReadLimit (same limiters as /relay).
func RegisterFeedRoutes(mux *http.ServeMux, cfg FeedConfig) {
if cfg.Mailbox == nil {
return
}
mux.HandleFunc("/feed/publish", withSubmitTxGuards(feedPublish(cfg)))
mux.HandleFunc("/feed/post/", withReadLimit(feedPostRouter(cfg)))
mux.HandleFunc("/feed/author/", withReadLimit(feedAuthor(cfg)))
mux.HandleFunc("/feed/timeline", withReadLimit(feedTimeline(cfg)))
mux.HandleFunc("/feed/trending", withReadLimit(feedTrending(cfg)))
mux.HandleFunc("/feed/foryou", withReadLimit(feedForYou(cfg)))
mux.HandleFunc("/feed/hashtag/", withReadLimit(feedHashtag(cfg)))
}
// ── POST /feed/publish ────────────────────────────────────────────────────
// feedPublishRequest — what the client sends. Signature is Ed25519 over
// canonical bytes: "publish:<post_id>:<content_sha256_hex>:<ts>".
// ts must be within ±5 minutes of server clock.
type feedPublishRequest struct {
PostID string `json:"post_id"`
Author string `json:"author"` // hex Ed25519
Content string `json:"content"`
ContentType string `json:"content_type,omitempty"`
AttachmentB64 string `json:"attachment_b64,omitempty"`
AttachmentMIME string `json:"attachment_mime,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
QuoteOf string `json:"quote_of,omitempty"`
Sig string `json:"sig"` // base64 Ed25519 sig
Ts int64 `json:"ts"`
}
type feedPublishResponse struct {
PostID string `json:"post_id"`
HostingRelay string `json:"hosting_relay"`
ContentHash string `json:"content_hash"` // hex sha256
Size uint64 `json:"size"`
Hashtags []string `json:"hashtags"`
EstimatedFeeUT uint64 `json:"estimated_fee_ut"` // base + size*byte_fee
}
func feedPublish(cfg FeedConfig) http.HandlerFunc {
const publishSkewSecs = 300
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
var req feedPublishRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
return
}
if req.PostID == "" || req.Author == "" || req.Sig == "" || req.Ts == 0 {
jsonErr(w, fmt.Errorf("post_id, author, sig, ts are required"), 400)
return
}
if req.Content == "" && req.AttachmentB64 == "" {
jsonErr(w, fmt.Errorf("post must have content or attachment"), 400)
return
}
now := time.Now().Unix()
if req.Ts < now-publishSkewSecs || req.Ts > now+publishSkewSecs {
jsonErr(w, fmt.Errorf("ts out of range (±%ds)", publishSkewSecs), 400)
return
}
if req.ReplyTo != "" && req.QuoteOf != "" {
jsonErr(w, fmt.Errorf("reply_to and quote_of are mutually exclusive"), 400)
return
}
// Decode attachment (raw upload — before scrub).
var rawAttachment []byte
var attachmentMIME string
if req.AttachmentB64 != "" {
b, err := base64.StdEncoding.DecodeString(req.AttachmentB64)
if err != nil {
if b, err = base64.RawURLEncoding.DecodeString(req.AttachmentB64); err != nil {
jsonErr(w, fmt.Errorf("attachment_b64: invalid base64"), 400)
return
}
}
rawAttachment = b
attachmentMIME = req.AttachmentMIME
}
// ── Step 1: verify signature over the RAW-upload hash ──────────
// The client signs what it sent. The server recomputes hash over
// the as-received bytes and verifies — this proves the upload
// came from the claimed author and wasn't tampered with in transit.
rawHasher := sha256.New()
rawHasher.Write([]byte(req.Content))
rawHasher.Write(rawAttachment)
rawContentHash := rawHasher.Sum(nil)
rawContentHashHex := hex.EncodeToString(rawContentHash)
msg := []byte(fmt.Sprintf("publish:%s:%s:%d", req.PostID, rawContentHashHex, req.Ts))
sigBytes, err := base64.StdEncoding.DecodeString(req.Sig)
if err != nil {
if sigBytes, err = base64.RawURLEncoding.DecodeString(req.Sig); err != nil {
jsonErr(w, fmt.Errorf("sig: invalid base64"), 400)
return
}
}
if _, err := hex.DecodeString(req.Author); err != nil {
jsonErr(w, fmt.Errorf("author: invalid hex"), 400)
return
}
ok, err := identity.Verify(req.Author, msg, sigBytes)
if err != nil || !ok {
jsonErr(w, fmt.Errorf("signature invalid"), 403)
return
}
// ── Step 2: MANDATORY server-side metadata scrub ─────────────
// Runs AFTER signature verification so a fake client can't burn
// CPU by triggering expensive scrub work on unauthenticated inputs.
//
// Images: in-process stdlib re-encode → kills EXIF/GPS/ICC/XMP by
// construction. Videos/audio: forwarded to FFmpeg sidecar; without
// one, we reject unless operator opted in to unscrubbed video.
attachment := rawAttachment
if len(attachment) > 0 {
if cfg.Scrubber == nil {
jsonErr(w, fmt.Errorf("media scrubber not configured on this node"), 503)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
cleaned, newMIME, err := cfg.Scrubber.Scrub(ctx, attachment, attachmentMIME)
cancel()
if err != nil {
if err == media.ErrSidecarUnavailable && cfg.AllowUnscrubbedVideo {
log.Printf("[feed] WARNING: storing unscrubbed video — no sidecar configured (author=%s)", req.Author)
} else {
status := 400
if err == media.ErrSidecarUnavailable {
status = 503
}
jsonErr(w, fmt.Errorf("scrub attachment: %w", err), status)
return
}
} else {
attachment = cleaned
attachmentMIME = newMIME
}
}
// ── Step 3: recompute content hash over the SCRUBBED bytes ────
// This is what goes into the response + on-chain CREATE_POST, so
// anyone fetching the body can verify integrity against the chain.
// The signature check already used the raw-upload hash above;
// this final hash binds the on-chain record to what readers will
// actually download.
finalHasher := sha256.New()
finalHasher.Write([]byte(req.Content))
finalHasher.Write(attachment)
contentHash := finalHasher.Sum(nil)
contentHashHex := hex.EncodeToString(contentHash)
post := &relay.FeedPost{
PostID: req.PostID,
Author: req.Author,
Content: req.Content,
ContentType: req.ContentType,
Attachment: attachment,
AttachmentMIME: attachmentMIME,
ReplyTo: req.ReplyTo,
QuoteOf: req.QuoteOf,
}
hashtags, err := cfg.Mailbox.Store(post, req.Ts)
if err != nil {
if errors.Is(err, relay.ErrPostTooLarge) {
jsonErr(w, err, 413)
return
}
if errors.Is(err, relay.ErrFeedQuotaExceeded) {
// 507 Insufficient Storage — the client should try
// another relay (or wait for TTL-driven eviction here).
jsonErr(w, err, 507)
return
}
jsonErr(w, err, 500)
return
}
// Report what the client should put into CREATE_POST.
size := uint64(len(req.Content)) + uint64(len(attachment)) + 128
fee := blockchain.BasePostFee + size*blockchain.PostByteFee
jsonOK(w, feedPublishResponse{
PostID: req.PostID,
HostingRelay: cfg.HostingRelayPub,
ContentHash: contentHashHex,
Size: size,
Hashtags: hashtags,
EstimatedFeeUT: fee,
})
}
}
// ── GET /feed/post/{id} [+ /stats subroute, POST /view] ─────────────────
// feedPostRouter dispatches /feed/post/{id}, /feed/post/{id}/stats,
// /feed/post/{id}/view to the right handler.
func feedPostRouter(cfg FeedConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/feed/post/")
rest = strings.Trim(rest, "/")
if rest == "" {
jsonErr(w, fmt.Errorf("post id required"), 400)
return
}
parts := strings.Split(rest, "/")
postID := parts[0]
if len(parts) == 1 {
feedGetPost(cfg)(w, r, postID)
return
}
switch parts[1] {
case "stats":
feedPostStats(cfg)(w, r, postID)
case "view":
feedPostView(cfg)(w, r, postID)
case "attachment":
feedPostAttachment(cfg)(w, r, postID)
default:
jsonErr(w, fmt.Errorf("unknown sub-route %q", parts[1]), 404)
}
}
}
// feedPostAttachment handles GET /feed/post/{id}/attachment — returns the
// raw attachment bytes with the correct Content-Type so clients can use
// the URL directly as an <Image source={uri: ...}>.
//
// Why a dedicated endpoint? The /feed/post/{id} response wraps the body
// as base64 inside JSON; fetching that + decoding for N posts in a feed
// list would blow up memory. Native image loaders stream bytes straight
// to the GPU — this route lets them do that without intermediate JSON.
//
// Respects on-chain soft-delete: returns 410 when the post is tombstoned.
func feedPostAttachment(cfg FeedConfig) postHandler {
return func(w http.ResponseWriter, r *http.Request, postID string) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
if cfg.GetPost != nil {
if rec, _ := cfg.GetPost(postID); rec != nil && rec.Deleted {
jsonErr(w, fmt.Errorf("post %s deleted", postID), 410)
return
}
}
post, err := cfg.Mailbox.Get(postID)
if err != nil {
jsonErr(w, err, 500)
return
}
if post == nil || len(post.Attachment) == 0 {
jsonErr(w, fmt.Errorf("no attachment for post %s", postID), 404)
return
}
mime := post.AttachmentMIME
if mime == "" {
mime = "application/octet-stream"
}
w.Header().Set("Content-Type", mime)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(post.Attachment)))
// Cache for 1 hour — attachments are immutable (tied to content_hash),
// so aggressive client-side caching is safe and saves bandwidth.
w.Header().Set("Cache-Control", "public, max-age=3600, immutable")
w.Header().Set("ETag", `"`+postID+`"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(post.Attachment)
}
}
type postHandler func(w http.ResponseWriter, r *http.Request, postID string)
func feedGetPost(cfg FeedConfig) postHandler {
return func(w http.ResponseWriter, r *http.Request, postID string) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
post, err := cfg.Mailbox.Get(postID)
if err != nil {
jsonErr(w, err, 500)
return
}
if post == nil {
jsonErr(w, fmt.Errorf("post %s not found", postID), 404)
return
}
// Respect on-chain soft-delete.
if cfg.GetPost != nil {
if rec, _ := cfg.GetPost(postID); rec != nil && rec.Deleted {
jsonErr(w, fmt.Errorf("post %s deleted", postID), 410)
return
}
}
jsonOK(w, post)
}
}
type postStatsResponse struct {
PostID string `json:"post_id"`
Views uint64 `json:"views"`
Likes uint64 `json:"likes"`
LikedByMe *bool `json:"liked_by_me,omitempty"` // set only when ?me=<pub> given
}
func feedPostStats(cfg FeedConfig) postHandler {
return func(w http.ResponseWriter, r *http.Request, postID string) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
views, _ := cfg.Mailbox.ViewCount(postID)
var likes uint64
if cfg.LikeCount != nil {
likes, _ = cfg.LikeCount(postID)
}
resp := postStatsResponse{
PostID: postID,
Views: views,
Likes: likes,
}
if me := r.URL.Query().Get("me"); me != "" && cfg.HasLiked != nil {
if liked, err := cfg.HasLiked(postID, me); err == nil {
resp.LikedByMe = &liked
}
}
jsonOK(w, resp)
}
}
func feedPostView(cfg FeedConfig) postHandler {
return func(w http.ResponseWriter, r *http.Request, postID string) {
if r.Method != http.MethodPost {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
next, err := cfg.Mailbox.IncrementView(postID)
if err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]any{
"post_id": postID,
"views": next,
})
}
}
// ── GET /feed/author/{pub} ────────────────────────────────────────────────
func feedAuthor(cfg FeedConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
pub := strings.TrimPrefix(r.URL.Path, "/feed/author/")
pub = strings.Trim(pub, "/")
if pub == "" {
jsonErr(w, fmt.Errorf("author pub required"), 400)
return
}
limit := queryInt(r, "limit", 30)
beforeTs := queryInt64(r, "before", 0) // pagination cursor (unix seconds)
// Prefer chain-authoritative list (includes soft-deleted flag) so
// clients can't be fooled by a stale relay that has an already-
// deleted post. If chain isn't wired, fall back to relay index.
if cfg.PostsByAuthor != nil {
records, err := cfg.PostsByAuthor(pub, beforeTs, limit)
if err != nil {
jsonErr(w, err, 500)
return
}
out := make([]feedAuthorItem, 0, len(records))
for _, rec := range records {
if rec == nil || rec.Deleted {
continue
}
out = append(out, buildAuthorItem(cfg, rec))
}
jsonOK(w, map[string]any{"author": pub, "count": len(out), "posts": out})
return
}
// Fallback: relay index (no chain). Doesn't support `before` yet;
// the chain-authoritative path above is what production serves.
ids, err := cfg.Mailbox.PostsByAuthor(pub, limit)
if err != nil {
jsonErr(w, err, 500)
return
}
out := expandByID(cfg, ids)
jsonOK(w, map[string]any{"author": pub, "count": len(out), "posts": out})
}
}
// feedAuthorItem is a chain record enriched with the body and live stats.
type feedAuthorItem struct {
PostID string `json:"post_id"`
Author string `json:"author"`
Content string `json:"content,omitempty"`
ContentType string `json:"content_type,omitempty"`
Hashtags []string `json:"hashtags,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
QuoteOf string `json:"quote_of,omitempty"`
CreatedAt int64 `json:"created_at"`
Size uint64 `json:"size"`
HostingRelay string `json:"hosting_relay"`
Views uint64 `json:"views"`
Likes uint64 `json:"likes"`
HasAttachment bool `json:"has_attachment"`
}
func buildAuthorItem(cfg FeedConfig, rec *blockchain.PostRecord) feedAuthorItem {
item := feedAuthorItem{
PostID: rec.PostID,
Author: rec.Author,
ReplyTo: rec.ReplyTo,
QuoteOf: rec.QuoteOf,
CreatedAt: rec.CreatedAt,
Size: rec.Size,
HostingRelay: rec.HostingRelay,
}
if body, _ := cfg.Mailbox.Get(rec.PostID); body != nil {
item.Content = body.Content
item.ContentType = body.ContentType
item.Hashtags = body.Hashtags
item.HasAttachment = len(body.Attachment) > 0
}
if cfg.LikeCount != nil {
item.Likes, _ = cfg.LikeCount(rec.PostID)
}
item.Views, _ = cfg.Mailbox.ViewCount(rec.PostID)
return item
}
// expandByID fetches bodies+stats for a list of post IDs (no chain record).
func expandByID(cfg FeedConfig, ids []string) []feedAuthorItem {
out := make([]feedAuthorItem, 0, len(ids))
for _, id := range ids {
body, _ := cfg.Mailbox.Get(id)
if body == nil {
continue
}
item := feedAuthorItem{
PostID: id,
Author: body.Author,
Content: body.Content,
ContentType: body.ContentType,
Hashtags: body.Hashtags,
ReplyTo: body.ReplyTo,
QuoteOf: body.QuoteOf,
CreatedAt: body.CreatedAt,
HasAttachment: len(body.Attachment) > 0,
}
if cfg.LikeCount != nil {
item.Likes, _ = cfg.LikeCount(id)
}
item.Views, _ = cfg.Mailbox.ViewCount(id)
out = append(out, item)
}
return out
}
// ── GET /feed/timeline ────────────────────────────────────────────────────
func feedTimeline(cfg FeedConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
follower := r.URL.Query().Get("follower")
if follower == "" {
jsonErr(w, fmt.Errorf("follower parameter required"), 400)
return
}
if cfg.Following == nil || cfg.PostsByAuthor == nil {
jsonErr(w, fmt.Errorf("timeline requires chain queries"), 503)
return
}
limit := queryInt(r, "limit", 30)
beforeTs := queryInt64(r, "before", 0) // pagination cursor
perAuthor := limit
if perAuthor > 30 {
perAuthor = 30
}
following, err := cfg.Following(follower)
if err != nil {
jsonErr(w, err, 500)
return
}
var merged []*blockchain.PostRecord
for _, target := range following {
posts, err := cfg.PostsByAuthor(target, beforeTs, perAuthor)
if err != nil {
continue
}
for _, p := range posts {
if p != nil && !p.Deleted {
merged = append(merged, p)
}
}
}
// Sort newest-first, take top N.
sort.Slice(merged, func(i, j int) bool { return merged[i].CreatedAt > merged[j].CreatedAt })
if len(merged) > limit {
merged = merged[:limit]
}
out := make([]feedAuthorItem, 0, len(merged))
for _, rec := range merged {
out = append(out, buildAuthorItem(cfg, rec))
}
jsonOK(w, map[string]any{"count": len(out), "posts": out})
}
}
// ── GET /feed/trending ────────────────────────────────────────────────────
func feedTrending(cfg FeedConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
limit := queryInt(r, "limit", 30)
// Window defaults to 24h; cap 7d so a viral post from a week ago
// doesn't permanently dominate.
windowHours := queryInt(r, "window", 24)
if windowHours > 24*7 {
windowHours = 24 * 7
}
if windowHours < 1 {
windowHours = 1
}
ids, err := cfg.Mailbox.RecentPostIDs(int64(windowHours)*3600, 500)
if err != nil {
jsonErr(w, err, 500)
return
}
// Score each = likes*3 + views, honoring soft-delete.
type scored struct {
id string
score uint64
}
scoredList := make([]scored, 0, len(ids))
for _, id := range ids {
if cfg.GetPost != nil {
if rec, _ := cfg.GetPost(id); rec != nil && rec.Deleted {
continue
}
}
views, _ := cfg.Mailbox.ViewCount(id)
var likes uint64
if cfg.LikeCount != nil {
likes, _ = cfg.LikeCount(id)
}
scoredList = append(scoredList, scored{id: id, score: likes*3 + views})
}
sort.Slice(scoredList, func(i, j int) bool { return scoredList[i].score > scoredList[j].score })
if len(scoredList) > limit {
scoredList = scoredList[:limit]
}
pickedIDs := make([]string, len(scoredList))
for i, s := range scoredList {
pickedIDs[i] = s.id
}
out := expandByID(cfg, pickedIDs)
jsonOK(w, map[string]any{"count": len(out), "posts": out})
}
}
// ── GET /feed/foryou ──────────────────────────────────────────────────────
//
// Simple recommendations heuristic for v2.0.0:
// 1. Compute the set of authors the user already follows.
// 2. Fetch recent posts from the relay (last 48h).
// 3. Filter OUT posts from followed authors (those live in /timeline).
// 4. Filter OUT posts the user has already liked.
// 5. Rank remaining by (likes × 3 + views) and return top N.
//
// Future improvements (tracked as v2.2.0 "Feed algorithm"):
// - Weight by "followed-of-followed" signal (friends-of-friends boost).
// - Decay by age (exp half-life ~12h).
// - Penalise self-engagement (author liking own post).
// - Collaborative filtering on hashtag co-occurrence.
func feedForYou(cfg FeedConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
pub := r.URL.Query().Get("pub")
limit := queryInt(r, "limit", 30)
// Gather user's follows + likes to exclude from the candidate pool.
excludedAuthors := make(map[string]struct{})
if cfg.Following != nil && pub != "" {
if list, err := cfg.Following(pub); err == nil {
for _, a := range list {
excludedAuthors[a] = struct{}{}
}
}
}
// Post pool: last 48h on this relay.
ids, err := cfg.Mailbox.RecentPostIDs(48*3600, 500)
if err != nil {
jsonErr(w, err, 500)
return
}
type scored struct {
id string
score uint64
}
scoredList := make([]scored, 0, len(ids))
for _, id := range ids {
body, _ := cfg.Mailbox.Get(id)
if body == nil {
continue
}
if _, followed := excludedAuthors[body.Author]; followed {
continue
}
if body.Author == pub {
continue // don't recommend user's own posts
}
if cfg.GetPost != nil {
if rec, _ := cfg.GetPost(id); rec != nil && rec.Deleted {
continue
}
}
// Skip already-liked.
if cfg.HasLiked != nil && pub != "" {
if liked, _ := cfg.HasLiked(id, pub); liked {
continue
}
}
views, _ := cfg.Mailbox.ViewCount(id)
var likes uint64
if cfg.LikeCount != nil {
likes, _ = cfg.LikeCount(id)
}
// Small "seed" score so posts with no engagement still get shown
// sometimes (otherwise a silent but fresh post can't break in).
scoredList = append(scoredList, scored{id: id, score: likes*3 + views + 1})
}
sort.Slice(scoredList, func(i, j int) bool { return scoredList[i].score > scoredList[j].score })
if len(scoredList) > limit {
scoredList = scoredList[:limit]
}
pickedIDs := make([]string, len(scoredList))
for i, s := range scoredList {
pickedIDs[i] = s.id
}
out := expandByID(cfg, pickedIDs)
jsonOK(w, map[string]any{"count": len(out), "posts": out})
}
}
// ── GET /feed/hashtag/{tag} ──────────────────────────────────────────────
func feedHashtag(cfg FeedConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
tag := strings.TrimPrefix(r.URL.Path, "/feed/hashtag/")
tag = strings.Trim(tag, "/")
if tag == "" {
jsonErr(w, fmt.Errorf("tag required"), 400)
return
}
limit := queryInt(r, "limit", 50)
ids, err := cfg.Mailbox.PostsByHashtag(tag, limit)
if err != nil {
jsonErr(w, err, 500)
return
}
out := expandByID(cfg, ids)
jsonOK(w, map[string]any{"tag": strings.ToLower(tag), "count": len(out), "posts": out})
}
}
// (queryInt helper is shared with the rest of the node HTTP surface;
// see api_common.go.)

View File

@@ -2,6 +2,7 @@ package node
import ( import (
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -9,6 +10,7 @@ import (
"time" "time"
"go-blockchain/blockchain" "go-blockchain/blockchain"
"go-blockchain/identity"
"go-blockchain/relay" "go-blockchain/relay"
) )
@@ -26,6 +28,12 @@ type RelayConfig struct {
// ContactRequests returns incoming contact records for the given Ed25519 pubkey. // ContactRequests returns incoming contact records for the given Ed25519 pubkey.
ContactRequests func(pubKey string) ([]blockchain.ContactInfo, error) ContactRequests func(pubKey string) ([]blockchain.ContactInfo, error)
// ResolveX25519 returns the X25519 hex published by the Ed25519 identity,
// or "" if the identity has not registered or does not exist. Used by
// authenticated mutating endpoints (e.g. DELETE /relay/inbox) to link a
// signing key back to its mailbox pubkey. nil disables those endpoints.
ResolveX25519 func(ed25519PubHex string) string
} }
// registerRelayRoutes wires relay mailbox endpoints onto mux. // registerRelayRoutes wires relay mailbox endpoints onto mux.
@@ -37,12 +45,19 @@ type RelayConfig struct {
// DELETE /relay/inbox/{envID} ?pub=<x25519hex> // DELETE /relay/inbox/{envID} ?pub=<x25519hex>
// GET /relay/contacts ?pub=<ed25519hex> // GET /relay/contacts ?pub=<ed25519hex>
func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) { func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
mux.HandleFunc("/relay/send", relaySend(rc)) // Writes go through withSubmitTxGuards: per-IP rate limit (10/s, burst 20)
mux.HandleFunc("/relay/broadcast", relayBroadcast(rc)) // + 256 KiB body cap. Without these, a single attacker could spam
mux.HandleFunc("/relay/inbox/count", relayInboxCount(rc)) // 500 envelopes per victim in a few seconds and evict every real message
mux.HandleFunc("/relay/inbox/", relayInboxDelete(rc)) // via the mailbox FIFO cap.
mux.HandleFunc("/relay/inbox", relayInboxList(rc)) mux.HandleFunc("/relay/send", withSubmitTxGuards(relaySend(rc)))
mux.HandleFunc("/relay/contacts", relayContacts(rc)) mux.HandleFunc("/relay/broadcast", withSubmitTxGuards(relayBroadcast(rc)))
// Reads go through withReadLimit: per-IP rate limit (20/s, burst 40).
// Protects against inbox-scraping floods from a single origin.
mux.HandleFunc("/relay/inbox/count", withReadLimit(relayInboxCount(rc)))
mux.HandleFunc("/relay/inbox/", withReadLimit(relayInboxDelete(rc)))
mux.HandleFunc("/relay/inbox", withReadLimit(relayInboxList(rc)))
mux.HandleFunc("/relay/contacts", withReadLimit(relayContacts(rc)))
} }
// relayInboxList handles GET /relay/inbox?pub=<hex>[&since=<ts>][&limit=N] // relayInboxList handles GET /relay/inbox?pub=<hex>[&since=<ts>][&limit=N]
@@ -109,8 +124,24 @@ func relayInboxList(rc RelayConfig) http.HandlerFunc {
} }
} }
// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<hex> // relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<x25519hex>
//
// Auth model:
// Query: ?pub=<x25519hex>
// Body: {"ed25519_pub":"<hex>", "sig":"<base64>", "ts":<unix_seconds>}
// sig = Ed25519(privEd25519,
// "inbox-delete:" + envID + ":" + x25519Pub + ":" + ts)
// ts must be within ±5 minutes of server clock (anti-replay).
//
// Server then:
// 1. Verifies sig over the canonical bytes above.
// 2. Looks up identity(ed25519_pub).X25519Pub — must equal the ?pub= query.
//
// This links the signing key to the mailbox key without exposing the user's
// X25519 private material.
func relayInboxDelete(rc RelayConfig) http.HandlerFunc { func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
const inboxDeleteSkewSecs = 300 // ±5 minutes
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete { if r.Method != http.MethodDelete {
// Also serve GET /relay/inbox/{id} for convenience (fetch single envelope) // Also serve GET /relay/inbox/{id} for convenience (fetch single envelope)
@@ -133,6 +164,61 @@ func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
return return
} }
// Auth. Unauthenticated DELETE historically let anyone wipe any
// mailbox by just knowing the pub — fixed in v1.0.2 via signed
// Ed25519 identity linked to the x25519 via identity registry.
if rc.ResolveX25519 == nil {
jsonErr(w, fmt.Errorf("mailbox delete not available on this node"), 503)
return
}
var body struct {
Ed25519Pub string `json:"ed25519_pub"`
Sig string `json:"sig"`
Ts int64 `json:"ts"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonErr(w, fmt.Errorf("invalid JSON body: %w", err), 400)
return
}
if body.Ed25519Pub == "" || body.Sig == "" || body.Ts == 0 {
jsonErr(w, fmt.Errorf("ed25519_pub, sig, ts are required"), 400)
return
}
now := time.Now().Unix()
if body.Ts < now-inboxDeleteSkewSecs || body.Ts > now+inboxDeleteSkewSecs {
jsonErr(w, fmt.Errorf("timestamp out of range (±%ds)", inboxDeleteSkewSecs), 400)
return
}
sigBytes, err := base64.StdEncoding.DecodeString(body.Sig)
if err != nil {
// Also try URL-safe for defensive UX.
sigBytes, err = base64.RawURLEncoding.DecodeString(body.Sig)
if err != nil {
jsonErr(w, fmt.Errorf("sig: invalid base64"), 400)
return
}
}
if _, err := hex.DecodeString(body.Ed25519Pub); err != nil {
jsonErr(w, fmt.Errorf("ed25519_pub: invalid hex"), 400)
return
}
msg := []byte(fmt.Sprintf("inbox-delete:%s:%s:%d", envID, pub, body.Ts))
ok, err := identity.Verify(body.Ed25519Pub, msg, sigBytes)
if err != nil || !ok {
jsonErr(w, fmt.Errorf("signature invalid"), 403)
return
}
// Link ed25519 → x25519 via identity registry.
registeredX := rc.ResolveX25519(body.Ed25519Pub)
if registeredX == "" {
jsonErr(w, fmt.Errorf("identity has no registered X25519 key"), 403)
return
}
if !strings.EqualFold(registeredX, pub) {
jsonErr(w, fmt.Errorf("pub does not match identity's registered X25519"), 403)
return
}
if err := rc.Mailbox.Delete(pub, envID); err != nil { if err := rc.Mailbox.Delete(pub, envID); err != nil {
jsonErr(w, err, 500) jsonErr(w, err, 500)
return return

View File

@@ -79,12 +79,6 @@ type ExplorerQuery struct {
GetNFTs func() ([]blockchain.NFTRecord, error) GetNFTs func() ([]blockchain.NFTRecord, error)
NFTsByOwner func(ownerPub string) ([]blockchain.NFTRecord, error) NFTsByOwner func(ownerPub string) ([]blockchain.NFTRecord, error)
// Channel group-messaging lookups (R1). GetChannel returns metadata;
// GetChannelMembers returns the Ed25519 pubkey of every current member.
// Both may be nil on nodes that don't expose channel state (tests).
GetChannel func(channelID string) (*blockchain.CreateChannelPayload, error)
GetChannelMembers func(channelID string) ([]string, error)
// Events is the SSE hub for the live event stream. Optional — if nil the // Events is the SSE hub for the live event stream. Optional — if nil the
// /api/events endpoint returns 501 Not Implemented. // /api/events endpoint returns 501 Not Implemented.
Events *SSEHub Events *SSEHub
@@ -127,7 +121,6 @@ func RegisterExplorerRoutes(mux *http.ServeMux, q ExplorerQuery, flags ...Explor
registerUpdateCheckAPI(mux, q) registerUpdateCheckAPI(mux, q)
registerOnboardingAPI(mux, q) registerOnboardingAPI(mux, q)
registerTokenAPI(mux, q) registerTokenAPI(mux, q)
registerChannelAPI(mux, q)
if !f.DisableSwagger { if !f.DisableSwagger {
registerSwaggerRoutes(mux) registerSwaggerRoutes(mux)
} }

View File

@@ -12,7 +12,7 @@
// returned forever (even if the implementation moves around internally). The // returned forever (even if the implementation moves around internally). The
// client uses them as "is this feature here or not?", not "what version is // client uses them as "is this feature here or not?", not "what version is
// this feature at?". Versioning a feature is done by shipping a new tag // this feature at?". Versioning a feature is done by shipping a new tag
// (e.g. "channels_v2" alongside "channels_v1" for a deprecation window). // (e.g. "feed_v3" alongside "feed_v2" for a deprecation window).
// //
// Response shape: // Response shape:
// //
@@ -55,15 +55,17 @@ const ProtocolVersion = 1
// a breaking successor (e.g. `channels_v1`, not `channels`). // a breaking successor (e.g. `channels_v1`, not `channels`).
var nodeFeatures = []string{ var nodeFeatures = []string{
"access_token", // DCHAIN_API_TOKEN gating on writes (+ optional reads) "access_token", // DCHAIN_API_TOKEN gating on writes (+ optional reads)
"channels_v1", // /api/channels/:id + /members with X25519 enrichment
"chain_id", // /api/network-info returns chain_id "chain_id", // /api/network-info returns chain_id
"contract_logs", // /api/contract/:id/logs endpoint "contract_logs", // /api/contract/:id/logs endpoint
"fan_out", // client-side per-recipient envelope sealing "fan_out", // client-side per-recipient envelope sealing
"feed_v2", // social feed: posts, likes, follows, timeline, trending, for-you
"identity_registry", // /api/identity/:pub returns X25519 pub + relay hints "identity_registry", // /api/identity/:pub returns X25519 pub + relay hints
"media_scrub", // mandatory EXIF/GPS strip on /feed/publish
"native_username_registry", // native:username_registry contract "native_username_registry", // native:username_registry contract
"onboarding_api", // /api/network-info for joiner bootstrap "onboarding_api", // /api/network-info for joiner bootstrap
"payment_channels", // off-chain payment channel open/close "payment_channels", // off-chain payment channel open/close
"relay_mailbox", // /relay/send + /relay/inbox "relay_broadcast", // /relay/broadcast (E2E envelope publish)
"relay_mailbox", // /relay/inbox (read), /relay/send (legacy non-E2E)
"ws_submit_tx", // WebSocket submit_tx op "ws_submit_tx", // WebSocket submit_tx op
} }

Some files were not shown because too many files have changed in this diff Show More