diff --git a/.gitignore b/.gitignore index 9d0821e..4191450 100644 --- a/.gitignore +++ b/.gitignore @@ -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. diff --git a/README.md b/README.md index 6f6271d..bf35c7c 100644 --- a/README.md +++ b/README.md @@ -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=` | Прочитать входящие конверты | +| `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=` | `{views, likes, liked_by_me?}` | +| `POST /feed/post/{id}/view` | Бамп off-chain счётчика просмотров | +| `GET /feed/author/{pub}?before=&limit=N` | Посты автора (пагинация `before`) | +| `GET /feed/timeline?follower=&before=&limit=N` | Merged лента подписок | +| `GET /feed/trending?window=24&limit=N` | Топ по `likes × 3 + views` за окно | +| `GET /feed/foryou?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 спека. diff --git a/blockchain/chain.go b/blockchain/chain.go index ace73af..71b8852 100644 --- a/blockchain/chain.go +++ b/blockchain/chain.go @@ -40,13 +40,20 @@ const ( prefixHeight = "height" // height → uint64 prefixBalance = "balance:" // balance: → uint64 prefixIdentity = "id:" // id: → RegisterKeyPayload JSON - prefixChannel = "chan:" // chan: → CreateChannelPayload JSON - prefixChanMember = "chan-member:" // chan-member:: → "" (presence = member) + + // Social feed (v2.0.0). Replaced the old channel keys (chan:, chan-member:). + prefixPost = "post:" // post: → PostRecord JSON + prefixPostByAuthor = "postbyauthor:" // postbyauthor::: → "" (chrono index) + prefixFollow = "follow:" // follow:: → "" (presence = follows) + prefixFollowInbound = "followin:" // followin:: → "" (reverse index — counts followers) + prefixLike = "like:" // like:: → "" (presence = liked) + prefixLikeCount = "likecount:" // likecount: → uint64 (cached count) prefixWalletBind = "walletbind:" // walletbind: → wallet_pubkey (string) prefixReputation = "rep:" // rep: → RepStats JSON prefixPayChan = "paychan:" // paychan: → PayChanState JSON prefixRelay = "relay:" // relay: → RegisterRelayPayload JSON prefixRelayHB = "relayhb:" // relayhb: → unix seconds (int64) of last HB + prefixRelayProof = "relayproof:" // relayproof: → claimant node_pubkey (1 claim per envelope) prefixContactIn = "contact_in:" // contact_in:: → contactRecord JSON prefixValidator = "validator:" // validator: → "" (presence = active) prefixContract = "contract:" // contract: → 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::...` 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:::" + 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::" + // key = "follow::" 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:: + reverse index followin:: + 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 { diff --git a/blockchain/chain_test.go b/blockchain/chain_test.go index 3a9180c..9d413fe 100644 --- a/blockchain/chain_test.go +++ b/blockchain/chain_test.go @@ -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 diff --git a/blockchain/types.go b/blockchain/types.go index 471d413..25f8647 100644 --- a/blockchain/types.go +++ b/blockchain/types.go @@ -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:. 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. // diff --git a/client-app/.gitignore b/client-app/.gitignore new file mode 100644 index 0000000..2e0754d --- /dev/null +++ b/client-app/.gitignore @@ -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 diff --git a/client-app/README.md b/client-app/README.md new file mode 100644 index 0000000..856ba71 --- /dev/null +++ b/client-app/README.md @@ -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: // sender's public key + recipient_pub: // recipient's public key + nonce: <24-byte hex> // random per message + ciphertext: // 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. diff --git a/client-app/app.json b/client-app/app.json new file mode 100644 index 0000000..7419e5f --- /dev/null +++ b/client-app/app.json @@ -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" + } + } + } +} diff --git a/client-app/app/(app)/_layout.tsx b/client-app/app/(app)/_layout.tsx new file mode 100644 index 0000000..5260729 --- /dev/null +++ b/client-app/app/(app)/_layout.tsx @@ -0,0 +1,81 @@ +/** + * Main app layout — кастомный `` + ``. + * + * 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 ( + + + + + {!hideNav && ( + + )} + + ); +} diff --git a/client-app/app/(app)/chats/[id].tsx b/client-app/app/(app)/chats/[id].tsx new file mode 100644 index 0000000..28eb0c9 --- /dev/null +++ b/client-app/app/(app)/chats/[id].tsx @@ -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(null); + + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + const [peerTyping, setPeerTyping] = useState(false); + const [composeMode, setComposeMode] = useState({ kind: 'new' }); + const [pendingAttach, setPendingAttach] = useState(null); + const [attachMenuOpen, setAttachMenuOpen] = useState(false); + const [videoCircleOpen, setVideoCircleOpen] = useState(false); + /** + * ID сообщения, которое сейчас подсвечено (после jump-to-reply). На + * ~2 секунды backgroundColor bubble'а мерцает accent-цветом. + * `null` — ничего не подсвечено. + */ + const [highlightedId, setHighlightedId] = useState(null); + const highlightClearTimer = useRef | null>(null); + + // ── Selection mode ─────────────────────────────────────────────────── + // Активируется первым long-press'ом на bubble'е. Header меняется на + // toolbar с Forward/Delete/Cancel. Tap по bubble'у в selection mode + // toggle'ит принадлежность к выборке. Cancel сбрасывает всё. + const [selectedIds, setSelectedIds] = useState>(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 | 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(); + 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 ; + return ( + + ); + }; + + return ( + + {/* Header — использует общий компонент
, чтобы соблюдать + правила шапки приложения (left slot / centered title / right slot). */} + + {selectionMode ? ( +
} + title={`${selectedIds.size} selected`} + right={ + <> + {selectedIds.size === 1 && ( + + )} + + + + } + /> + ) : ( +
router.back()} />} + title={ + + + + + {name} + + {peerTyping && ( + + typing… + + )} + {!peerTyping && !contact?.x25519Pub && ( + + waiting for key + + )} + + + } + right={} + /> + )} + + + {/* Messages — inverted: data[0] рендерится снизу, последующее — + выше. Это стандартный chat-паттерн: FlatList сразу монтируется + с "scroll position at bottom" без ручного scrollToEnd, и новые + сообщения (добавляемые в начало reversed-массива) появляются + внизу естественно. Никаких jerk'ов при открытии. */} + 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={() => ( + + + + Say hi to {name} + + + Your messages are end-to-end encrypted. + + + )} + /> + + {/* Composer — floating, прибит к низу. */} + + setAttachMenuOpen(true)} + attachment={pendingAttach} + onClearAttach={() => setPendingAttach(null)} + onFinishVoice={(att) => { + // Voice отправляется сразу — sendCore получает attachment + // явным аргументом, минуя state-задержку. + sendCore('', att); + }} + onStartVideoCircle={() => setVideoCircleOpen(true)} + /> + + + setAttachMenuOpen(false)} + onPick={(att) => setPendingAttach(att)} + /> + + setVideoCircleOpen(false)} + onFinish={(att) => { + // Video-circle тоже отправляется сразу. + sendCore('', att); + }} + /> + + ); +} diff --git a/client-app/app/(app)/chats/_layout.tsx b/client-app/app/(app)/chats/_layout.tsx new file mode 100644 index 0000000..b64a699 --- /dev/null +++ b/client-app/app/(app)/chats/_layout.tsx @@ -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 ( + + ); +} diff --git a/client-app/app/(app)/chats/index.tsx b/client-app/app/(app)/chats/index.tsx new file mode 100644 index 0000000..272c5c0 --- /dev/null +++ b/client-app/app/(app)/chats/index.tsx @@ -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 ( + + + + + c.address} + renderItem={({ item }) => ( + router.push(`/(app)/chats/${item.address}` as never)} + /> + )} + contentContainerStyle={{ paddingBottom: 40, flexGrow: 1 }} + showsVerticalScrollIndicator={false} + /> + + {sorted.length === 0 && ( + + + + No chats yet + + + Use the search tab in the navbar to add your first contact. + + + )} + + + ); +} diff --git a/client-app/app/(app)/compose.tsx b/client-app/app/(app)/compose.tsx new file mode 100644 index 0000000..41ac210 --- /dev/null +++ b/client-app/app/(app)/compose.tsx @@ -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] ··· [] [~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(null); + const [busy, setBusy] = useState(false); + const [picking, setPicking] = useState(false); + const [balance, setBalance] = useState(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(); + 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 ( + + {/* Header */} + + router.back()} hitSlop={8}> + + + + ({ + paddingHorizontal: 18, paddingVertical: 9, + borderRadius: 999, + backgroundColor: canPublish ? (pressed ? '#1a8cd8' : '#1d9bf0') : '#1f1f1f', + })} + > + {busy ? ( + + ) : ( + + Опубликовать + + )} + + + + + {/* Avatar + TextInput row */} + + + + + + {/* Hashtag preview */} + {hashtags.length > 0 && ( + + {hashtags.map(tag => ( + + + #{tag} + + + ))} + + )} + + {/* Attachment preview */} + {attach && ( + + + + 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', + })} + > + + + + + {Math.round(attach.size / 1024)} KB · метаданные удалят на сервере + + + )} + + + {/* Footer: attach / counter / fee */} + + ({ + opacity: pressed || picking || attach ? 0.5 : 1, + })} + > + {picking + ? + : } + + + 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 + + + + ≈ {formatFee(estimatedFee)} + + + + ); +} + +// ── 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; +} diff --git a/client-app/app/(app)/feed/[id].tsx b/client-app/app/(app)/feed/[id].tsx new file mode 100644 index 0000000..355baa9 --- /dev/null +++ b/client-app/app/(app)/feed/[id].tsx @@ -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(null); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + +
router.back()} />} + title="Пост" + /> + + {loading ? ( + + + + ) : error ? ( + + {error} + + ) : !post ? ( + + + + Пост удалён или больше недоступен + + + ) : ( + + {/* `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. */} + + + {/* Detailed stats block */} + + + Информация о посте + + + + + + + + + {post.hashtags && post.hashtags.length > 0 && ( + <> + + + Хештеги + + + {post.hashtags.map(tag => ( + router.push(`/(app)/feed/tag/${encodeURIComponent(tag)}` as never)} + style={{ + color: '#1d9bf0', + fontSize: 13, + paddingHorizontal: 8, + paddingVertical: 3, + backgroundColor: '#081a2a', + borderRadius: 999, + }} + > + #{tag} + + ))} + + + )} + + + + + )} + + ); +} + +function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +function shortAddr(a: string, n = 6): string { + if (!a) return '—'; + return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; +} diff --git a/client-app/app/(app)/feed/_layout.tsx b/client-app/app/(app)/feed/_layout.tsx new file mode 100644 index 0000000..adcf221 --- /dev/null +++ b/client-app/app/(app)/feed/_layout.tsx @@ -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 ( + + ); +} diff --git a/client-app/app/(app)/feed/index.tsx b/client-app/app/(app)/feed/index.tsx new file mode 100644 index 0000000..55e4c0a --- /dev/null +++ b/client-app/app/(app)/feed/index.tsx @@ -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 = { + following: 'Подписки', + foryou: 'Для вас', + trending: 'В тренде', +}; + +export default function FeedScreen() { + const insets = useSafeAreaInsets(); + const keyFile = useStore(s => s.keyFile); + + const [tab, setTab] = useState('foryou'); // default: discovery + const [posts, setPosts] = useState([]); + const [likedSet, setLikedSet] = useState>(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(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(); + 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>(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 ( + + + + {/* Tab strip — три таба, равномерно распределены по ширине + (justifyContent: space-between). Каждый Pressable hug'ает + свой контент — табы НЕ тянутся на 1/3 ширины, а жмутся к + своему лейблу, что даёт воздух между ними. Индикатор активной + вкладки — тонкая полоска под лейблом. */} + + {(Object.keys(TAB_LABELS) as TabKey[]).map(key => ( + setTab(key)} + style={({ pressed }) => ({ + alignItems: 'center', + paddingVertical: 16, + paddingHorizontal: 6, + opacity: pressed ? 0.6 : 1, + })} + > + + {TAB_LABELS[key]} + + + + ))} + + + {/* Feed list */} + p.post_id} + renderItem={({ item }) => ( + + )} + ItemSeparatorComponent={PostSeparator} + onEndReached={loadMore} + onEndReachedThreshold={0.6} + ListFooterComponent={ + loadingMore ? ( + + + + ) : 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={ + loadPosts(true)} + tintColor="#1d9bf0" + /> + } + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={viewabilityConfig} + ListEmptyComponent={ + loading ? ( + + + + ) : error ? ( + loadPosts(false)} + /> + ) : ( + + ) + } + 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. */} + + 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, + })} + > + + + + + ); +} + +// ── Empty state ───────────────────────────────────────────────────────── + +function EmptyState({ + icon, title, subtitle, onRetry, +}: { + icon: React.ComponentProps['name']; + title: string; + subtitle?: string; + onRetry?: () => void; +}) { + return ( + + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + {onRetry && ( + ({ + marginTop: 16, + paddingHorizontal: 20, paddingVertical: 10, + borderRadius: 999, + backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + + Попробовать снова + + + )} + + ); +} + +function chunk(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; +} diff --git a/client-app/app/(app)/feed/tag/[tag].tsx b/client-app/app/(app)/feed/tag/[tag].tsx new file mode 100644 index 0000000..026de04 --- /dev/null +++ b/client-app/app/(app)/feed/tag/[tag].tsx @@ -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([]); + const [likedSet, setLikedSet] = useState>(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(); + 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 ( + +
router.back()} />} + title={`#${tag}`} + /> + + p.post_id} + renderItem={({ item }) => ( + + )} + ItemSeparatorComponent={PostSeparator} + initialNumToRender={10} + maxToRenderPerBatch={8} + windowSize={7} + removeClippedSubviews + refreshControl={ + load(true)} + tintColor="#1d9bf0" + /> + } + ListEmptyComponent={ + loading ? ( + + + + ) : ( + + + + Пока нет постов с этим тегом + + + Будьте первым — напишите пост с #{tag} + + + ) + } + contentContainerStyle={ + posts.length === 0 + ? { flexGrow: 1 } + : { paddingTop: 8, paddingBottom: 24 } + } + /> + + ); +} diff --git a/client-app/app/(app)/new-contact.tsx b/client-app/app/(app)/new-contact.tsx new file mode 100644 index 0000000..e2636eb --- /dev/null +++ b/client-app/app/(app)/new-contact.tsx @@ -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(null); + const [searching, setSearching] = useState(false); + const [sending, setSending] = useState(false); + const [error, setError] = useState(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 ( + +
router.back()} />} + /> + + + Enter a @username, a + hex pubkey or a DC… address. + + + + + ({ + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + paddingVertical: 11, borderRadius: 999, marginTop: 12, + backgroundColor: !query.trim() || searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + {searching ? ( + + ) : ( + Search + )} + + + {error && ( + + {error} + + )} + + {/* Resolved profile card */} + {resolved && ( + <> + + + + + + {displayName} + + + {shortAddr(resolved.address, 10)} + + + + + {resolved.x25519 ? 'E2E-ready' : 'Key not published yet'} + + + + + + + {/* Intro */} + + Intro (optional, plaintext on-chain) + + + + {intro.length}/140 + + + {/* Fee tier */} + + Anti-spam fee (goes to recipient) + + + {FEE_TIERS.map(t => { + const active = fee === t.value; + return ( + 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', + })} + > + + {t.label} + + + {formatAmount(t.value)} + + + ); + })} + + + {/* Submit */} + ({ + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + paddingVertical: 13, borderRadius: 999, marginTop: 20, + backgroundColor: sending ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + {sending ? ( + + ) : ( + + Send request · {formatAmount(fee + 1000)} + + )} + + + )} + + + ); +} diff --git a/client-app/app/(app)/profile/[address].tsx b/client-app/app/(app)/profile/[address].tsx new file mode 100644 index 0000000..35e8391 --- /dev/null +++ b/client-app/app/(app)/profile/[address].tsx @@ -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/ + * + * 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 ( + +
router.back()} />} + /> + + + {/* ── Hero: avatar + Follow button ──────────────────────────── */} + + + + {!isMe ? ( + ({ + 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 ? ( + + ) : ( + + {following ? 'Вы подписаны' : 'Подписаться'} + + )} + + ) : ( + router.push('/(app)/settings' as never)} + style={({ pressed }) => ({ + paddingHorizontal: 18, paddingVertical: 9, + borderRadius: 999, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + })} + > + + Редактировать + + + )} + + + {/* Name + verified tick */} + + + {displayName} + + {contact?.username && ( + + )} + + + {/* Open chat — single CTA, full width, icon inline with text. + Only when we know this is a contact (direct chat exists). */} + {!isMe && contact && ( + ({ + marginTop: 14, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingVertical: 11, + borderRadius: 999, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + })} + > + + + Открыть чат + + + )} + + {/* ── Info card ───────────────────────────────────────────────── */} + + {/* Address — entire row is tappable → copies */} + ({ + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, paddingVertical: 12, + backgroundColor: pressed ? '#0f0f0f' : 'transparent', + })} + > + + Адрес + + + {copied ? 'Скопировано' : shortAddr(address ?? '')} + + + + + {/* Encryption status */} + {contact && ( + <> + + + + + + + {/* 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' && ( + <> + + + + )} + + )} + + + {!contact && !isMe && ( + + Этот пользователь пока не в ваших контактах. Нажмите «Подписаться», чтобы видеть его посты в ленте, или добавьте в чаты через @username. + + )} + + + ); +} + +function Divider() { + return ; +} + +function InfoRow({ + label, value, icon, danger, +}: { + label: string; + value: string; + icon?: React.ComponentProps['name']; + danger?: boolean; +}) { + return ( + + {icon && ( + + )} + {label} + + {value} + + + ); +} diff --git a/client-app/app/(app)/profile/_layout.tsx b/client-app/app/(app)/profile/_layout.tsx new file mode 100644 index 0000000..2ca12c3 --- /dev/null +++ b/client-app/app/(app)/profile/_layout.tsx @@ -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 → , which is + * stack-less. Nesting a 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 ( + + ); +} diff --git a/client-app/app/(app)/requests.tsx b/client-app/app/(app)/requests.tsx new file mode 100644 index 0000000..b3f9f59 --- /dev/null +++ b/client-app/app/(app)/requests.tsx @@ -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(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 ( + + + + + {name} + + + wants to message you · {relativeTime(req.timestamp)} + + {req.intro ? ( + + {req.intro} + + ) : null} + + + accept(req)} + disabled={isAccepting} + style={({ pressed }) => ({ + flex: 1, + alignItems: 'center', justifyContent: 'center', + paddingVertical: 9, borderRadius: 999, + backgroundColor: isAccepting ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + {isAccepting ? ( + + ) : ( + Accept + )} + + decline(req)} + disabled={isAccepting} + style={({ pressed }) => ({ + flex: 1, + alignItems: 'center', justifyContent: 'center', + paddingVertical: 9, borderRadius: 999, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + })} + > + Decline + + + + + ); + }; + + return ( + + + + {requests.length === 0 ? ( + + + + All caught up + + + Contact requests and network events will appear here. + + + ) : ( + r.txHash} + renderItem={renderItem} + contentContainerStyle={{ paddingBottom: 120 }} + showsVerticalScrollIndicator={false} + /> + )} + + ); +} diff --git a/client-app/app/(app)/settings.tsx b/client-app/app/(app)/settings.tsx new file mode 100644 index 0000000..854fb2d --- /dev/null +++ b/client-app/app/(app)/settings.tsx @@ -0,0 +1,595 @@ +/** + * Settings screen — sub-route, открывается по tap'у на profile-avatar в + * TabHeader. Использует обычный `
` с 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['name']; + +// ─── Shared layout primitives ───────────────────────────────────── + +function SectionLabel({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function Card({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** + * Row — clickable / non-clickable list item внутри Card'а. + * + * Layout живёт на ВНЕШНЕМ контейнере (View если read-only, Pressable + * если tappable). Для pressed-стейта используется вложенный `` + * с 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) => ( + + + + + + + {label} + + {value !== undefined && ( + + {value} + + )} + + {right} + {onPress && !right && ( + + )} + + ); + + if (!onPress) return {body(false)}; + return ( + + {({ pressed }) => body(pressed)} + + ); +} + +// ─── 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('idle'); + const [peerCount, setPeerCount] = useState(null); + const [blockCount, setBlockCount] = useState(null); + const [copied, setCopied] = useState(false); + const [savingNode, setSavingNode] = useState(false); + + // Username registration state + const [nameInput, setNameInput] = useState(''); + const [nameError, setNameError] = useState(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 ( + +
router.back()} />} + /> + + {/* ── Profile ── */} + Profile + + + + + {username ? ( + + + @{username} + + + + ) : ( + No username yet + )} + + {keyFile ? shortAddr(keyFile.pub_key, 10) : '—'} + + + + } + /> + + + {/* ── Username (только если ещё нет) ── */} + {!username && ( + <> + Username + + + + Buy a username + + + Flat {formatAmount(USERNAME_REGISTRATION_FEE)} fee + {formatAmount(1000)} network. + Only a-z, 0-9, _, -. Starts with a letter. + + + + @ + + + {nameError && ( + + {nameError} + + )} + + + + + + )} + + {/* ── Node ── */} + Node + + + + + + + + + } + /> + + + {/* ── Account ── */} + Account + + + + + + + ); +} + +// ─── Form primitives ────────────────────────────────────────────── + +function LabeledInput({ + label, value, onChangeText, placeholder, monospace, +}: { + label: string; + value: string; + onChangeText: (v: string) => void; + placeholder?: string; + monospace?: boolean; +}) { + return ( + + {label} + + + ); +} + +function PrimaryButton({ + label, onPress, disabled, loading, style, +}: { + label: string; + onPress: () => void; + disabled?: boolean; + loading?: boolean; + style?: object; +}) { + return ( + + {({ pressed }) => ( + + {loading ? ( + + ) : ( + + {label} + + )} + + )} + + ); +} diff --git a/client-app/app/(app)/wallet.tsx b/client-app/app/(app)/wallet.tsx new file mode 100644 index 0000000..33c5393 --- /dev/null +++ b/client-app/app/(app)/wallet.tsx @@ -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['name']; + +interface TxMeta { + label: string; + icon: IoniconName; + tone: 'in' | 'out' | 'neutral'; +} + +const TX_META: Record = { + 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([]); + 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 ( + + } + /> + + } + contentContainerStyle={{ paddingBottom: 120 }} + showsVerticalScrollIndicator={false} + > + setShowSend(true)} + /> + + Recent transactions + + {txHistory.length === 0 ? ( + + ) : ( + + {txHistory.map((tx, i) => ( + + ))} + + )} + + + setShowSend(false)} + balance={balance} + keyFile={keyFile} + onSent={() => { + setShowSend(false); + setTimeout(load, 1200); + }} + /> + + ); +} + +// ─── Hero card ───────────────────────────────────────────────────── + +function BalanceHero({ + balance, address, copied, onCopy, onSend, +}: { + balance: number; + address: string; + copied: boolean; + onCopy: () => void; + onSend: () => void; +}) { + return ( + + + Balance + + + {formatAmount(balance)} + + + {/* Address chip */} + + + + + {copied ? 'Copied!' : shortAddr(address, 10)} + + + + + {/* Actions */} + + + + + + ); +} + +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 ( + + {({ pressed }) => ( + + + + {label} + + + )} + + ); +} + +// ─── Section label ──────────────────────────────────────────────── + +function SectionLabel({ children }: { children: string }) { + return ( + + {children} + + ); +} + +// ─── Empty state ────────────────────────────────────────────────── + +function EmptyTx() { + return ( + + + + No transactions yet + + + Pull to refresh + + + ); +} + +// ─── 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 ( + + + + + + + + {m.label} + + + {tx.type === 'TRANSFER' + ? (isMineTx ? `→ ${shortAddr(tx.to ?? '', 5)}` : `← ${shortAddr(tx.from, 5)}`) + : shortAddr(tx.hash, 8)} + {' · '} + {relativeTime(tx.timestamp)} + + + {amt > 0 && ( + + {sign}{formatAmount(amt)} + + )} + + + ); +} + +// ─── 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 ( + + + { /* 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', + }} + > + + + Send tokens + + + + + + + + + + + + + + + + + + + + {/* Summary */} + + + + + balance ? '#f4212e' : '#ffffff'} + /> + + + + + {({ pressed }) => ( + + + Cancel + + + )} + + + {({ pressed }) => ( + + {sending ? ( + + ) : ( + + Send + + )} + + )} + + + + + + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + {label} + + {children} + + + ); +} + +function SummaryRow({ + label, value, muted, accent, +}: { + label: string; + value: string; + muted?: boolean; + accent?: string; +}) { + return ( + + {label} + + {value} + + + ); +} diff --git a/client-app/app/(auth)/create.tsx b/client-app/app/(auth)/create.tsx new file mode 100644 index 0000000..21c9d5e --- /dev/null +++ b/client-app/app/(auth)/create.tsx @@ -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 ( + +
router.back()} />} + /> + + + A new identity is created locally + + + Your private key never leaves this device. The app encrypts it in the + platform secure store. + + + + + + + + + + + + Important + + + Export and backup your key file right after creation. If you lose + it there is no recovery — blockchain has no password reset. + + + + ({ + alignItems: 'center', justifyContent: 'center', + paddingVertical: 13, borderRadius: 999, marginTop: 20, + backgroundColor: loading ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + {loading ? ( + + ) : ( + + Generate keys & continue + + )} + + + + ); +} + +function InfoRow({ + icon, label, desc, first, +}: { + icon: React.ComponentProps['name']; + label: string; + desc: string; + first?: boolean; +}) { + return ( + + + + + + {label} + {desc} + + + ); +} diff --git a/client-app/app/(auth)/created.tsx b/client-app/app/(auth)/created.tsx new file mode 100644 index 0000000..b01a6bf --- /dev/null +++ b/client-app/app/(auth)/created.tsx @@ -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(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 ( + +
+ + {/* Success badge */} + + + + + + Welcome aboard + + + Keys have been generated and stored securely. + + + + {/* Address */} + copy(keyFile.pub_key, 'address')} + /> + + {/* X25519 */} + + copy(keyFile.x25519_pub, 'x25519')} + /> + + {/* Backup */} + + + + + Backup your key file + + + + Export it now and store somewhere safe — password managers, cold + storage, printed paper. If you lose it, you lose the account. + + ({ + alignItems: 'center', justifyContent: 'center', + paddingVertical: 10, borderRadius: 999, + backgroundColor: pressed ? '#2a1f0f' : '#1a1409', + borderWidth: 1, borderColor: 'rgba(240,179,90,0.35)', + })} + > + + Export key.json + + + + + {/* Continue */} + router.replace('/(app)/chats' as never)} + style={({ pressed }) => ({ + alignItems: 'center', justifyContent: 'center', + paddingVertical: 14, borderRadius: 999, marginTop: 20, + backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + + Open messenger + + + + + ); +} + +function KeyCard({ + title, value, copied, onCopy, +}: { + title: string; + value: string; + copied: boolean; + onCopy: () => void; +}) { + return ( + + + {title} + + + {value} + + ({ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 9, borderRadius: 999, + marginTop: 10, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + })} + > + + + {copied ? 'Copied' : 'Copy'} + + + + ); +} diff --git a/client-app/app/(auth)/import.tsx b/client-app/app/(auth)/import.tsx new file mode 100644 index 0000000..b2208eb --- /dev/null +++ b/client-app/app/(auth)/import.tsx @@ -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('paste'); + const [jsonText, setJsonText] = useState(''); + const [fileName, setFileName] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + +
router.back()} />} + /> + + + Restore your account from a previously exported{' '} + dchain_key.json. + + + {/* Tabs */} + + {(['paste', 'file'] as Tab[]).map(t => ( + setTab(t)} + style={{ + flex: 1, + alignItems: 'center', + paddingVertical: 8, + borderRadius: 999, + backgroundColor: tab === t ? '#1d9bf0' : 'transparent', + }} + > + + {t === 'paste' ? 'Paste JSON' : 'Pick file'} + + + ))} + + + {tab === 'paste' ? ( + <> + + ({ + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + paddingVertical: 12, borderRadius: 999, marginTop: 12, + backgroundColor: loading ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + {loading ? ( + + ) : ( + + {jsonText.trim() ? 'Import key' : 'Paste from clipboard'} + + )} + + + ) : ( + <> + ({ + alignItems: 'center', justifyContent: 'center', + paddingVertical: 40, borderRadius: 14, + backgroundColor: pressed ? '#111111' : '#0a0a0a', + borderWidth: 1, borderStyle: 'dashed', borderColor: '#1f1f1f', + })} + > + + + {fileName ?? 'Tap to pick key.json'} + + + Will auto-import on selection + + + {loading && ( + + + + )} + + )} + + {error && ( + + {error} + + )} + + + ); +} diff --git a/client-app/app/_layout.tsx b/client-app/app/_layout.tsx new file mode 100644 index 0000000..39a8728 --- /dev/null +++ b/client-app/app/_layout.tsx @@ -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 ( + + + + + {booted ? ( + + ) : ( + // Пустой чёрный экран пока bootstrap идёт — без flicker'а. + + )} + + + + ); +} diff --git a/client-app/app/index.tsx b/client-app/app/index.tsx new file mode 100644 index 0000000..263e58a --- /dev/null +++ b/client-app/app/index.tsx @@ -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 загрузил) — + * делаем в (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(null); + const [page, setPage] = useState(0); + const [nodeInput, setNodeInput] = useState(''); + const [scanning, setScanning] = useState(false); + const [checking, setChecking] = useState(false); + const [nodeOk, setNodeOk] = useState(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 ; + + 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 ( + + + + + + Point at a DChain node QR code + + + setScanning(false)} + style={{ + position: 'absolute', top: 56, left: 16, + backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, + paddingHorizontal: 16, paddingVertical: 8, + }} + > + ✕ Cancel + + + ); + } + + 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 ( + + { + const p = Math.round(e.nativeEvent.contentOffset.x / SCREEN_W); + setPage(p); + }} + style={{ flex: 1 }} + keyboardShouldPersistTaps="handled" + > + {/* ───────── Slide 1: Why DChain ───────── */} + + + + + + + + DChain + + + A messenger that belongs to you. + + + + + + + + + {/* CTA — прижата к правому нижнему краю. */} + + goToPage(1)} /> + + + + {/* ───────── Slide 2: How it works ───────── */} + + + + Как это работает + + + Сообщения проходят через релей-ноду в зашифрованном виде. + Выбери публичную или подключи свою. + + + + + + + Node URL + + + + + { 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 + ? + : nodeOk === true + ? + : nodeOk === false + ? + : null} + + ({ + width: 48, alignItems: 'center', justifyContent: 'center', + backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a', + borderWidth: 1, borderColor: '#1f1f1f', + borderRadius: 12, + })} + > + + + + {nodeOk === false && ( + + Cannot reach node — check URL and that the node is running + + )} + + + {/* CTA — прижата к правому нижнему краю. */} + + Linking.openURL(GITEA_URL).catch(() => {})} + /> + goToPage(2)} /> + + + + {/* ───────── Slide 3: Your keys ───────── */} + + + + + + + + Твой аккаунт + + + Создай новую пару ключей или импортируй существующую. + Ключи хранятся только на этом устройстве. + + + + + {/* CTA — прижата к правому нижнему краю. */} + + router.push('/(auth)/import' as never)} + /> + router.push('/(auth)/create' as never)} + /> + + + + + {/* Footer: dots-only pager indicator. CTA-кнопки теперь inline + на каждом слайде, чтобы выглядели как полноценные кнопки, а не + мелкий "Далее" в углу. */} + + {[0, 1, 2].map(i => ( + goToPage(i)} + hitSlop={8} + style={{ + width: page === i ? 22 : 7, + height: 7, + borderRadius: 3.5, + backgroundColor: page === i ? '#1d9bf0' : '#2a2a2a', + }} + /> + ))} + + + ); +} + +// ───────── 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 ( + ({ opacity: pressed ? 0.85 : 1 })}> + + + {label} + + + + ); +} + +/** Secondary CTA — тёмный pill с border'ом, optional icon слева. */ +function CTASecondary({ + label, icon, onPress, +}: { + label: string; + icon?: React.ComponentProps['name']; + onPress: () => void; +}) { + return ( + ({ opacity: pressed ? 0.6 : 1 })}> + + {icon && } + + {label} + + + + ); +} + +function FeatureRow({ + icon, title, text, +}: { icon: React.ComponentProps['name']; title: string; text: string }) { + return ( + + + + + + + {title} + + + {text} + + + + ); +} + +function OptionCard({ + icon, title, text, actionLabel, onAction, +}: { + icon: React.ComponentProps['name']; + title: string; + text: string; + actionLabel?: string; + onAction?: () => void; +}) { + return ( + + + + + {title} + + + + {text} + + {actionLabel && onAction && ( + ({ opacity: pressed ? 0.6 : 1, marginTop: 8 })}> + + {actionLabel} + + + )} + + ); +} diff --git a/client-app/babel.config.js b/client-app/babel.config.js new file mode 100644 index 0000000..d08d04a --- /dev/null +++ b/client-app/babel.config.js @@ -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 + ], + }; +}; diff --git a/client-app/components/AnimatedSlot.tsx b/client-app/components/AnimatedSlot.tsx new file mode 100644 index 0000000..008e9ed --- /dev/null +++ b/client-app/components/AnimatedSlot.tsx @@ -0,0 +1,35 @@ +/** + * AnimatedSlot — renders the (app) group as a native . + * + * 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 ( + + ); +} diff --git a/client-app/components/Avatar.tsx b/client-app/components/Avatar.tsx new file mode 100644 index 0000000..9186203 --- /dev/null +++ b/client-app/components/Avatar.tsx @@ -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 ( + + + + {initial} + + + {dotColor && ( + + )} + + ); +} diff --git a/client-app/components/ChatTile.tsx b/client-app/components/ChatTile.tsx new file mode 100644 index 0000000..eb74137 --- /dev/null +++ b/client-app/components/ChatTile.tsx @@ -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['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 ( + ({ + backgroundColor: pressed ? '#0a0a0a' : 'transparent', + })} + > + + + + + {/* Первая строка: [kind-icon] name [verified] ··· time */} + + {kindIcon && ( + + )} + + {name} + + {c.username && ( + + )} + {last && ( + + {formatWhen(last.timestamp)} + + )} + + + {/* Вторая строка: [✓✓ mine-seen] preview ··· [unread] */} + + {last?.mine && ( + + )} + + {last + ? lastPreview(last) + : c.x25519Pub + ? 'Tap to start encrypted chat' + : 'Waiting for identity…'} + + + {unread !== null && ( + + + {unread > 99 ? '99+' : unread} + + + )} + + + + + ); +} diff --git a/client-app/components/Composer.tsx b/client-app/components/Composer.tsx new file mode 100644 index 0000000..ebc2e3d --- /dev/null +++ b/client-app/components/Composer.tsx @@ -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(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 ( + + {/* ── Banner: edit / reply ── */} + {(inEdit || inReply) && !recordingVoice && ( + + + + {inEdit && ( + + Edit message + + )} + {inReply && ( + <> + + Reply to {(mode as { author: string }).author} + + + {(mode as { preview: string }).preview} + + + )} + + ({ opacity: pressed ? 0.5 : 1 })} + > + + + + )} + + {/* ── Pending attachment preview ── */} + {attachment && !recordingVoice && ( + + )} + + {/* ── Voice recording (inline) ИЛИ обычный input ── */} + {recordingVoice ? ( + { + setRecordingVoice(false); + onFinishVoice?.(att); + }} + onCancel={() => setRecordingVoice(false)} + /> + ) : ( + + + {/* + attach — всегда, кроме edit */} + {onAttach && !inEdit && ( + { e.stopPropagation?.(); onAttach(); }} + hitSlop={6} + style={({ pressed }) => ({ + width: 32, height: 32, borderRadius: 16, + alignItems: 'center', justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + })} + > + + + )} + + + + {/* Правая часть: send ИЛИ [mic + video-circle] */} + {canSend ? ( + { e.stopPropagation?.(); onSend(); }} + style={({ pressed }) => ({ + width: 32, height: 32, borderRadius: 16, + backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0', + alignItems: 'center', justifyContent: 'center', + })} + > + {sending ? ( + + ) : ( + + )} + + ) : !inEdit && (onFinishVoice || onStartVideoCircle) ? ( + + {onStartVideoCircle && ( + { e.stopPropagation?.(); onStartVideoCircle(); }} + hitSlop={6} + style={({ pressed }) => ({ + width: 32, height: 32, borderRadius: 16, + alignItems: 'center', justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + })} + > + + + )} + {onFinishVoice && ( + { e.stopPropagation?.(); setRecordingVoice(true); }} + hitSlop={6} + style={({ pressed }) => ({ + width: 32, height: 32, borderRadius: 16, + alignItems: 'center', justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + })} + > + + + )} + + ) : null} + + + )} + + ); +} + +// ─── Attachment chip — preview текущего pending attachment'а ──────── + +function AttachmentChip({ + attachment, onClear, +}: { + attachment: Attachment; + onClear?: () => void; +}) { + const icon: React.ComponentProps['name'] = + attachment.kind === 'image' ? 'image-outline' : + attachment.kind === 'video' ? 'videocam-outline' : + attachment.kind === 'voice' ? 'mic-outline' : + 'document-outline'; + + return ( + + {attachment.kind === 'image' || attachment.kind === 'video' ? ( + + ) : ( + + + + )} + + + + {attachment.name ?? attachmentLabel(attachment)} + + + {attachment.kind.toUpperCase()} + {attachment.circle ? ' · circle' : ''} + {attachment.size ? ` · ${(attachment.size / 1024).toFixed(0)} KB` : ''} + {attachment.duration ? ` · ${attachment.duration}s` : ''} + + + + ({ opacity: pressed ? 0.5 : 1, padding: 4 })} + > + + + + ); +} + +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'; + } +} diff --git a/client-app/components/Header.tsx b/client-app/components/Header.tsx new file mode 100644 index 0000000..10f8d96 --- /dev/null +++ b/client-app/components/Header.tsx @@ -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 ( + + + {/* Left slot — натуральная ширина, минимум 44 чтобы title + визуально центрировался для одно-icon-left + одно-icon-right. */} + {left} + + {/* Title centered */} + + {typeof title === 'string' ? ( + + {title} + + ) : title ?? null} + + + {/* Right slot — row, натуральная ширина, минимум 44. gap=4 + чтобы несколько IconButton'ов не слипались в selection-mode. */} + + {right} + + + + ); +} diff --git a/client-app/components/IconButton.tsx b/client-app/components/IconButton.tsx new file mode 100644 index 0000000..df62c53 --- /dev/null +++ b/client-app/components/IconButton.tsx @@ -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['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 ( + ({ + width: size, + height: size, + borderRadius: radius, + backgroundColor: pressed && !disabled ? (variant === 'solid' ? '#1a8cd8' : '#1a1a1a') : bg, + alignItems: 'center', + justifyContent: 'center', + })} + > + + + ); +} diff --git a/client-app/components/NavBar.tsx b/client-app/components/NavBar.tsx new file mode 100644 index 0000000..0e54566 --- /dev/null +++ b/client-app/components/NavBar.tsx @@ -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['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 ( + + {items.map((it) => { + const active = isActive(it.href); + return ( + { + 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, + })} + > + + + {it.badge && it.badge > 0 ? ( + + + {it.badge > 99 ? '99+' : it.badge} + + + ) : null} + {it.soon && ( + + + SOON + + + )} + + + ); + })} + + ); +} diff --git a/client-app/components/SearchBar.tsx b/client-app/components/SearchBar.tsx new file mode 100644 index 0000000..0c63e53 --- /dev/null +++ b/client-app/components/SearchBar.tsx @@ -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 ( + + {centered ? ( + // ── Idle state — только текст+icon, отцентрированы. + // Невидимый TextInput поверх ловит tap, чтобы не дергать focus вручную. + + + {placeholder} + setFocused(true)} + onSubmitEditing={onSubmitEditing} + returnKeyType="search" + style={{ + position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, + color: 'transparent', + // Скрываем cursor в idle-режиме; при focus компонент перерисуется. + }} + /> + + ) : ( + + + setFocused(true)} + onBlur={() => setFocused(false)} + onSubmitEditing={onSubmitEditing} + returnKeyType="search" + style={{ + flex: 1, + color: '#ffffff', + fontSize: 14, + padding: 0, + includeFontPadding: false, + }} + /> + + )} + + ); +} diff --git a/client-app/components/TabHeader.tsx b/client-app/components/TabHeader.tsx new file mode 100644 index 0000000..2022535 --- /dev/null +++ b/client-app/components/TabHeader.tsx @@ -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 ( +
router.push('/(app)/settings' as never)} hitSlop={8}> + + + } + right={ + right ?? ( + router.push('/(app)/settings' as never)} + /> + ) + } + /> + ); +} diff --git a/client-app/components/chat/AttachmentMenu.tsx b/client-app/components/chat/AttachmentMenu.tsx new file mode 100644 index 0000000..234ebd2 --- /dev/null +++ b/client-app/components/chat/AttachmentMenu.tsx @@ -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 ( + + + + {}} + style={{ + backgroundColor: '#111111', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingTop: 8, + paddingBottom: Math.max(insets.bottom, 12) + 10, + paddingHorizontal: 10, + borderTopWidth: 1, borderColor: '#1f1f1f', + }} + > + {/* Drag handle */} + + + Attach + + + + + + + + + ); +} + +function Row({ + icon, label, onPress, +}: { + icon: React.ComponentProps['name']; + label: string; + onPress: () => void; +}) { + return ( + ({ + flexDirection: 'row', + alignItems: 'center', + gap: 14, + paddingHorizontal: 14, + paddingVertical: 14, + borderRadius: 14, + backgroundColor: pressed ? '#1a1a1a' : 'transparent', + })} + > + + + + {label} + + ); +} diff --git a/client-app/components/chat/AttachmentPreview.tsx b/client-app/components/chat/AttachmentPreview.tsx new file mode 100644 index 0000000..468012a --- /dev/null +++ b/client-app/components/chat/AttachmentPreview.tsx @@ -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 ; + case 'video': + // circle=true — круглое видео-сообщение (Telegram-стиль). + return attachment.circle + ? + : ; + case 'voice': + return ; + case 'file': + return ; + } +} + +// ─── 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 ( + + ); +} + +// ─── Video ────────────────────────────────────────────────────────── + +function VideoAttachment({ att }: { att: Attachment }) { + const aspect = att.width && att.height ? att.width / att.height : 16 / 9; + return ( + + + {/* Play overlay по центру */} + + + + {att.duration !== undefined && ( + + + {formatDuration(att.duration)} + + + )} + + ); +} + +// ─── Voice ────────────────────────────────────────────────────────── +// Реальный плеер — см. components/chat/VoicePlayer.tsx (expo-av Sound). + +// ─── File ─────────────────────────────────────────────────────────── + +function FileAttachment({ att, own }: { att: Attachment; own?: boolean }) { + return ( + + + + + + + {att.name ?? 'file'} + + + {att.size !== undefined ? formatSize(att.size) : ''} + {att.size !== undefined && att.mime ? ' · ' : ''} + {att.mime ?? ''} + + + + ); +} diff --git a/client-app/components/chat/DaySeparator.tsx b/client-app/components/chat/DaySeparator.tsx new file mode 100644 index 0000000..f5b18cb --- /dev/null +++ b/client-app/components/chat/DaySeparator.tsx @@ -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 ( + + + {label} + + + ); +} diff --git a/client-app/components/chat/MessageBubble.tsx b/client-app/components/chat/MessageBubble.tsx new file mode 100644 index 0000000..33c9eb9 --- /dev/null +++ b/client-app/components/chat/MessageBubble.tsx @@ -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 ; + if (!props.withSenderMeta) return ; + return ; +} + +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 | 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 = ( + + + {msg.replyTo && ( + onJumpToReply?.(msg.replyTo!.id)} + /> + )} + {msg.attachment && ( + + )} + {msg.postRef && ( + + )} + {msg.text.trim() ? ( + {msg.text} + ) : null} + + + + ); + + const contentRow = + variant === 'own' ? ( + + {bubbleNode} + + ) : variant === 'peer-compact' ? ( + + {bubbleNode} + + ) : ( + + {showName && ( + + + {peerName} + + + )} + + + {showAvatar ? ( + + + + ) : null} + + {bubbleNode} + + + ); + + return ( + { 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 && ( + onTap?.(msg)} + /> + )} + + ); +} + +// ─── Clickable check-dot ──────────────────────────────────────────── + +function CheckDot({ selected, onPress }: { selected: boolean; onPress: () => void }) { + return ( + + + {selected && } + + + ); +} + +// ─── 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 ( + + {edited && ( + <> + Edited + · + + )} + {time} + {own && ( + + )} + + ); +} diff --git a/client-app/components/chat/PostRefCard.tsx b/client-app/components/chat/PostRefCard.tsx new file mode 100644 index 0000000..5d9500d --- /dev/null +++ b/client-app/components/chat/PostRefCard.tsx @@ -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 ( + ({ + 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. */} + + + + ПОСТ + + + + {/* Author + excerpt */} + + + + + {displayName} + + {excerpt.length > 0 && ( + + {excerpt} + + )} + {hasImage && ( + + + + с фото + + + )} + + + + ); +} diff --git a/client-app/components/chat/ReplyQuote.tsx b/client-app/components/chat/ReplyQuote.tsx new file mode 100644 index 0000000..a1bba49 --- /dev/null +++ b/client-app/components/chat/ReplyQuote.tsx @@ -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 ( + ({ + 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 слева */} + + + + {author} + + + {preview || 'attachment'} + + + + ); +} diff --git a/client-app/components/chat/VideoCirclePlayer.tsx b/client-app/components/chat/VideoCirclePlayer.tsx new file mode 100644 index 0000000..9d01cf6 --- /dev/null +++ b/client-app/components/chat/VideoCirclePlayer.tsx @@ -0,0 +1,158 @@ +/** + * VideoCirclePlayer — telegram-style круглое видео-сообщение. + * + * Мигрировано с expo-av `