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
This commit is contained in:
vsecoder
2026-04-18 22:06:18 +03:00
103 changed files with 29307 additions and 263 deletions

5
.gitignore vendored
View File

@@ -60,4 +60,7 @@ Thumbs.db
# Not part of the release bundle — tracked separately
CONTEXT.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,6 +1,6 @@
# DChain
Блокчейн-стек для децентрализованного мессенджера:
Блокчейн-стек для децентрализованного мессенджера + социальной ленты:
- **PBFT** консенсус с multi-sig validator governance и equivocation slashing
- **Native Go контракты** рядом с WASM (wazero) — нулевая задержка для
@@ -8,6 +8,12 @@
- **WebSocket push API** — клиент не опрашивает, все события прилетают
на соединение
- **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`,
peer-version gossip, `/api/update-check` против Gitea releases,
`update.sh` с semver guard
@@ -142,7 +148,8 @@ sudo systemctl enable --now dchain-update.timer
| `node/` | HTTP + WS API, SSE, metrics, access control |
| `node/version/` | Build-time version metadata (ldflags-инжектимый) |
| `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 |
| `economy/` | Fee model, rewards |
| `wallet/` | Optional payout wallet (отдельный ключ) |
@@ -179,6 +186,33 @@ sudo systemctl enable --now dchain-update.timer
Scoped WS-топики (`addr:`, `inbox:`, `typing:`) требуют auth через
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
- `GET /swagger`**Swagger UI** (рендерится через swagger-ui-dist).
- `GET /swagger/openapi.json` — сырая OpenAPI 3.0 спека.

View File

@@ -40,13 +40,20 @@ const (
prefixHeight = "height" // height → uint64
prefixBalance = "balance:" // balance:<pubkey> → uint64
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)
prefixReputation = "rep:" // rep:<pubkey> → RepStats JSON
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
@@ -537,11 +544,13 @@ func (c *Chain) Identity(pubKeyHex string) (*RegisterKeyPayload, error) {
return &p, err
}
// Channel returns the CreateChannelPayload for a channel ID, or nil.
func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) {
var p CreateChannelPayload
// ── Feed queries (v2.0.0) ──────────────────────────────────────────────────
// 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 {
item, err := txn.Get([]byte(prefixChannel + channelID))
item, err := txn.Get([]byte(prefixPost + postID))
if err != nil {
return err
}
@@ -552,13 +561,69 @@ func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) {
if errors.Is(err, badger.ErrKeyNotFound) {
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.
func (c *Chain) ChannelMembers(channelID string) ([]string, error) {
prefix := []byte(fmt.Sprintf("%s%s:", prefixChanMember, channelID))
var members []string
// PostsByAuthor returns the last `limit` posts by the given author, newest
// first. Iterates `postbyauthor:<author>:...` in reverse order. If limit
// ≤ 0, defaults to 50; capped at 200.
//
// 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 {
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
@@ -567,15 +632,95 @@ func (c *Chain) ChannelMembers(channelID string) ([]string, error) {
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
key := string(it.Item().Key())
// key = "chan-member:<channelID>:<memberPubKey>"
// key = "follow:<follower>:<target>"
parts := strings.SplitN(key, ":", 3)
if len(parts) == 3 {
members = append(members, parts[2])
out = append(out, parts[2])
}
}
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.
@@ -740,41 +885,197 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
return 0, err
}
case EventCreateChannel:
var p CreateChannelPayload
// ── Feed events (v2.0.0) ──────────────────────────────────────────
case EventCreatePost:
var p CreatePostPayload
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 {
return 0, fmt.Errorf("CREATE_CHANNEL debit: %w", err)
return 0, fmt.Errorf("CREATE_POST debit: %w", err)
}
val, _ := json.Marshal(p)
if err := txn.Set([]byte(prefixChannel+p.ChannelID), val); err != nil {
// Full fee goes to the hosting relay (storage compensation). No
// 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
}
case EventAddMember:
var p AddMemberPayload
case EventDeletePost:
var p DeletePostPayload
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 == "" {
return 0, fmt.Errorf("%w: ADD_MEMBER: channel_id required", ErrTxFailed)
if p.PostID == "" {
return 0, fmt.Errorf("%w: DELETE_POST: post_id required", ErrTxFailed)
}
if _, err := txn.Get([]byte(prefixChannel + p.ChannelID)); err != nil {
item, err := txn.Get([]byte(prefixPost + p.PostID))
if errors.Is(err, badger.ErrKeyNotFound) {
return 0, fmt.Errorf("%w: DELETE_POST: post %s not found", ErrTxFailed, p.PostID)
}
if err != nil {
return 0, err
}
var rec PostRecord
if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err != nil {
return 0, err
}
if rec.Author != tx.From {
return 0, fmt.Errorf("%w: DELETE_POST: only author can delete", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("DELETE_POST debit: %w", err)
}
rec.Deleted = true
val, _ := json.Marshal(rec)
if err := txn.Set([]byte(prefixPost+p.PostID), val); err != nil {
return 0, err
}
case EventFollow:
if tx.To == "" {
return 0, fmt.Errorf("%w: FOLLOW: target (to) is required", ErrTxFailed)
}
if tx.To == tx.From {
return 0, fmt.Errorf("%w: FOLLOW: cannot follow yourself", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("FOLLOW debit: %w", err)
}
// follow:<follower>:<target> + reverse index followin:<target>:<follower>
fKey := []byte(prefixFollow + tx.From + ":" + tx.To)
if _, err := txn.Get(fKey); err == nil {
return 0, fmt.Errorf("%w: FOLLOW: already following", ErrTxFailed)
}
if err := txn.Set(fKey, []byte{}); err != nil {
return 0, err
}
if err := txn.Set([]byte(prefixFollowInbound+tx.To+":"+tx.From), []byte{}); err != nil {
return 0, err
}
case EventUnfollow:
if tx.To == "" {
return 0, fmt.Errorf("%w: UNFOLLOW: target (to) is required", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("UNFOLLOW debit: %w", err)
}
fKey := []byte(prefixFollow + tx.From + ":" + tx.To)
if _, err := txn.Get(fKey); err != nil {
if errors.Is(err, badger.ErrKeyNotFound) {
return 0, fmt.Errorf("%w: ADD_MEMBER: channel %q not found", ErrTxFailed, p.ChannelID)
return 0, fmt.Errorf("%w: UNFOLLOW: not following", ErrTxFailed)
}
return 0, err
}
if err := txn.Delete(fKey); err != nil {
return 0, err
}
if err := txn.Delete([]byte(prefixFollowInbound + tx.To + ":" + tx.From)); err != nil {
return 0, err
}
case EventLikePost:
var p LikePostPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: LIKE_POST bad payload: %v", ErrTxFailed, err)
}
if p.PostID == "" {
return 0, fmt.Errorf("%w: LIKE_POST: post_id required", ErrTxFailed)
}
if _, err := txn.Get([]byte(prefixPost + p.PostID)); err != nil {
if errors.Is(err, badger.ErrKeyNotFound) {
return 0, fmt.Errorf("%w: LIKE_POST: post %s not found", ErrTxFailed, p.PostID)
}
return 0, err
}
lKey := []byte(prefixLike + p.PostID + ":" + tx.From)
if _, err := txn.Get(lKey); err == nil {
return 0, fmt.Errorf("%w: LIKE_POST: already liked", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("LIKE_POST debit: %w", err)
}
if err := txn.Set(lKey, []byte{}); err != nil {
return 0, err
}
if err := bumpLikeCount(txn, p.PostID, +1); err != nil {
return 0, err
}
case EventUnlikePost:
var p UnlikePostPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: UNLIKE_POST bad payload: %v", ErrTxFailed, err)
}
if p.PostID == "" {
return 0, fmt.Errorf("%w: UNLIKE_POST: post_id required", ErrTxFailed)
}
lKey := []byte(prefixLike + p.PostID + ":" + tx.From)
if _, err := txn.Get(lKey); err != nil {
if errors.Is(err, badger.ErrKeyNotFound) {
return 0, fmt.Errorf("%w: UNLIKE_POST: not liked", ErrTxFailed)
}
return 0, err
}
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 member == "" {
member = tx.From
if err := txn.Delete(lKey); err != nil {
return 0, err
}
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
}
@@ -795,9 +1096,21 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
if err := json.Unmarshal(tx.Payload, &p); err != nil {
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 {
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)
ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig)
if err != nil || !ok {
@@ -818,6 +1131,10 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
}); err != nil {
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:
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",
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 {
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(),
}
val, _ := json.Marshal(rec)
key := prefixContactIn + tx.To + ":" + tx.From
if err := txn.Set([]byte(key), val); err != nil {
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
// (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) {
pubBytes, err := hex.DecodeString(pubKeyHex)
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).
var _ = ed25519.PublicKey(nil)
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 (
EventRegisterKey EventType = "REGISTER_KEY"
EventCreateChannel EventType = "CREATE_CHANNEL"
EventAddMember EventType = "ADD_MEMBER"
EventOpenPayChan EventType = "OPEN_PAY_CHAN"
EventClosePayChan EventType = "CLOSE_PAY_CHAN"
EventTransfer EventType = "TRANSFER"
@@ -37,6 +35,17 @@ const (
EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token
EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership
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).
@@ -64,6 +73,31 @@ const (
// MinContactFee is the minimum amount a sender must pay the recipient when
// submitting an EventContactRequest (anti-spam; goes directly to recipient).
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.
@@ -90,11 +124,66 @@ type RegisterKeyPayload struct {
X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging
}
// CreateChannelPayload is embedded in EventCreateChannel transactions.
type CreateChannelPayload struct {
ChannelID string `json:"channel_id"`
Title string `json:"title"`
IsPublic bool `json:"is_public"`
// ── Feed payloads (v2.0.0) ─────────────────────────────────────────────────
// CreatePostPayload is embedded in EventCreatePost transactions. The body
// itself is NOT stored on-chain — it lives in the relay feed-mailbox keyed
// 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.
@@ -241,24 +330,6 @@ type BlockContactPayload struct {
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.
// 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,81 @@
/**
* 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 { useDevSeed } from '@/lib/devSeed';
import { NavBar } from '@/components/NavBar';
import { AnimatedSlot } from '@/components/AnimatedSlot';
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)
const hideNav =
/^\/chats\/[^/]+/.test(pathname) ||
pathname === '/compose' ||
/^\/feed\/.+/.test(pathname);
useBalance();
useContacts();
useWellKnownContracts();
useDevSeed();
useNotifications(); // permission + tap-handler
useGlobalInbox(); // global inbox listener → notifications on new peer msg
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,519 @@
/**
* 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 } 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 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 (например, из devSeed или
// только что полученные по 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 = 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 (!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 {
if (hasText) {
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,
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={() => router.back()} />}
title={
<Pressable
onPress={onOpenPeerProfile}
hitSlop={4}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
>
<Avatar name={name} address={contactAddress} size={28} />
<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 && !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'ов при открытии. */}
<FlatList
ref={listRef}
data={rows}
inverted
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
renderItem={renderRow}
contentContainerStyle={{ paddingVertical: 10 }}
showsVerticalScrollIndicator={false}
// Lazy render: only mount ~1.5 screens of bubbles initially,
// render further batches as the user scrolls older. Keeps
// initial paint fast on chats with thousands of messages.
initialNumToRender={25}
maxToRenderPerBatch={12}
windowSize={10}
removeClippedSubviews
ListEmptyComponent={() => (
<View style={{
flex: 1, alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, gap: 10,
transform: [{ scaleY: -1 }], // inverted flips cells; un-flip empty state
}}>
<Avatar name={name} address={contactAddress} size={72} />
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
Say hi to {name}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
Your messages are end-to-end encrypted.
</Text>
</View>
)}
/>
{/* 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,105 @@
/**
* 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);
// Статус подключения: 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;
};
// Сортировка по последней активности.
const sorted = useMemo(() => {
return [...contacts]
.map(c => ({ c, last: lastOf(c) }))
.sort((a, b) => {
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
const kb = b.last ? b.last.timestamp : b.c.addedAt / 1000;
return kb - ka;
})
.map(x => x.c);
}, [contacts, messages]);
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)}
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,390 @@
/**
* 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';
const MAX_CONTENT_LENGTH = 4000;
const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize
const IMAGE_MAX_DIM = 1080;
const IMAGE_QUALITY = 0.5; // JPEG Q=50 — small, still readable
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(
'Нужен доступ к фото',
'Откройте настройки и разрешите доступ к галерее.',
[
{ text: 'Отмена' },
{ text: 'Настройки', 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 > MAX_POST_BYTES - 512) {
Alert.alert(
'Слишком большое',
`Картинка ${Math.round(bytes.length / 1024)} KB — лимит ${MAX_POST_BYTES / 1024} KB. Попробуйте выбрать поменьше.`,
);
return;
}
setAttach({
uri: manipulated.uri,
mime: 'image/jpeg',
size: bytes.length,
bytes,
width: manipulated.width,
height: manipulated.height,
});
} catch (e: any) {
Alert.alert('Не удалось', String(e?.message ?? e));
} finally {
setPicking(false);
}
};
const onPublish = async () => {
if (!keyFile || !canPublish) return;
// Balance guard.
if (balance !== null && balance < estimatedFee) {
Alert.alert(
'Недостаточно средств',
`Нужно ${formatFee(estimatedFee)}, на балансе ${formatFee(balance)}.`,
);
return;
}
Alert.alert(
'Опубликовать пост?',
`Цена: ${formatFee(estimatedFee)}\nРазмер: ${Math.round(totalBytes / 1024 * 10) / 10} KB`,
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Опубликовать',
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('Не удалось опубликовать', 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={() => router.back()} 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,
}}
>
Опубликовать
</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="Что происходит?"
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 · метаданные удалят на сервере
</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,206 @@
/**
* 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';
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.
router.back();
}, []);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
title="Пост"
/>
{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 }}>
Пост удалён или больше недоступен
</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,
}}>
Информация о посте
</Text>
<DetailRow label="Просмотров" value={formatCount(stats?.views ?? post.views)} />
<DetailRow label="Лайков" value={formatCount(stats?.likes ?? post.likes)} />
<DetailRow label="Размер" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} />
<DetailRow
label="Стоимость публикации"
value={formatFee(1000 + post.size)}
/>
<DetailRow
label="Хостинг"
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 }}>
Хештеги
</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,431 @@
/**
* 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';
import { getDevSeedFeed } from '@/lib/devSeedFeed';
type TabKey = 'following' | 'foryou' | 'trending';
const TAB_LABELS: Record<TabKey, string> = {
following: 'Подписки',
foryou: 'Для вас',
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
// Dev-only fallback: if the node has no real posts yet, surface
// synthetic ones so we can scroll + tap. Stripped from production.
if (items.length === 0) {
items = getDevSeedFeed();
}
setPosts(items);
// If the server returned fewer than PAGE_SIZE, we already have
// everything — disable further paginated fetches.
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. In __DEV__
// fall back to synthetic seed posts so the scroll / tap UI stays
// testable; in production this path shows the empty state.
if (/Network request failed|→\s*404/.test(msg)) {
setPosts(getDevSeedFeed());
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 'Подпишитесь на кого-нибудь, чтобы увидеть их посты здесь.';
case 'foryou': return 'Пока нет рекомендаций — возвращайтесь позже.';
case 'trending': return 'В этой ленте пока тихо.';
}
}, [tab]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Лента" />
{/* 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="Не удалось загрузить ленту"
subtitle={error}
onRetry={() => loadPosts(false)}
/>
) : (
<EmptyState
icon="newspaper-outline"
title="Здесь пока пусто"
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 }}>
Попробовать снова
</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,138 @@
/**
* 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';
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={() => router.back()} />}
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 }}>
Пока нет постов с этим тегом
</Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}>
Будьте первым напишите пост с #{tag}
</Text>
</View>
)
}
contentContainerStyle={
posts.length === 0
? { flexGrow: 1 }
: { paddingTop: 8, paddingBottom: 24 }
}
/>
</View>
);
}

View File

@@ -0,0 +1,288 @@
/**
* 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 } 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;
}
const identity = await getIdentity(address);
setResolved({
address: identity?.pub_key ?? address,
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 (balance < fee + 1000) {
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (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: () => router.back() }],
);
} 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="New chat"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: 120 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 14 }}>
Enter a <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text>, a
hex pubkey or a <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> address.
</Text>
<SearchBar
value={query}
onChangeText={setQuery}
placeholder="@alice or hex / DC address"
onSubmitEditing={search}
/>
<Pressable
onPress={search}
disabled={searching || !query.trim()}
style={({ pressed }) => ({
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
paddingVertical: 11, borderRadius: 999, marginTop: 12,
backgroundColor: !query.trim() || searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{searching ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text>
)}
</Pressable>
{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 recipient)
</Text>
<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,
alignItems: 'center',
paddingVertical: 10,
borderRadius: 10,
backgroundColor: active ? '#ffffff' : pressed ? '#1a1a1a' : '#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>
</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,323 @@
/**
* 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, { 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 } from '@/lib/api';
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);
const isMe = !!keyFile && keyFile.pub_key === address;
const displayName = contact?.username
? `@${contact.username}`
: contact?.alias ?? (isMe ? 'Вы' : 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="Профиль"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
/>
<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} />
<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 ? 'Вы подписаны' : 'Подписаться'}
</Text>
)}
</Pressable>
) : (
<Pressable
onPress={() => router.push('/(app)/settings' as never)}
style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 9,
borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Редактировать
</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>
{/* Open chat — single CTA, full width, icon inline with text.
Only when we know this is a contact (direct chat exists). */}
{!isMe && contact && (
<Pressable
onPress={openChat}
style={({ pressed }) => ({
marginTop: 14,
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 }}>
Открыть чат
</Text>
</Pressable>
)}
{/* ── 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 }}>
Адрес
</Text>
<Text
style={{
color: copied ? '#3ba55d' : '#ffffff',
fontSize: 13,
fontFamily: 'monospace',
fontWeight: '600',
}}
numberOfLines={1}
>
{copied ? 'Скопировано' : shortAddr(address ?? '')}
</Text>
<Ionicons
name={copied ? 'checkmark' : 'copy-outline'}
size={14}
color={copied ? '#3ba55d' : '#6a6a6a'}
style={{ marginLeft: 8 }}
/>
</Pressable>
{/* Encryption status */}
{contact && (
<>
<Divider />
<InfoRow
label="Шифрование"
value={contact.x25519Pub
? 'end-to-end (NaCl)'
: 'ключ ещё не опубликован'}
danger={!contact.x25519Pub}
icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'}
/>
<Divider />
<InfoRow
label="Добавлен"
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="Участников"
value="—"
icon="people-outline"
/>
</>
)}
</>
)}
</View>
{!contact && !isMe && (
<Text style={{
color: '#6a6a6a',
fontSize: 12,
textAlign: 'center',
marginTop: 14,
paddingHorizontal: 24,
lineHeight: 17,
}}>
Этот пользователь пока не в ваших контактах. Нажмите «Подписаться», чтобы видеть его посты в ленте, или добавьте в чаты через @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 message you · {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 } 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={() => router.back()} />}
/>
<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,652 @@
/**
* 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 { 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>
<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,139 @@
/**
* 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 { 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={() => router.back()} />}
/>
<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,230 @@
/**
* 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 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={() => router.back()} />}
/>
<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 на каждом сообщении. Даже релей-нода не может прочитать переписку."
/>
<FeatureRow
icon="key"
title="Твои ключи — твой аккаунт"
text="Без телефона, email и серверных паролей. Ключи никогда не покидают устройство."
/>
<FeatureRow
icon="git-network"
title="Decentralised"
text="Любой может поднять свою ноду. Нет единой точки отказа и цензуры."
/>
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end',
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTAPrimary label="Продолжить" 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 }}>
Как это работает
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, marginBottom: 22 }}>
Сообщения проходят через релей-ноду в зашифрованном виде.
Выбери публичную или подключи свою.
</Text>
<OptionCard
icon="globe"
title="Публичная нода"
text="Удобно и быстро — нода хостится комьюнити, небольшая комиссия за каждое отправленное сообщение."
/>
<OptionCard
icon="hardware-chip"
title="Своя нода"
text="Максимальный контроль. Исходники открыты — подними на своём сервере за 5 минут."
/>
<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="Исходники"
icon="logo-github"
onPress={() => Linking.openURL(GITEA_URL).catch(() => {})}
/>
<CTAPrimary label="Продолжить" 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' }}>
Твой аккаунт
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, textAlign: 'center', maxWidth: 280 }}>
Создай новую пару ключей или импортируй существующую.
Ключи хранятся только на этом устройстве.
</Text>
</View>
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTASecondary
label="Импорт"
onPress={() => router.push('/(auth)/import' as never)}
/>
<CTAPrimary
label="Создать аккаунт"
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,76 @@
/**
* Avatar — круглая заглушка с инициалом, опционально online-пип.
* Нет зависимостей от асинхронных источников (картинок) — для messenger-тайла
* важнее мгновенный рендер, чем фотография. Если в будущем будут фото,
* расширяем здесь.
*/
import React from 'react';
import { View, Text } from 'react-native';
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;
}
/** Простое хэширование имени → один из 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 }: AvatarProps) {
const seed = (name ?? address ?? '?').replace(/^@/, '');
const initial = seed.charAt(0).toUpperCase() || '?';
const bg = 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',
}}
>
<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,174 @@
/**
* 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;
}
export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
const name = 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}
dotColor={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)
: 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,88 @@
/**
* SearchBar — серый блок, в состоянии idle текст с иконкой 🔍 отцентрированы.
*
* Когда пользователь тапает/фокусирует — поле становится input-friendly, но
* визуально рестайл не нужен: при наличии текста placeholder скрыт и
* пользовательский ввод выравнивается влево автоматически (multiline off).
*/
import React, { useState } from 'react';
import { View, TextInput, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export interface SearchBarProps {
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
autoFocus?: boolean;
onSubmitEditing?: () => void;
}
export function SearchBar({
value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing,
}: SearchBarProps) {
const [focused, setFocused] = useState(false);
// Placeholder центрируется пока нет фокуса И нет значения.
// Как только юзер фокусируется или начинает печатать — иконка+текст
// прыгают к левому краю, чтобы не мешать вводу.
const centered = !focused && !value;
return (
<View
style={{
backgroundColor: '#1a1a1a',
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 9,
minHeight: 36,
justifyContent: 'center',
}}
>
{centered ? (
// ── Idle state — только текст+icon, отцентрированы.
// Невидимый TextInput поверх ловит tap, чтобы не дергать focus вручную.
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 6 }} />
<Text style={{ color: '#8b8b8b', fontSize: 14 }}>{placeholder}</Text>
<TextInput
value={value}
onChangeText={onChangeText}
autoFocus={autoFocus}
onFocus={() => setFocused(true)}
onSubmitEditing={onSubmitEditing}
returnKeyType="search"
style={{
position: 'absolute', left: 0, right: 0, top: 0, bottom: 0,
color: 'transparent',
// Скрываем cursor в idle-режиме; при focus компонент перерисуется.
}}
/>
</View>
) : (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 8 }} />
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#8b8b8b"
autoCapitalize="none"
autoCorrect={false}
autoFocus
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onSubmitEditing={onSubmitEditing}
returnKeyType="search"
style={{
flex: 1,
color: '#ffffff',
fontSize: 14,
padding: 0,
includeFontPadding: false,
}}
/>
</View>
)}
</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,
}}
>
ПОСТ
</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 }}>
с фото
</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,475 @@
/**
* 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.
const displayName = useMemo(() => {
const c = contacts.find(x => x.address === post.author);
if (c?.username) return `@${c.username}`;
if (c?.alias) return c.alias;
if (mine) return 'You';
return shortAddr(post.author);
}, [contacts, post.author, mine]);
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('Не удалось', 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: 'Удалить пост',
destructive: true,
onPress: () => {
Alert.alert('Удалить пост?', 'Это действие нельзя отменить.', [
{ text: 'Отмена', style: 'cancel' },
{
text: 'Удалить',
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('Ошибка', 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: 'Отмена', style: 'cancel' as const },
];
Alert.alert('Действия', '', 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,307 @@
/**
* 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('Готово', `Отправлено в ${ok} из ${ok + failed} чат${plural(ok + failed)}.`);
}
// Close + reset regardless — done is done.
setPicked(new Set());
setQuery('');
onClose();
} catch (e: any) {
Alert.alert('Не удалось', String(e?.message ?? e));
} 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' }}>
Поделиться постом
</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="Поиск по контактам"
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
? 'Нет контактов по такому запросу'
: 'Контакты с ключами шифрования отсутствуют'}
</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
? 'Выберите контакты'
: `Отправить (${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 {
const mod100 = n % 100;
const mod10 = n % 10;
if (mod100 >= 11 && mod100 <= 19) return 'ов';
if (mod10 === 1) return '';
if (mod10 >= 2 && mod10 <= 4) return 'а';
return 'ов';
}

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
}

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

@@ -0,0 +1,781 @@
/**
* 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 'Слишком много запросов к ноде. Подождите пару секунд и попробуйте снова.';
}
if (raw.startsWith('400') && raw.includes('timestamp')) {
return 'Часы устройства не синхронизированы с нодой. Проверьте время на телефоне (±1 час).';
}
if (raw.startsWith('400') && raw.includes('signature')) {
return 'Подпись транзакции невалидна. Попробуйте ещё раз; если не помогает — вероятна несовместимость версий клиента и ноды.';
}
if (raw.startsWith('400')) {
return `Нода отклонила транзакцию: ${raw.replace(/^400:\s*/, '')}`;
}
if (raw.startsWith('5')) {
return `Ошибка ноды (${raw}). Попробуйте позже.`;
}
// Network-level
if (raw.toLowerCase().includes('network request failed')) {
return 'Нет связи с нодой. Проверьте URL в настройках и доступность сервера.';
}
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;
}
}
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 /api/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[] }>(`/api/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;
}
/** 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-таймштампов делаем нормализацию внутри.
*/
// ─── Русские месяцы (genitive для "17 июня 2025") ────────────────────────────
const RU_MONTHS_GEN = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
];
function sameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/**
* Day-bucket label для сепараторов внутри чата.
* "Сегодня" / "Вчера" / "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 'Сегодня';
if (sameDay(d, yday)) return 'Вчера';
return `${d.getDate()} ${RU_MONTHS_GEN[d.getMonth()]} ${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')}`;
}

444
client-app/lib/devSeed.ts Normal file
View File

@@ -0,0 +1,444 @@
/**
* Dev seed — заполняет store фейковыми контактами и сообщениями для UI-теста.
*
* Запускается один раз при монтировании layout'а если store пустой
* (useDevSeed). Реальные контакты через WS/HTTP приходят позже —
* `upsertContact` перезаписывает mock'и по address'у.
*
* Цели seed'а:
* 1. Показать все три типа чатов (direct / group / channel) с разным
* поведением sender-meta.
* 2. Наполнить список чатов до скролла (15+ контактов).
* 3. В каждом чате — ≥15 сообщений для скролла в chat view.
* 4. Продемонстрировать "staircase" (run'ы одного отправителя
* внутри 1h-окна) и переключения между отправителями.
*/
import { useEffect } from 'react';
import { useStore } from './store';
import type { Contact, Message } from './types';
// ─── Детерминированные «pubkey» (64 hex символа) ───────────────────
function fakeHex(seed: number): string {
let h = '';
let x = seed;
for (let i = 0; i < 32; i++) {
x = (x * 1103515245 + 12345) & 0xffffffff;
h += (x & 0xff).toString(16).padStart(2, '0');
}
return h;
}
const now = () => Math.floor(Date.now() / 1000);
const MINE = fakeHex(9999);
// ─── Контакты ──────────────────────────────────────────────────────
// 16 штук: 5 DM + 6 групп + 5 каналов. Поле `addedAt` задаёт порядок в
// списке когда нет messages — ordering-fallback.
const mockContacts: Contact[] = [
// ── DM ──────────────────────────────────────────────────────────
{ address: fakeHex(1001), x25519Pub: fakeHex(2001),
username: 'jordan', addedAt: Date.now() - 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1002), x25519Pub: fakeHex(2002),
alias: 'Myles Wagner', addedAt: Date.now() - 2 * 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1010), x25519Pub: fakeHex(2010),
username: 'sarah_k', addedAt: Date.now() - 3 * 60 * 60 * 1_000, kind: 'direct',
unread: 2 },
{ address: fakeHex(1011), x25519Pub: fakeHex(2011),
alias: 'Mom', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1012), x25519Pub: fakeHex(2012),
username: 'alex_dev', addedAt: Date.now() - 6 * 60 * 60 * 1_000, kind: 'direct' },
// ── Groups ─────────────────────────────────────────────────────
{ address: fakeHex(1003), x25519Pub: fakeHex(2003),
alias: 'Tahoe weekend 🌲', addedAt: Date.now() - 4 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1004), x25519Pub: fakeHex(2004),
alias: 'Knicks tickets', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'group',
unread: 3 },
{ address: fakeHex(1020), x25519Pub: fakeHex(2020),
alias: 'Family', addedAt: Date.now() - 8 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1021), x25519Pub: fakeHex(2021),
alias: 'Work eng', addedAt: Date.now() - 12 * 60 * 60 * 1_000, kind: 'group',
unread: 7 },
{ address: fakeHex(1022), x25519Pub: fakeHex(2022),
alias: 'Book club', addedAt: Date.now() - 24 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1023), x25519Pub: fakeHex(2023),
alias: 'Tuesday D&D 🎲', addedAt: Date.now() - 30 * 60 * 60 * 1_000, kind: 'group' },
// (Channel seeds removed in v2.0.0 — channels replaced by the social feed.)
];
// ─── Генератор сообщений ───────────────────────────────────────────
// Альт-отправители для group-чатов — нужны только как идентификатор `from`.
const P_TYRA = fakeHex(3001);
const P_MYLES = fakeHex(3002);
const P_NATE = fakeHex(3003);
const P_TYLER = fakeHex(3004);
const P_MOM = fakeHex(3005);
const P_DAD = fakeHex(3006);
const P_SIS = fakeHex(3007);
const P_LEAD = fakeHex(3008);
const P_PM = fakeHex(3009);
const P_QA = fakeHex(3010);
const P_DESIGN= fakeHex(3011);
const P_ANNA = fakeHex(3012);
const P_DM_PEER = fakeHex(3013);
type Msg = Omit<Message, 'id'>;
function list(prefix: string, list: Msg[]): Message[] {
return list.map((m, i) => ({ ...m, id: `${prefix}_${i}` }));
}
function mockMessagesFor(contact: Contact): Message[] {
const peer = contact.x25519Pub;
// ── DM: @jordan ────────────────────────────────────────────────
if (contact.username === 'jordan') {
// Важно: id'ы сообщений используются в replyTo.id, поэтому
// указываем их явно где нужно сшить thread.
const msgs: Message[] = list('jordan', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 22, text: 'Hey, have a sec later today?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 21, read: true, text: 'yep around 4pm' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'cool, coffee at the corner spot?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 19, read: true, text: 'works' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'just parked 🚗' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'see you in 5' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 3, read: true, text: "that was a great catchup" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: "totally — thanks for the book rec" },
{ from: peer, mine: false, timestamp: now() - 60 * 40, text: 'Hey Jordan - Got tickets to the Knicks game tomorrow, let me know if you want to come!' },
{ from: peer, mine: false, timestamp: now() - 60 * 39, text: "we've got floor seats 🔥" },
{ from: peer, mine: false, timestamp: now() - 60 * 38, text: "starts at 7, pregame at the bar across the street" },
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: 'Ah sadly I already have plans' },
{ from: MINE, mine: true, timestamp: now() - 60 * 13, read: true, text: 'maybe next time?' },
{ from: peer, mine: false, timestamp: now() - 60 * 5, text: "no worries — enjoy whatever you're up to" },
{ from: peer, mine: false, timestamp: now() - 60 * 2, text: "wish you could make it tho 🏀" },
]);
// Пришьём reply: MINE-сообщение "Ah sadly…" отвечает на "Hey Jordan - Got tickets…"
const target = msgs.find(m => m.text.startsWith('Hey Jordan - Got tickets'));
const mine = msgs.find(m => m.text === 'Ah sadly I already have plans');
if (target && mine) {
mine.replyTo = {
id: target.id,
author: '@jordan',
text: target.text,
};
}
return msgs;
}
// ── DM: Myles Wagner ───────────────────────────────────────────
if (contact.alias === 'Myles Wagner') {
return list('myles', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'saw the draft, left a bunch of comments' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 29, read: true, text: 'thx, going through them now' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 29, text: 'no rush — tomorrow is fine' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'lunch today?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: "can't, stuck in reviews" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: 'tomorrow?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: '✅ tomorrow' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: '',
attachment: {
kind: 'voice',
uri: 'voice-demo://myles-1',
duration: 17,
},
},
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'the dchain repo finally built for me' },
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'docker weirdness was the issue' },
{ from: MINE, mine: true, timestamp: now() - 60 * 21, read: true, text: "nice, told you the WSL path would do it" },
{ from: peer, mine: false, timestamp: now() - 60 * 20, text: 'So good!' },
]);
}
// ── DM: @sarah_k (с unread=2) ──────────────────────────────────
if (contact.username === 'sarah_k') {
return list('sarah', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: "hey! been a while" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 28, read: true, text: 'yeah, finally surfaced after the launch crunch' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 27, text: 'how did it go?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 27, read: true, text: "pretty well actually 🙏" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'btw drinks on friday?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'that new wine bar' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'around 7 if you can make it' },
]);
}
// ── DM: Mom ────────────────────────────────────────────────────
if (contact.alias === 'Mom') {
return list('mom', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Did you see the photos from the trip?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 47, read: true, text: 'not yet, send them again?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 47, text: 'ok' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: 'wow, grandma looks great' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'she asked about you!' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'call later?' },
]);
}
// ── DM: @alex_dev ──────────────────────────────────────────────
if (contact.username === 'alex_dev') {
return list('alex', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'did you try the new WASM build?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 11, read: true, text: 'yeah, loader error on start' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 11, text: 'path encoding issue again?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 10, read: true, text: 'probably, checking now' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 8, read: true, text: 'yep, was the trailing slash' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 8, text: 'classic 😅' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'PR for that incoming tomorrow' },
]);
}
// ── Group: Tahoe weekend 🌲 ────────────────────────────────────
if (contact.alias === 'Tahoe weekend 🌲') {
const msgs: Message[] = list('tahoe', [
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: "who's in for Tahoe this weekend?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 49, text: "me!" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 48, read: true, text: "count me in" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: "woohoo 🎉" },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 47, text: "planning friday night → sunday evening yeah?" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 46, text: "yep, maybe leave friday after lunch" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "I made this itinerary with Grok, what do you think?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 1: Eagle Falls hike" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 2: Emerald bay kayak" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 3: lazy breakfast then drive back" },
{
from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: '',
attachment: {
kind: 'file',
uri: 'https://example.com/Lake_Tahoe_Itinerary.pdf',
name: 'Lake_Tahoe_Itinerary.pdf',
size: 97_280, // ~95 KB
mime: 'application/pdf',
},
},
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 24, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "Eagle falls was stunning last year!" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 31, text: "who's excited for Tahoe this weekend?" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 30, text: "I've been checking the forecast — sun all weekend 🌞" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 22, text: "I made this itinerary with Grok, what do you think?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 21, text: "Day 1 we can hit Eagle Falls" },
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
{
from: P_TYRA, mine: false, timestamp: now() - 60 * 3, text: 'pic from my last trip 😍',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1505245208761-ba872912fac0?w=800',
width: 800,
height: 1000,
mime: 'image/jpeg',
},
},
]);
// Thread: mine "Love it — Eagle falls looks insane" — ответ на
// Myles'овский itinerary-PDF. Берём ПЕРВЫЙ match "Day 1 we can hit
// Eagle Falls" и пришиваем его к первому mine-bubble'у.
const target = msgs.find(m => m.text === 'Day 1 we can hit Eagle Falls');
const reply = msgs.find(m => m.text === 'Love it — Eagle falls looks insane' && m.mine);
if (target && reply) {
reply.replyTo = {
id: target.id,
author: 'Myles Wagner',
text: target.text,
};
}
return msgs;
}
// ── Group: Knicks tickets ──────────────────────────────────────
if (contact.alias === 'Knicks tickets') {
return list('knicks', [
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 20, text: "quick group update — got 5 tickets for thursday" },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'wow nice' },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'where are we seated?' },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 19, text: 'section 102, row 12' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 18, read: true, text: 'thats a great spot' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 18, text: "can someone venmo nate 🙏" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 17, read: true, text: 'sending now' },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 32, text: "Ok who's in for tomorrow's game?" },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 31, text: 'Got 2 extra tickets, first-come-first-served' },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 27, text: "I'm in!" },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 26, text: 'What time does it start?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 20, read: true, text: "Let's meet at the bar around 6?" },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 15, text: 'Sounds good' },
]);
}
// ── Group: Family ──────────────────────────────────────────────
if (contact.alias === 'Family') {
return list('family', [
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 36, text: 'remember grandma birthday on sunday' },
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 36, text: 'noted 🎂' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 35, text: 'who is bringing the cake?' },
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 35, text: "I'll get it from the bakery" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 34, read: true, text: 'I can pick up flowers' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 34, text: 'perfect' },
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 8, text: 'forecast is rain sunday — backup plan?' },
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 8, text: "we'll move indoors, the living room works" },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 7, text: 'works!' },
]);
}
// ── Group: Work eng (unread=7) ─────────────────────────────────
if (contact.alias === 'Work eng') {
return list('work', [
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 16, text: 'standup at 10 moved to 11 today btw' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 16, text: 'thanks!' },
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 15, text: "the staging deploy broke again 🙃" },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 15, text: "ugh, looking" },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 14, text: 'fixed — migration was stuck' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 13, read: true, text: 'Worked for me now 👍' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 5, text: 'reminder: demo tomorrow, slides by eod' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Ill handle the technical half' },
{ from: P_DESIGN,mine: false,timestamp: now() - 60 * 60 * 4, text: 'just posted the v2 mocks in figma' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 3, text: 'chatting with sales — 3 new trials this week' },
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 2, text: 'flaky test on CI — investigating' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 30, text: 'okay seems like CI is green now' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 28, text: 'retry passed' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 20, text: "we're good for release" },
]);
}
// ── Group: Book club ───────────────────────────────────────────
if (contact.alias === 'Book club') {
return list('book', [
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 96, text: 'next month: "Project Hail Mary"?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 95, read: true, text: '👍' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 94, text: 'yes please' },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'halfway through — so good' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'love the linguistics angle' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: "rocky is my favourite character in years" },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 28, text: 'agreed' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "let's meet sunday 4pm?" },
]);
}
// ── Group: Tuesday D&D 🎲 ──────────────────────────────────────
if (contact.alias === 'Tuesday D&D 🎲') {
return list('dnd', [
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 72, text: 'Session 14 recap up on the wiki' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 72, text: '🙏' },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: 'can we start 30min late next tuesday? commute issue' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 50, text: 'sure' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 49, read: true, text: 'works for me' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 32, text: 'we pick up where we left — in the dragons cave' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 32, text: 'excited 🐉' },
]);
}
// ── Channel: dchain_updates ────────────────────────────────────
if (contact.username === 'dchain_updates') {
return list('dchain_updates', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 96, text: '🔨 v0.0.1-alpha tagged on Gitea' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 72, text: 'PBFT equivocation-detection тесты зелёные' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'New: /api/peers теперь включает peer-version info' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: '📘 Docs overhaul merged: docs/README.md' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 36, text: 'Schema migration scaffold landed (no-op для текущей версии)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: '🚀 v0.0.1 released' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 10, text: 'Includes: auto-update from Gitea, peer-version gossip, schema migrations' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 20, text: 'Check /api/well-known-version for the full feature list' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'Thanks to all testers — feedback drives the roadmap 🙏' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'v0.0.2 roadmap published: https://git.vsecoder.vodka/vsecoder/dchain' },
{ from: peer, mine: false, timestamp: now() - 60 * 30, text: 'quick heads-up: nightly builds switching to new docker-slim base' },
]);
}
// ── Channel: Relay broadcasts ──────────────────────────────────
if (contact.alias === '⚡ Relay broadcasts') {
return list('relay_bc', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Relay fleet snapshot: 12 active, 3 inactive' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Relay #3 came online in US-east' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Validator set updated: 3→4' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'PBFT view-change детектирован и отработан на блоке 184120' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 15, text: 'Mailbox eviction ran — 42 stale envelopes' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'Relay #8 slashed for equivocation — evidence at block 184202' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'Relay #12 came online in EU-west, registering now…' },
]);
}
// ── Channel: Tech news ────────────────────────────────────────
if (contact.alias === '📰 Tech news') {
return list('tech_news', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Rust 1.78 released — new lints for raw pointers' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Go 1.23 ships range-over-func officially' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Expo SDK 54 drops — new-architecture default' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'CVE-2026-1337 patched in libsodium (update your keys)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Matrix protocol adds post-quantum handshakes' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Data-center aerial view — new hyperscaler in Iceland',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'IETF draft: "DNS-over-blockchain"' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 6, text: 'GitHub tightens 2FA defaults for orgs' },
]);
}
// ── Channel: Design inspo (unread=12) ──────────────────────────
if (contact.alias === '🎨 Design inspo') {
return list('design_inspo', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Weekly pick: Linear UI v3 breakdown' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 140, text: 'Figma file of the week: "Command bar patterns"' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Motion study: Stripe checkout shake-error animation' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: "10 great empty-state illustrations (blogpost)" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Tool: Hatch — colour-palette extractor from photos' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: '🔮 Trend watch: glassmorphism is back (again)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Twitter thread: why rounded buttons are the default' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'Framer templates — black friday sale' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'New typeface: "Grotesk Pro" — free for personal use' },
]);
}
// ── Channel: NBA scores ────────────────────────────────────────
if (contact.alias === '🏀 NBA scores') {
return list('nba', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Lakers 112 — Warriors 108 (OT)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 130, text: 'Celtics 128 — Heat 115' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Nuggets 119 — Thunder 102' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 70, text: "Knicks 101 — Bulls 98" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Mavericks 130 — Kings 127' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 24, text: 'Bucks 114 — Sixers 110' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Live: Lakers leading 78-72 at half' },
]);
}
return [];
}
// ─── Hook ──────────────────────────────────────────────────────────
export function useDevSeed() {
const contacts = useStore(s => s.contacts);
const setContacts = useStore(s => s.setContacts);
const setMessages = useStore(s => s.setMessages);
useEffect(() => {
if (contacts.length > 0) return;
setContacts(mockContacts);
for (const c of mockContacts) {
const msgs = mockMessagesFor(c);
if (msgs.length > 0) setMessages(c.address, msgs);
}
}, [contacts.length, setContacts, setMessages]);
}

View File

@@ -0,0 +1,180 @@
/**
* Dev-only mock posts for the feed.
*
* Why: in __DEV__ before any real posts exist on the node, the timeline/
* for-you/trending tabs come back empty. Empty state is fine visually but
* doesn't let you test scrolling, like animations, view-counter bumps,
* navigation to post detail, etc. This module injects a small set of
* synthetic posts so the UI has something to chew on.
*
* Gating:
* - Only active when __DEV__ === true (stripped from production builds).
* - Only surfaces when the REAL API returns an empty array. If the node
* is returning actual posts, we trust those and skip the mocks.
*
* These posts have made-up post_ids — tapping on them to open detail
* WILL 404 against the real backend. That's intentional — the mock is
* purely for scroll / tap-feedback testing.
*/
import type { FeedPostItem } from './feed';
// Fake hex-like pubkeys so Avatar's colour hash still looks varied.
function fakeAddr(seed: number): string {
const h = (seed * 2654435761).toString(16).padStart(8, '0');
return (h + h + h + h).slice(0, 64);
}
function fakePostID(n: number): string {
return `dev${String(n).padStart(29, '0')}`;
}
const NOW = Math.floor(Date.now() / 1000);
// Small curated pool of posts covering the render surface we care about:
// plain text, hashtag variety, different lengths, likes / views spread,
// reply/quote references, one with an attachment marker.
const SEED_POSTS: FeedPostItem[] = [
{
post_id: fakePostID(1),
author: fakeAddr(1),
content: 'Добро пожаловать в ленту DChain. Это #DEV-посты — они видны только пока реальная лента пустая.',
created_at: NOW - 60,
size: 200,
hosting_relay: fakeAddr(100),
views: 127, likes: 42,
has_attachment: false,
hashtags: ['dev'],
},
{
post_id: fakePostID(2),
author: fakeAddr(2),
content: 'Пробую новую ленту #twitter-style. Лайки, просмотры, подписки — всё on-chain, тела постов — off-chain в mailbox релея.',
created_at: NOW - 540,
size: 310,
hosting_relay: fakeAddr(100),
views: 89, likes: 23,
has_attachment: false,
hashtags: ['twitter'],
},
{
post_id: fakePostID(3),
author: fakeAddr(3),
content: 'Сжатие изображений — максимальное на клиенте (WebP Q=50 @1080p), плюс серверный EXIF-скраб через stdlib re-encode. GPS-координаты из EXIF больше никогда не утекают. #privacy',
created_at: NOW - 1200,
size: 420,
hosting_relay: fakeAddr(100),
views: 312, likes: 78,
has_attachment: true,
hashtags: ['privacy'],
},
{
post_id: fakePostID(4),
author: fakeAddr(4),
content: 'Короткий пост.',
created_at: NOW - 3600,
size: 128,
hosting_relay: fakeAddr(100),
views: 12, likes: 3,
has_attachment: false,
},
{
post_id: fakePostID(5),
author: fakeAddr(1),
content: 'Отвечаю сам себе — фича threads пока через reply_to только, без UI thread-виджета.',
created_at: NOW - 7200,
size: 220,
hosting_relay: fakeAddr(100),
views: 45, likes: 11,
has_attachment: false,
reply_to: fakePostID(1),
},
{
post_id: fakePostID(6),
author: fakeAddr(5),
content: '#golang + #badgerdb + #libp2p = DChain бэкенд. Пять package в test suite, все зелёные.',
created_at: NOW - 10800,
size: 180,
hosting_relay: fakeAddr(100),
views: 201, likes: 66,
has_attachment: false,
hashtags: ['golang', 'badgerdb', 'libp2p'],
},
{
post_id: fakePostID(7),
author: fakeAddr(6),
content: 'Feed-mailbox хранит тела постов до 30 дней (настраиваемо через DCHAIN_FEED_TTL_DAYS). Потом BadgerDB выселяет автоматически — chain-метаданные остаются навсегда.',
created_at: NOW - 14400,
size: 380,
hosting_relay: fakeAddr(100),
views: 156, likes: 48,
has_attachment: false,
},
{
post_id: fakePostID(8),
author: fakeAddr(7),
content: 'Pricing: BasePostFee = 1000 µT (0.001 T) + 1 µT за каждый байт. Уходит владельцу релея, принявшего пост.',
created_at: NOW - 21600,
size: 250,
hosting_relay: fakeAddr(100),
views: 78, likes: 22,
has_attachment: false,
},
{
post_id: fakePostID(9),
author: fakeAddr(8),
content: 'Twitter-like, но без миллиардов долларов на инфраструктуру — каждый оператор ноды платит за свой кусок хостинга и зарабатывает на публикациях. #decentralised #messaging',
created_at: NOW - 43200,
size: 340,
hosting_relay: fakeAddr(100),
views: 412, likes: 103,
has_attachment: false,
hashtags: ['decentralised', 'messaging'],
},
{
post_id: fakePostID(10),
author: fakeAddr(9),
content: 'Короче. Лайк = on-chain tx с fee 1000 µT. Дорого для спама, дёшево для реального лайка. Пока без батчинга, но в плане. #design',
created_at: NOW - 64800,
size: 200,
hosting_relay: fakeAddr(100),
views: 92, likes: 29,
has_attachment: false,
hashtags: ['design'],
},
{
post_id: fakePostID(11),
author: fakeAddr(2),
content: 'Follow граф на chain: двусторонний индекс (forward + inbound), так что Followers() и Following() — оба O(M).',
created_at: NOW - 86400 - 1000,
size: 230,
hosting_relay: fakeAddr(100),
views: 61, likes: 14,
has_attachment: false,
},
{
post_id: fakePostID(12),
author: fakeAddr(10),
content: 'Рекомендации (For You): берём последние 48ч постов, фильтруем подписки + уже лайкнутые + свои, ранжируем по likes × 3 + views. Версия 1 — будет умнее. #recsys',
created_at: NOW - 129600,
size: 290,
hosting_relay: fakeAddr(100),
views: 189, likes: 58,
has_attachment: false,
hashtags: ['recsys'],
},
];
/**
* Returns the dev-seed post list. Only returns actual items in dev
* builds; release bundles return an empty array so fake posts never
* appear in production.
*
* We use the runtime `globalThis.__DEV__` lookup rather than the typed
* `__DEV__` global because some builds can have the TS typing
* out-of-sync with the actual injected value.
*/
export function getDevSeedFeed(): FeedPostItem[] {
const g = globalThis as unknown as { __DEV__?: boolean };
if (g.__DEV__ !== true) return [];
return SEED_POSTS;
}

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
}

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

@@ -0,0 +1,35 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
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

@@ -41,6 +41,7 @@ import (
"go-blockchain/consensus"
"go-blockchain/economy"
"go-blockchain/identity"
"go-blockchain/media"
"go-blockchain/node"
"go-blockchain/node/version"
"go-blockchain/p2p"
@@ -77,6 +78,10 @@ func main() {
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)")
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)")
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
@@ -634,6 +639,15 @@ func main() {
go mailbox.RunGC()
log.Printf("[NODE] relay mailbox: %s", *mailboxDB)
// --- Feed mailbox (social-feed post bodies, v2.0.0) ---
feedTTL := time.Duration(*feedTTLDays) * 24 * time.Hour
feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL)
if err != nil {
log.Fatalf("[NODE] feed mailbox: %v", err)
}
defer feedMailbox.Close()
log.Printf("[NODE] feed mailbox: %s (TTL %d days)", *feedDB, *feedTTLDays)
// Push-notify bus consumers whenever a fresh envelope lands in the
// mailbox. Clients subscribed to `inbox:<my_x25519>` (via WS) get the
// event immediately so they no longer need to poll /relay/inbox.
@@ -854,8 +868,6 @@ func main() {
GetNFT: chain.NFT,
GetNFTs: chain.NFTs,
NFTsByOwner: chain.NFTsByOwner,
GetChannel: chain.Channel,
GetChannelMembers: chain.ChannelMembers,
Events: sseHub,
WS: wsHub,
// Onboarding: expose libp2p peers + chain_id so new nodes/clients can
@@ -920,6 +932,36 @@ func main() {
ContactRequests: func(pubKey string) ([]blockchain.ContactInfo, error) {
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() {
@@ -942,6 +984,7 @@ func main() {
if err := stats.ListenAndServe(*statsAddr, statsQuery, func(mux *http.ServeMux) {
node.RegisterExplorerRoutes(mux, explorerQuery, routeFlags)
node.RegisterRelayRoutes(mux, relayConfig)
node.RegisterFeedRoutes(mux, feedConfig)
// POST /api/governance/link — link deployed contracts at runtime.
// Body: {"governance": "<id>"}

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.22-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 |
| [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)

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
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:**
```json
{
"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
MSG=$(echo -n "Hello, Bob!" | base64)
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\"}"
```
**Response:**
```json
{
"id": "env-abc123",
"recipient_pub": "...",
"status": "sent"
}
```
**Response:** `{"id": "env-abc123", "recipient_pub": "...", "status": "sent"}`
---
## Broadcast конверта
## Broadcast конверта (legacy alias)
### `POST /relay/broadcast`
Опубликовать pre-sealed конверт (для light-клиентов, которые шифруют на своей стороне).
См. выше. Это основной E2E-путь.
**Request body:**
```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
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:**
```json
{"id": "env-abc123", "status": "deleted"}
```
**Response:** `{"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, │
│ escrow; deployed on-chain via DEPLOY_CONTRACT) │
│ escrow; native feed events; deployed on-chain) │
├─────────────────────────────────────────────────────────────┤
│ L2 Transport — relay mailbox, E2E NaCl encryption
(relay/, mailbox, GossipSub envelopes, RELAY_PROOF tx)
│ L2 Transport — relay mailbox (1:1 E2E) +
feed mailbox (public posts + attachments)
│ (relay/, media scrubber, GossipSub envelopes, RELAY_PROOF) │
├─────────────────────────────────────────────────────────────┤
│ L1 Chain — PBFT consensus, WASM VM, BadgerDB │
│ (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-движок перезагружает сет без рестарта ноды.

View File

@@ -147,8 +147,12 @@ txchron:<block20d>:<seq04d> → tx_id (recent-tx index)
balance:<pubkey> → uint64 (µT)
stake:<pubkey> → uint64 (µT)
id:<pubkey> → JSON RegisterKeyPayload
chan:<channelID> → JSON CreateChannelPayload
chan-member:<ch>:<pub> → ""
post:<postID> → JSON PostRecord (v2.0.0 social feed)
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
cstate:<contractID>:<key> → bytes
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,
"features": [
"access_token", "chain_id", "channels_v1", "contract_logs",
"fan_out", "identity_registry", "native_username_registry",
"onboarding_api", "payment_channels", "relay_mailbox",
"ws_submit_tx"
"access_token", "chain_id", "contract_logs",
"fan_out", "feed_v2", "identity_registry", "media_scrub",
"native_username_registry", "onboarding_api", "payment_channels",
"relay_broadcast", "relay_mailbox", "ws_submit_tx"
],
"chain_id": "dchain-ddb9a7e37fc8"
}

21
go.mod
View File

@@ -1,6 +1,6 @@
module go-blockchain
go 1.21
go 1.25.0
require (
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/multiformats/go-multiaddr v0.12.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 (
@@ -114,12 +119,12 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/mod v0.13.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sync v0.4.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.14.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
gonum.org/v1/gonum v0.13.0 // indirect
google.golang.org/protobuf v1.31.0 // 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.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.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
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-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
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.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
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-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
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-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
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.3.0/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.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
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-20180826012351-8a410e7b638d/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-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.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
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-20181017192945-9dcd33a902f4/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-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.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
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-20180830151530-49385e6e1522/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.5.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.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
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/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.3/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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
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-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
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-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
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.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
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-20191011141410-1b5146add898/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
}
// 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.
func queryIntMin0(r *http.Request, key string) int {
s := r.URL.Query().Get(key)

768
node/api_feed.go Normal file
View File

@@ -0,0 +1,768 @@
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"
"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 err == relay.ErrPostTooLarge {
jsonErr(w, err, 413)
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 (
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
@@ -9,6 +10,7 @@ import (
"time"
"go-blockchain/blockchain"
"go-blockchain/identity"
"go-blockchain/relay"
)
@@ -26,6 +28,12 @@ type RelayConfig struct {
// ContactRequests returns incoming contact records for the given Ed25519 pubkey.
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.
@@ -37,12 +45,19 @@ type RelayConfig struct {
// DELETE /relay/inbox/{envID} ?pub=<x25519hex>
// GET /relay/contacts ?pub=<ed25519hex>
func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
mux.HandleFunc("/relay/send", relaySend(rc))
mux.HandleFunc("/relay/broadcast", relayBroadcast(rc))
mux.HandleFunc("/relay/inbox/count", relayInboxCount(rc))
mux.HandleFunc("/relay/inbox/", relayInboxDelete(rc))
mux.HandleFunc("/relay/inbox", relayInboxList(rc))
mux.HandleFunc("/relay/contacts", relayContacts(rc))
// Writes go through withSubmitTxGuards: per-IP rate limit (10/s, burst 20)
// + 256 KiB body cap. Without these, a single attacker could spam
// 500 envelopes per victim in a few seconds and evict every real message
// via the mailbox FIFO cap.
mux.HandleFunc("/relay/send", withSubmitTxGuards(relaySend(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]
@@ -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 {
const inboxDeleteSkewSecs = 300 // ±5 minutes
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
// Also serve GET /relay/inbox/{id} for convenience (fetch single envelope)
@@ -133,6 +164,61 @@ func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
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 {
jsonErr(w, err, 500)
return
@@ -167,9 +253,16 @@ func relayInboxCount(rc RelayConfig) http.HandlerFunc {
// "msg_b64": "<base64-encoded plaintext>",
// }
//
// The relay node seals the message using its own X25519 keypair and broadcasts
// it on the relay gossipsub topic. No on-chain fee is attached — delivery is
// free for light clients using this endpoint.
// WARNING — NOT END-TO-END ENCRYPTED.
// The relay node seals the message using its OWN X25519 keypair, not the
// sender's. That means:
// - The relay can read the plaintext (msg_b64 arrives in the clear).
// - The recipient cannot authenticate the sender — they only see "a
// message from the relay".
// For real E2E messaging, clients should seal the envelope themselves and
// use POST /relay/broadcast instead. This endpoint is retained only for
// backwards compatibility with legacy integrations and for bootstrap
// scenarios where the sender doesn't have a long-lived X25519 key yet.
func relaySend(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {

View File

@@ -79,12 +79,6 @@ type ExplorerQuery struct {
GetNFTs func() ([]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
// /api/events endpoint returns 501 Not Implemented.
Events *SSEHub
@@ -127,7 +121,6 @@ func RegisterExplorerRoutes(mux *http.ServeMux, q ExplorerQuery, flags ...Explor
registerUpdateCheckAPI(mux, q)
registerOnboardingAPI(mux, q)
registerTokenAPI(mux, q)
registerChannelAPI(mux, q)
if !f.DisableSwagger {
registerSwaggerRoutes(mux)
}

View File

@@ -12,7 +12,7 @@
// returned forever (even if the implementation moves around internally). The
// 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
// (e.g. "channels_v2" alongside "channels_v1" for a deprecation window).
// (e.g. "feed_v3" alongside "feed_v2" for a deprecation window).
//
// Response shape:
//
@@ -55,15 +55,17 @@ const ProtocolVersion = 1
// a breaking successor (e.g. `channels_v1`, not `channels`).
var nodeFeatures = []string{
"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
"contract_logs", // /api/contract/:id/logs endpoint
"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
"media_scrub", // mandatory EXIF/GPS strip on /feed/publish
"native_username_registry", // native:username_registry contract
"onboarding_api", // /api/network-info for joiner bootstrap
"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
}

831
node/feed_e2e_test.go Normal file
View File

@@ -0,0 +1,831 @@
// End-to-end integration tests for the social feed (v2.0.0).
//
// These tests exercise the full HTTP surface against a real in-process
// setup: a BadgerDB chain, a BadgerDB feed-mailbox, the media scrubber,
// and a net/http ServeMux with all feed routes wired. Requests hit the
// real handlers (including rate-limiters, auth, and scrubber) so we
// catch wire-level regressions that unit tests miss.
//
// Layout of a typical test:
//
// h := newFeedHarness(t)
// defer h.Close()
// author := h.newUser("alice")
// h.fund(author, 1_000_000) // give them tokens
// resp := h.publish(author, "Hello #world", nil) // POST /feed/publish
// h.commitCreatePost(author, resp) // chain tx
// got := h.getPost(resp.PostID)
// ...
package node
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"image"
"image/color"
"image/jpeg"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"go-blockchain/blockchain"
"go-blockchain/identity"
"go-blockchain/media"
"go-blockchain/relay"
)
// ── Harness ──────────────────────────────────────────────────────────────
type feedHarness struct {
t *testing.T
chainDir string
feedDir string
chain *blockchain.Chain
mailbox *relay.FeedMailbox
scrubber *media.Scrubber
server *httptest.Server
validator *identity.Identity
tip *blockchain.Block
}
func newFeedHarness(t *testing.T) *feedHarness {
t.Helper()
chainDir, err := os.MkdirTemp("", "dchain-e2e-chain-*")
if err != nil {
t.Fatalf("MkdirTemp chain: %v", err)
}
feedDir, err := os.MkdirTemp("", "dchain-e2e-feed-*")
if err != nil {
t.Fatalf("MkdirTemp feed: %v", err)
}
c, err := blockchain.NewChain(chainDir)
if err != nil {
t.Fatalf("NewChain: %v", err)
}
fm, err := relay.OpenFeedMailbox(feedDir, 24*time.Hour)
if err != nil {
t.Fatalf("OpenFeedMailbox: %v", err)
}
validator, err := identity.Generate()
if err != nil {
t.Fatalf("identity.Generate: %v", err)
}
// Bootstrap a genesis block so the validator has funds to disburse.
genesis := blockchain.GenesisBlock(validator.PubKeyHex(), validator.PrivKey)
if err := c.AddBlock(genesis); err != nil {
t.Fatalf("AddBlock genesis: %v", err)
}
scrubber := media.NewScrubber(media.SidecarConfig{}) // no sidecar — images only
cfg := FeedConfig{
Mailbox: fm,
HostingRelayPub: validator.PubKeyHex(),
Scrubber: scrubber,
AllowUnscrubbedVideo: false,
GetPost: c.Post,
LikeCount: c.LikeCount,
HasLiked: c.HasLiked,
PostsByAuthor: c.PostsByAuthor,
Following: c.Following,
}
mux := http.NewServeMux()
RegisterFeedRoutes(mux, cfg)
srv := httptest.NewServer(mux)
h := &feedHarness{
t: t, chainDir: chainDir, feedDir: feedDir,
chain: c, mailbox: fm, scrubber: scrubber,
server: srv, validator: validator, tip: genesis,
}
t.Cleanup(h.Close)
return h
}
// Close releases all handles and removes the temp directories. Safe to
// call multiple times.
func (h *feedHarness) Close() {
if h.server != nil {
h.server.Close()
h.server = nil
}
if h.mailbox != nil {
_ = h.mailbox.Close()
h.mailbox = nil
}
if h.chain != nil {
_ = h.chain.Close()
h.chain = nil
}
// Retry because Windows holds mmap files briefly after Close.
for _, dir := range []string{h.chainDir, h.feedDir} {
for i := 0; i < 20; i++ {
if err := os.RemoveAll(dir); err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
}
}
// newUser generates a fresh identity. Not funded — call fund() separately.
func (h *feedHarness) newUser(label string) *identity.Identity {
h.t.Helper()
id, err := identity.Generate()
if err != nil {
h.t.Fatalf("%s identity: %v", label, err)
}
return id
}
// fund sends `amount` µT from the genesis validator to `target`, committing
// the transfer in its own block.
func (h *feedHarness) fund(target *identity.Identity, amount uint64) {
h.t.Helper()
tx := &blockchain.Transaction{
ID: h.nextTxID(h.validator.PubKeyHex(), blockchain.EventTransfer),
Type: blockchain.EventTransfer,
From: h.validator.PubKeyHex(),
To: target.PubKeyHex(),
Amount: amount,
Fee: blockchain.MinFee,
Timestamp: time.Now().UTC(),
}
h.commit(tx)
}
// commit wraps one or more txs into a block, signs, and appends.
func (h *feedHarness) commit(txs ...*blockchain.Transaction) {
h.t.Helper()
// Small sleep to guarantee distinct tx IDs across calls.
time.Sleep(2 * time.Millisecond)
var totalFees uint64
for _, tx := range txs {
totalFees += tx.Fee
}
b := &blockchain.Block{
Index: h.tip.Index + 1,
Timestamp: time.Now().UTC(),
Transactions: txs,
PrevHash: h.tip.Hash,
Validator: h.validator.PubKeyHex(),
TotalFees: totalFees,
}
b.ComputeHash()
b.Sign(h.validator.PrivKey)
if err := h.chain.AddBlock(b); err != nil {
h.t.Fatalf("AddBlock: %v", err)
}
h.tip = b
}
func (h *feedHarness) nextTxID(from string, typ blockchain.EventType) string {
// Hash (from, type, now_nanos) for uniqueness.
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", from, typ, time.Now().UnixNano())))
return hex.EncodeToString(sum[:16])
}
// publish POSTs /feed/publish as `author` with signed request body. On
// success returns the server's response so the caller can commit the
// on-chain CREATE_POST with matching metadata.
func (h *feedHarness) publish(author *identity.Identity, content string, attachment []byte) feedPublishResponse {
h.t.Helper()
attachB64 := ""
attachMIME := ""
if len(attachment) > 0 {
attachB64 = base64.StdEncoding.EncodeToString(attachment)
attachMIME = "image/jpeg"
}
// Client-side hash matches the server's canonical bytes rule:
// publish:<post_id>:<sha256(content||attachment) hex>:<ts>
// The client knows its own attachment before any server-side scrub,
// so this is the hash over the "raw upload". The server recomputes
// over SCRUBBED bytes and returns that as content_hash — the client
// then uses server's number for CREATE_POST.
idHash := sha256.Sum256([]byte(fmt.Sprintf("%s-%d-%s",
author.PubKeyHex(), time.Now().UnixNano(), content)))
postID := hex.EncodeToString(idHash[:16])
// Build signature over CLIENT-side hash.
h256 := sha256.New()
h256.Write([]byte(content))
h256.Write(attachment)
clientHash := hex.EncodeToString(h256.Sum(nil))
ts := time.Now().Unix()
sigBytes := author.Sign([]byte(fmt.Sprintf("publish:%s:%s:%d", postID, clientHash, ts)))
req := feedPublishRequest{
PostID: postID,
Author: author.PubKeyHex(),
Content: content,
AttachmentB64: attachB64,
AttachmentMIME: attachMIME,
Sig: base64.StdEncoding.EncodeToString(sigBytes),
Ts: ts,
}
var resp feedPublishResponse
h.postJSON("/feed/publish", req, &resp)
return resp
}
// commitCreatePost sends the on-chain CREATE_POST tx that pays the
// hosting relay (this node's validator in the harness). Must be called
// after publish() so the two agree on the content hash and size.
func (h *feedHarness) commitCreatePost(author *identity.Identity, pub feedPublishResponse) {
h.t.Helper()
contentHash, err := hex.DecodeString(pub.ContentHash)
if err != nil {
h.t.Fatalf("decode content hash: %v", err)
}
payload := blockchain.CreatePostPayload{
PostID: pub.PostID,
ContentHash: contentHash,
Size: pub.Size,
HostingRelay: pub.HostingRelay,
}
pbytes, _ := json.Marshal(payload)
tx := &blockchain.Transaction{
ID: h.nextTxID(author.PubKeyHex(), blockchain.EventCreatePost),
Type: blockchain.EventCreatePost,
From: author.PubKeyHex(),
Amount: 0,
Fee: pub.EstimatedFeeUT,
Payload: pbytes,
Timestamp: time.Now().UTC(),
}
h.commit(tx)
}
// like / unlike / follow / unfollow helpers — all just small tx builders.
func (h *feedHarness) like(liker *identity.Identity, postID string) {
payload, _ := json.Marshal(blockchain.LikePostPayload{PostID: postID})
tx := &blockchain.Transaction{
ID: h.nextTxID(liker.PubKeyHex(), blockchain.EventLikePost),
Type: blockchain.EventLikePost,
From: liker.PubKeyHex(),
Fee: blockchain.MinFee,
Payload: payload,
Timestamp: time.Now().UTC(),
}
h.commit(tx)
}
func (h *feedHarness) follow(follower *identity.Identity, target string) {
tx := &blockchain.Transaction{
ID: h.nextTxID(follower.PubKeyHex(), blockchain.EventFollow),
Type: blockchain.EventFollow,
From: follower.PubKeyHex(),
To: target,
Fee: blockchain.MinFee,
Payload: []byte(`{}`),
Timestamp: time.Now().UTC(),
}
h.commit(tx)
}
// deletePost commits an on-chain EventDeletePost for the given post,
// signed by the author.
func (h *feedHarness) deletePost(author *identity.Identity, postID string) {
payload, _ := json.Marshal(blockchain.DeletePostPayload{PostID: postID})
tx := &blockchain.Transaction{
ID: h.nextTxID(author.PubKeyHex(), blockchain.EventDeletePost),
Type: blockchain.EventDeletePost,
From: author.PubKeyHex(),
Fee: blockchain.MinFee,
Payload: payload,
Timestamp: time.Now().UTC(),
}
h.commit(tx)
}
// ── HTTP helpers ──────────────────────────────────────────────────────────
func (h *feedHarness) postJSON(path string, req any, out any) {
h.t.Helper()
body, _ := json.Marshal(req)
resp, err := http.Post(h.server.URL+path, "application/json", bytes.NewReader(body))
if err != nil {
h.t.Fatalf("POST %s: %v", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
h.t.Fatalf("POST %s → %d: %s", path, resp.StatusCode, string(raw))
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
h.t.Fatalf("decode %s response: %v", path, err)
}
}
}
func (h *feedHarness) postJSONExpectStatus(path string, req any, want int) string {
h.t.Helper()
body, _ := json.Marshal(req)
resp, err := http.Post(h.server.URL+path, "application/json", bytes.NewReader(body))
if err != nil {
h.t.Fatalf("POST %s: %v", path, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != want {
h.t.Fatalf("POST %s → %d, want %d: %s", path, resp.StatusCode, want, string(raw))
}
return string(raw)
}
func (h *feedHarness) getJSON(path string, out any) {
h.t.Helper()
resp, err := http.Get(h.server.URL + path)
if err != nil {
h.t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
h.t.Fatalf("GET %s → %d: %s", path, resp.StatusCode, string(raw))
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
h.t.Fatalf("decode %s response: %v", path, err)
}
}
}
// getStatus fetches path and returns status + body; doesn't fail on non-2xx.
func (h *feedHarness) getStatus(path string) (int, string) {
resp, err := http.Get(h.server.URL + path)
if err != nil {
h.t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(raw)
}
// postRaw is for endpoints like /feed/post/{id}/view that take no body.
func (h *feedHarness) postRaw(path string, out any) {
h.t.Helper()
resp, err := http.Post(h.server.URL+path, "application/json", nil)
if err != nil {
h.t.Fatalf("POST %s: %v", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
h.t.Fatalf("POST %s → %d: %s", path, resp.StatusCode, string(raw))
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
h.t.Fatalf("decode %s response: %v", path, err)
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────
// TestE2EFullFlow runs the whole publish → commit → read cycle end-to-end.
//
// Covers: /feed/publish signature, /feed/post/{id} body fetch, /feed/post/{id}/stats,
// /feed/post/{id}/view counter, CREATE_POST fee debit to author + credit to
// hosting relay, PostsByAuthor enrichment, DELETE soft-delete → 410.
func TestE2EFullFlow(t *testing.T) {
h := newFeedHarness(t)
alice := h.newUser("alice")
h.fund(alice, 10*blockchain.Token)
hostBalBefore, _ := h.chain.Balance(h.validator.PubKeyHex())
// 1. PUBLISH → body lands in feed mailbox.
pub := h.publish(alice, "Hello from the feed #dchain #intro", nil)
if pub.PostID == "" || pub.ContentHash == "" {
t.Fatalf("publish response missing required fields: %+v", pub)
}
if pub.HostingRelay != h.validator.PubKeyHex() {
t.Errorf("hosting_relay: got %s, want %s", pub.HostingRelay, h.validator.PubKeyHex())
}
wantTags := []string{"dchain", "intro"}
if len(pub.Hashtags) != len(wantTags) {
t.Errorf("hashtags: got %v, want %v", pub.Hashtags, wantTags)
}
// Before the CREATE_POST tx lands the body is available but /stats
// says 0 likes. That's the expected "just published, not committed" state.
// 2. COMMIT on-chain CREATE_POST tx.
h.commitCreatePost(alice, pub)
// Hosting relay should have been credited tx.Fee.
hostBalAfter, _ := h.chain.Balance(h.validator.PubKeyHex())
if hostBalAfter <= hostBalBefore {
t.Errorf("hosting relay balance did not increase after CREATE_POST: %d → %d",
hostBalBefore, hostBalAfter)
}
// 3. READ via HTTP — body comes back.
var got struct {
PostID string `json:"post_id"`
Author string `json:"author"`
Content string `json:"content"`
}
h.getJSON("/feed/post/"+pub.PostID, &got)
if got.Content != "Hello from the feed #dchain #intro" {
t.Errorf("content: got %q, want original", got.Content)
}
if got.Author != alice.PubKeyHex() {
t.Errorf("author: got %s, want %s", got.Author, alice.PubKeyHex())
}
// 4. VIEW COUNTER increments.
var viewResp struct {
Views uint64 `json:"views"`
}
for i := 1; i <= 3; i++ {
h.postRaw("/feed/post/"+pub.PostID+"/view", &viewResp)
if viewResp.Views != uint64(i) {
t.Errorf("views #%d: got %d, want %d", i, viewResp.Views, i)
}
}
// 5. STATS aggregate is correct.
var stats postStatsResponse
h.getJSON("/feed/post/"+pub.PostID+"/stats", &stats)
if stats.Views != 3 {
t.Errorf("stats.views: got %d, want 3", stats.Views)
}
if stats.Likes != 0 {
t.Errorf("stats.likes: got %d, want 0", stats.Likes)
}
// 6. AUTHOR listing merges chain record + body + stats.
var authorResp struct {
Count int `json:"count"`
Posts []feedAuthorItem `json:"posts"`
}
h.getJSON("/feed/author/"+alice.PubKeyHex(), &authorResp)
if authorResp.Count != 1 {
t.Fatalf("author count: got %d, want 1", authorResp.Count)
}
if authorResp.Posts[0].Views != 3 {
t.Errorf("author post views: got %d, want 3", authorResp.Posts[0].Views)
}
if len(authorResp.Posts[0].Hashtags) != 2 {
t.Errorf("author post hashtags: got %v, want 2", authorResp.Posts[0].Hashtags)
}
// 7. DELETE → body stays in mailbox but chain marks deleted → 410 on fetch.
h.deletePost(alice, pub.PostID)
status, body := h.getStatus("/feed/post/" + pub.PostID)
if status != http.StatusGone {
t.Errorf("GET deleted post: got status %d, want 410; body: %s", status, body)
}
}
// TestE2ELikeUnlikeAffectsStats: on-chain LIKE_POST updates /stats.
func TestE2ELikeUnlikeAffectsStats(t *testing.T) {
h := newFeedHarness(t)
alice := h.newUser("alice")
bob := h.newUser("bob")
h.fund(alice, 10*blockchain.Token)
h.fund(bob, 10*blockchain.Token)
pub := h.publish(alice, "likeable", nil)
h.commitCreatePost(alice, pub)
// Bob likes alice's post.
h.like(bob, pub.PostID)
var stats postStatsResponse
h.getJSON("/feed/post/"+pub.PostID+"/stats?me="+bob.PubKeyHex(), &stats)
if stats.Likes != 1 {
t.Errorf("likes after like: got %d, want 1", stats.Likes)
}
if stats.LikedByMe == nil || !*stats.LikedByMe {
t.Errorf("liked_by_me: got %v, want true", stats.LikedByMe)
}
// And a non-liker sees liked_by_me=false.
carol := h.newUser("carol")
h.getJSON("/feed/post/"+pub.PostID+"/stats?me="+carol.PubKeyHex(), &stats)
if stats.LikedByMe == nil || *stats.LikedByMe {
t.Errorf("liked_by_me for carol: got %v, want false", stats.LikedByMe)
}
}
// TestE2ETimeline: follow graph merges posts newest-first.
func TestE2ETimeline(t *testing.T) {
h := newFeedHarness(t)
alice := h.newUser("alice")
bob := h.newUser("bob")
carol := h.newUser("carol")
// Fund everyone.
for _, u := range []*identity.Identity{alice, bob, carol} {
h.fund(u, 10*blockchain.Token)
}
// Alice follows bob + carol.
h.follow(alice, bob.PubKeyHex())
h.follow(alice, carol.PubKeyHex())
// Bob + carol each publish a post. Sleep 1.1s between so the tx
// timestamps land in distinct unix seconds — the chain chrono index
// is second-resolution, not millisecond.
pubBob := h.publish(bob, "post from bob", nil)
h.commitCreatePost(bob, pubBob)
time.Sleep(1100 * time.Millisecond)
pubCarol := h.publish(carol, "post from carol", nil)
h.commitCreatePost(carol, pubCarol)
var tl struct {
Count int `json:"count"`
Posts []feedAuthorItem `json:"posts"`
}
h.getJSON("/feed/timeline?follower="+alice.PubKeyHex(), &tl)
if tl.Count != 2 {
t.Fatalf("timeline count: got %d, want 2", tl.Count)
}
// Newest first — carol was published last, so her post should be [0].
if tl.Posts[0].PostID != pubCarol.PostID {
t.Errorf("timeline[0]: got %s, want carol's post %s", tl.Posts[0].PostID, pubCarol.PostID)
}
if tl.Posts[1].PostID != pubBob.PostID {
t.Errorf("timeline[1]: got %s, want bob's post %s", tl.Posts[1].PostID, pubBob.PostID)
}
}
// TestE2ETrendingRanking: post with more engagement floats to the top.
func TestE2ETrendingRanking(t *testing.T) {
h := newFeedHarness(t)
alice := h.newUser("alice")
bob := h.newUser("bob")
carol := h.newUser("carol")
for _, u := range []*identity.Identity{alice, bob, carol} {
h.fund(u, 10*blockchain.Token)
}
lowPost := h.publish(alice, "low-engagement post", nil)
h.commitCreatePost(alice, lowPost)
hotPost := h.publish(alice, "hot post", nil)
h.commitCreatePost(alice, hotPost)
// Hot post gets 2 likes + 5 views; low post stays at 0.
h.like(bob, hotPost.PostID)
h.like(carol, hotPost.PostID)
var viewResp struct{ Views uint64 }
for i := 0; i < 5; i++ {
h.postRaw("/feed/post/"+hotPost.PostID+"/view", &viewResp)
}
var tr struct {
Count int `json:"count"`
Posts []feedAuthorItem `json:"posts"`
}
h.getJSON("/feed/trending?limit=10", &tr)
if tr.Count < 2 {
t.Fatalf("trending: got %d posts, want ≥2", tr.Count)
}
// Hot post MUST be first (likes × 3 + views = 11 vs 0).
if tr.Posts[0].PostID != hotPost.PostID {
t.Errorf("trending[0]: got %s, want hot post %s", tr.Posts[0].PostID, hotPost.PostID)
}
}
// TestE2EForYouFilters: recommendations exclude followed authors,
// already-liked posts, and the user's own posts.
func TestE2EForYouFilters(t *testing.T) {
h := newFeedHarness(t)
alice := h.newUser("alice") // asking for recs
bob := h.newUser("bob") // alice follows bob → bob's posts excluded
carol := h.newUser("carol") // stranger → should surface
dave := h.newUser("dave") // post liked by alice → excluded
for _, u := range []*identity.Identity{alice, bob, carol, dave} {
h.fund(u, 10*blockchain.Token)
}
// Alice follows bob.
h.follow(alice, bob.PubKeyHex())
// Each non-alice user publishes a post, plus alice herself.
postOwn := h.publish(alice, "my own post", nil)
h.commitCreatePost(alice, postOwn)
postBob := h.publish(bob, "from bob (followed)", nil)
h.commitCreatePost(bob, postBob)
postCarol := h.publish(carol, "from carol (stranger)", nil)
h.commitCreatePost(carol, postCarol)
postDave := h.publish(dave, "from dave", nil)
h.commitCreatePost(dave, postDave)
// Alice likes dave's post — so it should NOT appear in her ForYou.
h.like(alice, postDave.PostID)
var fy struct {
Count int `json:"count"`
Posts []feedAuthorItem `json:"posts"`
}
h.getJSON("/feed/foryou?pub="+alice.PubKeyHex()+"&limit=20", &fy)
// Expected: only carol's post. The others are excluded.
seen := map[string]bool{}
for _, p := range fy.Posts {
seen[p.PostID] = true
}
if seen[postOwn.PostID] {
t.Errorf("ForYou included alice's own post %s", postOwn.PostID)
}
if seen[postBob.PostID] {
t.Errorf("ForYou included followed author bob's post %s", postBob.PostID)
}
if seen[postDave.PostID] {
t.Errorf("ForYou included already-liked post from dave %s", postDave.PostID)
}
if !seen[postCarol.PostID] {
t.Errorf("ForYou missing carol's post %s (should surface)", postCarol.PostID)
}
}
// TestE2EHashtagSearch: a tag returns only posts that used it.
func TestE2EHashtagSearch(t *testing.T) {
h := newFeedHarness(t)
alice := h.newUser("alice")
h.fund(alice, 10*blockchain.Token)
goPost := h.publish(alice, "learning #golang today", nil)
h.commitCreatePost(alice, goPost)
rustPost := h.publish(alice, "later — #rust", nil)
h.commitCreatePost(alice, rustPost)
untagged := h.publish(alice, "no tags", nil)
h.commitCreatePost(alice, untagged)
var tag struct {
Tag string `json:"tag"`
Count int `json:"count"`
Posts []feedAuthorItem `json:"posts"`
}
h.getJSON("/feed/hashtag/golang", &tag)
if tag.Count != 1 || tag.Posts[0].PostID != goPost.PostID {
t.Errorf("hashtag(golang): got %+v, want [%s]", tag, goPost.PostID)
}
h.getJSON("/feed/hashtag/rust", &tag)
if tag.Count != 1 || tag.Posts[0].PostID != rustPost.PostID {
t.Errorf("hashtag(rust): got %+v, want [%s]", tag, rustPost.PostID)
}
}
// TestE2EScrubberStripsEXIF: uploaded image with EXIF canary comes back
// without the canary in the stored body. Proves server-side scrub is
// mandatory and working at the HTTP boundary.
func TestE2EScrubberStripsEXIF(t *testing.T) {
h := newFeedHarness(t)
alice := h.newUser("alice")
h.fund(alice, 1*blockchain.Token)
// Build a JPEG with an injected EXIF segment containing a canary.
var jpegBuf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 16, 16))
for y := 0; y < 16; y++ {
for x := 0; x < 16; x++ {
img.Set(x, y, color.RGBA{uint8(x * 16), uint8(y * 16), 100, 255})
}
}
if err := jpeg.Encode(&jpegBuf, img, &jpeg.Options{Quality: 80}); err != nil {
t.Fatalf("jpeg encode: %v", err)
}
withEXIF := injectEXIFSegment(t, jpegBuf.Bytes(),
"SUPERSECRETGPS-51.5N-0.1W-iPhone-Serial-A1B2C3")
// Pre-flight: the upload bytes DO contain the canary.
if !bytes.Contains(withEXIF, []byte("SUPERSECRETGPS")) {
t.Fatalf("test setup: canary not injected")
}
pub := h.publish(alice, "look at this photo", withEXIF)
if pub.PostID == "" {
t.Fatalf("publish failed")
}
h.commitCreatePost(alice, pub)
// Fetch the stored body back. The attachment field is the cleaned bytes.
var fetched struct {
Attachment string `json:"attachment"` // base64
}
h.getJSON("/feed/post/"+pub.PostID, &fetched)
if fetched.Attachment == "" {
t.Fatalf("attachment not returned")
}
decoded, err := base64.StdEncoding.DecodeString(fetched.Attachment)
if err != nil {
t.Fatalf("decode attachment: %v", err)
}
if bytes.Contains(decoded, []byte("SUPERSECRETGPS")) {
t.Errorf("CRITICAL: EXIF canary survived server-side scrub — metadata leaked")
}
// Sanity: still a valid JPEG after scrub.
if _, err := jpeg.Decode(bytes.NewReader(decoded)); err != nil {
t.Errorf("scrubbed attachment is not a valid JPEG: %v", err)
}
}
// TestE2ERejectsMIMEMismatch: claimed MIME vs magic bytes.
func TestE2ERejectsMIMEMismatch(t *testing.T) {
h := newFeedHarness(t)
alice := h.newUser("alice")
h.fund(alice, 1*blockchain.Token)
// Build a PNG but claim it's a JPEG.
fake := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n',
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
ts := time.Now().Unix()
postID := "mimecheck"
hash := sha256.Sum256(append([]byte("mislabel"), fake...))
sig := alice.Sign([]byte(fmt.Sprintf("publish:%s:%s:%d",
postID, hex.EncodeToString(hash[:]), ts)))
req := feedPublishRequest{
PostID: postID,
Author: alice.PubKeyHex(),
Content: "mislabel",
AttachmentB64: base64.StdEncoding.EncodeToString(fake),
AttachmentMIME: "image/jpeg", // LIE — it's PNG magic
Sig: base64.StdEncoding.EncodeToString(sig),
Ts: ts,
}
h.postJSONExpectStatus("/feed/publish", req, http.StatusBadRequest)
}
// TestE2ERejectsBadSignature: wrong signer cannot publish.
func TestE2ERejectsBadSignature(t *testing.T) {
h := newFeedHarness(t)
alice := h.newUser("alice")
eve := h.newUser("eve")
h.fund(alice, 1*blockchain.Token)
h.fund(eve, 1*blockchain.Token)
ts := time.Now().Unix()
postID := "forgery"
hash := sha256.Sum256([]byte("evil"))
// Eve signs over data but claims to be alice.
sig := eve.Sign([]byte(fmt.Sprintf("publish:%s:%s:%d",
postID, hex.EncodeToString(hash[:]), ts)))
req := feedPublishRequest{
PostID: postID,
Author: alice.PubKeyHex(), // claim alice
Content: "evil",
Sig: base64.StdEncoding.EncodeToString(sig),
Ts: ts,
}
h.postJSONExpectStatus("/feed/publish", req, http.StatusForbidden)
}
// TestE2ERejectsStaleTimestamp: publish with ts way in the past must be rejected.
func TestE2ERejectsStaleTimestamp(t *testing.T) {
h := newFeedHarness(t)
alice := h.newUser("alice")
h.fund(alice, 1*blockchain.Token)
ts := time.Now().Add(-1 * time.Hour).Unix() // 1 hour stale
postID := "stale"
hash := sha256.Sum256([]byte("old"))
sig := alice.Sign([]byte(fmt.Sprintf("publish:%s:%s:%d",
postID, hex.EncodeToString(hash[:]), ts)))
req := feedPublishRequest{
PostID: postID,
Author: alice.PubKeyHex(),
Content: "old",
Sig: base64.StdEncoding.EncodeToString(sig),
Ts: ts,
}
h.postJSONExpectStatus("/feed/publish", req, http.StatusBadRequest)
}
// injectEXIFSegment splices an APP1 EXIF segment with the given canary
// string into a JPEG. Mirrors media/scrub_test.go but local to keep the
// integration test self-contained.
func injectEXIFSegment(t *testing.T, src []byte, canary string) []byte {
t.Helper()
if len(src) < 2 || src[0] != 0xFF || src[1] != 0xD8 {
t.Fatalf("not a JPEG")
}
payload := []byte("Exif\x00\x00" + canary)
segLen := len(payload) + 2
out := make([]byte, 0, len(src)+segLen+4)
out = append(out, src[0], src[1]) // SOI
out = append(out, 0xFF, 0xE1, byte(segLen>>8), byte(segLen&0xff))
out = append(out, payload...)
out = append(out, src[2:]...)
return out
}
// Silence unused-import lint if strings gets trimmed by refactor.
var _ = strings.TrimSpace
var _ = context.TODO

504
node/feed_twonode_test.go Normal file
View File

@@ -0,0 +1,504 @@
// Two-node simulation: verifies that a post published on Node A is
// discoverable and fetchable from Node B after block propagation.
//
// The real network uses libp2p gossipsub for blocks + an HTTP pull
// fallback. For tests we simulate gossip by manually calling chain.AddBlock
// on both nodes with the same block — identical to what each node does
// after receiving a peer's gossiped block in production.
//
// Body ownership: only the HOSTING relay has the post body in its
// feed mailbox. Readers on OTHER nodes see the on-chain record
// (hosting_relay pubkey, content hash, size, author) and fetch the
// body directly from the hosting node over HTTP. That's the design —
// storage costs don't get amortised across the whole network, the
// author pays one node to host, and the public reads from that one
// node (or from replicas if/when we add post pinning in v3.0.0).
package node
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"go-blockchain/blockchain"
"go-blockchain/identity"
"go-blockchain/media"
"go-blockchain/relay"
)
// twoNodeHarness wires two independent chain+feed instances sharing a
// single block history (simulated gossip). Node A is the "hosting"
// relay; Node B is the reader.
type twoNodeHarness struct {
t *testing.T
aChainDir, aFeedDir string
bChainDir, bFeedDir string
aChain, bChain *blockchain.Chain
aMailbox, bMailbox *relay.FeedMailbox
aServer, bServer *httptest.Server
aHostPub string
bHostPub string
validator *identity.Identity
tipIndex uint64
tipHash []byte
}
func newTwoNodeHarness(t *testing.T) *twoNodeHarness {
t.Helper()
mkdir := func(prefix string) string {
d, err := os.MkdirTemp("", prefix)
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
return d
}
h := &twoNodeHarness{
t: t,
aChainDir: mkdir("dchain-2n-chainA-*"),
aFeedDir: mkdir("dchain-2n-feedA-*"),
bChainDir: mkdir("dchain-2n-chainB-*"),
bFeedDir: mkdir("dchain-2n-feedB-*"),
}
var err error
h.aChain, err = blockchain.NewChain(h.aChainDir)
if err != nil {
t.Fatalf("chain A: %v", err)
}
h.bChain, err = blockchain.NewChain(h.bChainDir)
if err != nil {
t.Fatalf("chain B: %v", err)
}
h.aMailbox, err = relay.OpenFeedMailbox(h.aFeedDir, 24*time.Hour)
if err != nil {
t.Fatalf("feed A: %v", err)
}
h.bMailbox, err = relay.OpenFeedMailbox(h.bFeedDir, 24*time.Hour)
if err != nil {
t.Fatalf("feed B: %v", err)
}
h.validator, err = identity.Generate()
if err != nil {
t.Fatalf("validator: %v", err)
}
// Both nodes start from the same genesis — the single bootstrap
// validator allocates the initial supply. In production this is
// hardcoded; in tests we just generate and use it to sign blocks
// on both chains.
genesis := blockchain.GenesisBlock(h.validator.PubKeyHex(), h.validator.PrivKey)
if err := h.aChain.AddBlock(genesis); err != nil {
t.Fatalf("A genesis: %v", err)
}
if err := h.bChain.AddBlock(genesis); err != nil {
t.Fatalf("B genesis: %v", err)
}
h.tipIndex = genesis.Index
h.tipHash = genesis.Hash
// Node A hosts; Node B is a pure reader (no host_pub of its own that
// anyone publishes to). They share a single validator because this
// test isn't about consensus — it's about chain state propagation.
h.aHostPub = h.validator.PubKeyHex()
// Node B uses a separate identity purely for its hosting_relay field
// (never actually hosts anything in this scenario). Distinguishes A
// from B in balance assertions.
idB, _ := identity.Generate()
h.bHostPub = idB.PubKeyHex()
scrubber := media.NewScrubber(media.SidecarConfig{})
aCfg := FeedConfig{
Mailbox: h.aMailbox,
HostingRelayPub: h.aHostPub,
Scrubber: scrubber,
GetPost: h.aChain.Post,
LikeCount: h.aChain.LikeCount,
HasLiked: h.aChain.HasLiked,
PostsByAuthor: h.aChain.PostsByAuthor,
Following: h.aChain.Following,
}
bCfg := FeedConfig{
Mailbox: h.bMailbox,
HostingRelayPub: h.bHostPub,
Scrubber: scrubber,
GetPost: h.bChain.Post,
LikeCount: h.bChain.LikeCount,
HasLiked: h.bChain.HasLiked,
PostsByAuthor: h.bChain.PostsByAuthor,
Following: h.bChain.Following,
}
muxA := http.NewServeMux()
RegisterFeedRoutes(muxA, aCfg)
h.aServer = httptest.NewServer(muxA)
muxB := http.NewServeMux()
RegisterFeedRoutes(muxB, bCfg)
h.bServer = httptest.NewServer(muxB)
t.Cleanup(h.Close)
return h
}
func (h *twoNodeHarness) Close() {
if h.aServer != nil {
h.aServer.Close()
}
if h.bServer != nil {
h.bServer.Close()
}
if h.aMailbox != nil {
_ = h.aMailbox.Close()
}
if h.bMailbox != nil {
_ = h.bMailbox.Close()
}
if h.aChain != nil {
_ = h.aChain.Close()
}
if h.bChain != nil {
_ = h.bChain.Close()
}
for _, dir := range []string{h.aChainDir, h.aFeedDir, h.bChainDir, h.bFeedDir} {
for i := 0; i < 20; i++ {
if err := os.RemoveAll(dir); err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
}
}
// gossipBlock simulates libp2p block propagation: same block applied to
// both chains. In production, AddBlock is called on each peer after the
// gossipsub message arrives — no chain-level difference from the direct
// call here.
func (h *twoNodeHarness) gossipBlock(txs ...*blockchain.Transaction) {
h.t.Helper()
time.Sleep(2 * time.Millisecond) // distinct tx IDs
var totalFees uint64
for _, tx := range txs {
totalFees += tx.Fee
}
b := &blockchain.Block{
Index: h.tipIndex + 1,
Timestamp: time.Now().UTC(),
Transactions: txs,
PrevHash: h.tipHash,
Validator: h.validator.PubKeyHex(),
TotalFees: totalFees,
}
b.ComputeHash()
b.Sign(h.validator.PrivKey)
if err := h.aChain.AddBlock(b); err != nil {
h.t.Fatalf("A AddBlock: %v", err)
}
if err := h.bChain.AddBlock(b); err != nil {
h.t.Fatalf("B AddBlock: %v", err)
}
h.tipIndex = b.Index
h.tipHash = b.Hash
}
func (h *twoNodeHarness) nextTxID(from string, typ blockchain.EventType) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", from, typ, time.Now().UnixNano())))
return hex.EncodeToString(sum[:16])
}
// fundAB transfers from validator → target, propagated to both chains.
func (h *twoNodeHarness) fundAB(target *identity.Identity, amount uint64) {
tx := &blockchain.Transaction{
ID: h.nextTxID(h.validator.PubKeyHex(), blockchain.EventTransfer),
Type: blockchain.EventTransfer,
From: h.validator.PubKeyHex(),
To: target.PubKeyHex(),
Amount: amount,
Fee: blockchain.MinFee,
Timestamp: time.Now().UTC(),
}
h.gossipBlock(tx)
}
// publishOnA uploads body to A's feed mailbox (only A gets the body) and
// gossips the CREATE_POST tx to both chains (both see the metadata).
func (h *twoNodeHarness) publishOnA(author *identity.Identity, content string) feedPublishResponse {
h.t.Helper()
idHash := sha256.Sum256([]byte(fmt.Sprintf("%s-%d-%s",
author.PubKeyHex(), time.Now().UnixNano(), content)))
postID := hex.EncodeToString(idHash[:16])
clientHasher := sha256.New()
clientHasher.Write([]byte(content))
clientHash := hex.EncodeToString(clientHasher.Sum(nil))
ts := time.Now().Unix()
sig := author.Sign([]byte(fmt.Sprintf("publish:%s:%s:%d", postID, clientHash, ts)))
req := feedPublishRequest{
PostID: postID,
Author: author.PubKeyHex(),
Content: content,
Sig: base64.StdEncoding.EncodeToString(sig),
Ts: ts,
}
body, _ := json.Marshal(req)
resp, err := http.Post(h.aServer.URL+"/feed/publish", "application/json", strings.NewReader(string(body)))
if err != nil {
h.t.Fatalf("publish on A: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
h.t.Fatalf("publish on A → %d: %s", resp.StatusCode, string(raw))
}
var out feedPublishResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
h.t.Fatalf("decode publish: %v", err)
}
// Now the ON-CHAIN CREATE_POST tx — gossiped to both nodes.
contentHash, _ := hex.DecodeString(out.ContentHash)
payload, _ := json.Marshal(blockchain.CreatePostPayload{
PostID: out.PostID,
ContentHash: contentHash,
Size: out.Size,
HostingRelay: out.HostingRelay,
})
tx := &blockchain.Transaction{
ID: h.nextTxID(author.PubKeyHex(), blockchain.EventCreatePost),
Type: blockchain.EventCreatePost,
From: author.PubKeyHex(),
Fee: out.EstimatedFeeUT,
Payload: payload,
Timestamp: time.Now().UTC(),
}
h.gossipBlock(tx)
return out
}
// likeOnB submits a LIKE_POST tx originating on Node B (simulates a
// follower using their own node). Both chains receive the block.
func (h *twoNodeHarness) likeOnB(liker *identity.Identity, postID string) {
payload, _ := json.Marshal(blockchain.LikePostPayload{PostID: postID})
tx := &blockchain.Transaction{
ID: h.nextTxID(liker.PubKeyHex(), blockchain.EventLikePost),
Type: blockchain.EventLikePost,
From: liker.PubKeyHex(),
Fee: blockchain.MinFee,
Payload: payload,
Timestamp: time.Now().UTC(),
}
h.gossipBlock(tx)
}
// getBodyFromA fetches /feed/post/{id} from Node A's HTTP server.
func (h *twoNodeHarness) getBodyFromA(postID string) (int, []byte) {
h.t.Helper()
resp, err := http.Get(h.aServer.URL + "/feed/post/" + postID)
if err != nil {
h.t.Fatalf("GET A: %v", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, raw
}
// getBodyFromB same for Node B.
func (h *twoNodeHarness) getBodyFromB(postID string) (int, []byte) {
h.t.Helper()
resp, err := http.Get(h.bServer.URL + "/feed/post/" + postID)
if err != nil {
h.t.Fatalf("GET B: %v", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, raw
}
// ── Tests ─────────────────────────────────────────────────────────────────
// TestTwoNodePostPropagation: Alice publishes on Node A. After block
// propagation, both chains have the record. Node B can read the
// on-chain metadata directly, and can fetch the body from Node A (the
// hosting relay) — which is what the client does in production.
func TestTwoNodePostPropagation(t *testing.T) {
h := newTwoNodeHarness(t)
alice, _ := identity.Generate()
h.fundAB(alice, 10*blockchain.Token)
pub := h.publishOnA(alice, "hello from node A")
// Node A chain has the record.
recA, err := h.aChain.Post(pub.PostID)
if err != nil || recA == nil {
t.Fatalf("A chain.Post: %v (rec=%v)", err, recA)
}
// Node B chain also has the record — propagation successful.
recB, err := h.bChain.Post(pub.PostID)
if err != nil || recB == nil {
t.Fatalf("B chain.Post: %v (rec=%v)", err, recB)
}
if recA.PostID != recB.PostID || recA.Author != recB.Author {
t.Errorf("chains disagree: A=%+v B=%+v", recA, recB)
}
if recB.HostingRelay != h.aHostPub {
t.Errorf("B sees hosting_relay=%s, want A's pub=%s", recB.HostingRelay, h.aHostPub)
}
// Node A HTTP serves the body.
statusA, _ := h.getBodyFromA(pub.PostID)
if statusA != http.StatusOK {
t.Errorf("A GET: status %d, want 200", statusA)
}
// Node B HTTP does NOT have the body — body only lives on the hosting
// relay. This is by design: the reader client on Node B would read
// chain.Post(id).HostingRelay, look up its URL via /api/relays, and
// fetch directly from Node A. Tested by the next assertion.
statusB, _ := h.getBodyFromB(pub.PostID)
if statusB != http.StatusNotFound {
t.Errorf("B GET: status %d, want 404 (body lives only on hosting relay)", statusB)
}
// Simulate the client routing step: use chain record from B to find
// hosting relay, then fetch from A.
hosting := recB.HostingRelay
if hosting != h.aHostPub {
t.Fatalf("hosting not A: %s", hosting)
}
// In production: look up hosting's URL via /api/relays. Here we
// already know it = h.aServer.URL. Just verify the fetch works.
statusCross, bodyCross := h.getBodyFromA(pub.PostID)
if statusCross != http.StatusOK {
t.Fatalf("cross-node fetch: status %d", statusCross)
}
var fetched struct {
Content string `json:"content"`
Author string `json:"author"`
}
if err := json.Unmarshal(bodyCross, &fetched); err != nil {
t.Fatalf("decode cross-node body: %v", err)
}
if fetched.Content != "hello from node A" {
t.Errorf("cross-node content: got %q", fetched.Content)
}
}
// TestTwoNodeLikeCounterSharedAcrossNodes: a like submitted with tx
// origin on Node B bumps the on-chain counter — which Node A's HTTP
// /stats then reflects. Demonstrates that engagement aggregates are
// consistent across the mesh because they live on the chain, not in
// any single relay's memory.
func TestTwoNodeLikeCounterSharedAcrossNodes(t *testing.T) {
h := newTwoNodeHarness(t)
alice, _ := identity.Generate()
bob, _ := identity.Generate()
h.fundAB(alice, 10*blockchain.Token)
h.fundAB(bob, 10*blockchain.Token)
pub := h.publishOnA(alice, "content for engagement test")
h.likeOnB(bob, pub.PostID)
// A's HTTP stats (backed by its chain.LikeCount) should see the like.
resp, err := http.Get(h.aServer.URL + "/feed/post/" + pub.PostID + "/stats")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var stats postStatsResponse
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
t.Fatal(err)
}
if stats.Likes != 1 {
t.Errorf("A /stats: got %d likes, want 1", stats.Likes)
}
// Same for B.
resp, err = http.Get(h.bServer.URL + "/feed/post/" + pub.PostID + "/stats")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
t.Fatal(err)
}
if stats.Likes != 1 {
t.Errorf("B /stats: got %d likes, want 1", stats.Likes)
}
}
// TestTwoNodeFollowGraphReplicates: FOLLOW tx on any node propagates to
// both chains; B's /feed/timeline returns A-hosted posts correctly.
func TestTwoNodeFollowGraphReplicates(t *testing.T) {
h := newTwoNodeHarness(t)
alice, _ := identity.Generate() // will follow bob
bob, _ := identity.Generate() // author
h.fundAB(alice, 10*blockchain.Token)
h.fundAB(bob, 10*blockchain.Token)
// Alice follows Bob (tx gossiped to both nodes).
followTx := &blockchain.Transaction{
ID: h.nextTxID(alice.PubKeyHex(), blockchain.EventFollow),
Type: blockchain.EventFollow,
From: alice.PubKeyHex(),
To: bob.PubKeyHex(),
Fee: blockchain.MinFee,
Payload: []byte(`{}`),
Timestamp: time.Now().UTC(),
}
h.gossipBlock(followTx)
// Bob publishes on A. Alice queries timeline on B.
bobPost := h.publishOnA(bob, "bob speaks")
// Alice's timeline on Node B should include Bob's post (metadata
// lives on chain). Body fetch would go to A, but /timeline only
// returns the enriched record — which DOES include body content
// because B's feed_mailbox doesn't have it... hmm.
//
// Actually this reveals a limitation: /feed/timeline on B merges
// chain records (available) with local mailbox bodies (missing).
// The body parts of the response will be empty. For the e2e test we
// just verify the metadata is there — the client is expected to
// resolve bodies separately via the hosting_relay URL.
resp, err := http.Get(h.bServer.URL + "/feed/timeline?follower=" + alice.PubKeyHex())
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var tl struct {
Count int `json:"count"`
Posts []feedAuthorItem `json:"posts"`
}
if err := json.NewDecoder(resp.Body).Decode(&tl); err != nil {
t.Fatal(err)
}
if tl.Count != 1 {
t.Fatalf("B timeline count: got %d, want 1", tl.Count)
}
if tl.Posts[0].PostID != bobPost.PostID {
t.Errorf("B timeline[0]: got %s, want %s", tl.Posts[0].PostID, bobPost.PostID)
}
// Metadata must be correct even if body is empty on B.
if tl.Posts[0].Author != bob.PubKeyHex() {
t.Errorf("B timeline[0].author: got %s, want %s", tl.Posts[0].Author, bob.PubKeyHex())
}
if tl.Posts[0].HostingRelay != h.aHostPub {
t.Errorf("B timeline[0].hosting_relay: got %s, want A (%s)", tl.Posts[0].HostingRelay, h.aHostPub)
}
// Body is intentionally empty on B (A hosts it). Verify.
if tl.Posts[0].Content != "" {
t.Errorf("B timeline[0].content: got %q, want empty (body lives on A)", tl.Posts[0].Content)
}
}

View File

@@ -521,13 +521,17 @@ func (h *WSHub) authorizeSubscribe(c *wsClient, topic string) error {
if authed == "" {
return fmt.Errorf("inbox:* requires auth")
}
// If we have an x25519 mapping, enforce it; otherwise accept
// (best-effort — identity may not be registered yet).
if authX != "" {
want := strings.TrimPrefix(topic, "inbox:")
if want != authX {
return fmt.Errorf("inbox:* only for your own x25519")
}
// Hard-require a registered X25519 identity — otherwise an
// Ed25519-only identity could subscribe to ANY inbox topic by
// design (authX == "" skipped the equality check). Fixed: we
// now refuse the subscription until the client publishes an
// X25519 key via REGISTER_KEY.
if authX == "" {
return fmt.Errorf("inbox:* requires a registered X25519 identity (send REGISTER_KEY first)")
}
want := strings.TrimPrefix(topic, "inbox:")
if want != authX {
return fmt.Errorf("inbox:* only for your own x25519")
}
return nil
}
@@ -536,11 +540,12 @@ func (h *WSHub) authorizeSubscribe(c *wsClient, topic string) error {
if authed == "" {
return fmt.Errorf("typing:* requires auth")
}
if authX != "" {
want := strings.TrimPrefix(topic, "typing:")
if want != authX {
return fmt.Errorf("typing:* only for your own x25519")
}
if authX == "" {
return fmt.Errorf("typing:* requires a registered X25519 identity")
}
want := strings.TrimPrefix(topic, "typing:")
if want != authX {
return fmt.Errorf("typing:* only for your own x25519")
}
return nil
}

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