Compare commits
27 Commits
546d2c503f
...
feature/fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a8731f479 | ||
|
|
6425b5cffb | ||
|
|
1c6622e809 | ||
|
|
ab98f21aac | ||
|
|
9be1b60ef1 | ||
|
|
0bb5780a5d | ||
|
|
50aacced0b | ||
|
|
38ae80f57a | ||
|
|
c5ca7a0612 | ||
|
|
7bfd8c7dea | ||
|
|
f688f45739 | ||
|
|
a248c540d5 | ||
|
|
51bc0a1850 | ||
|
|
93040a0684 | ||
|
|
98a0a4b8ba | ||
|
|
5728cfc85a | ||
|
|
0ff2760a11 | ||
|
|
5b64ef2560 | ||
|
|
9e86c93fda | ||
|
|
f885264d23 | ||
|
|
126658f294 | ||
|
|
88848efa63 | ||
|
|
f2cb5586ca | ||
|
|
15d0ed306b | ||
|
|
8082dd0bf7 | ||
|
|
32eec62ba4 | ||
|
|
78d97281f0 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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.
|
||||
|
||||
38
README.md
38
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=<x25519>` | Прочитать входящие конверты |
|
||||
| `DELETE /relay/inbox/{id}` | Удалить envelope (требует Ed25519-подписи владельца) |
|
||||
|
||||
Детали — [`docs/api/relay.md`](docs/api/relay.md). `/relay/send` оставлен
|
||||
для backward-compat, но ломает E2E (nod-релей запечатывает своим ключом)
|
||||
и помечен как non-recommended.
|
||||
|
||||
### Social feed (v2.0.0)
|
||||
| Endpoint | Описание |
|
||||
|----------|----------|
|
||||
| `POST /feed/publish` | Загрузить тело поста + EXIF-скраб + вернуть fee |
|
||||
| `GET /feed/post/{id}` | Тело поста |
|
||||
| `GET /feed/post/{id}/attachment` | Сырые байты картинки/видео (cache'able) |
|
||||
| `GET /feed/post/{id}/stats?me=<pub>` | `{views, likes, liked_by_me?}` |
|
||||
| `POST /feed/post/{id}/view` | Бамп off-chain счётчика просмотров |
|
||||
| `GET /feed/author/{pub}?before=<ts>&limit=N` | Посты автора (пагинация `before`) |
|
||||
| `GET /feed/timeline?follower=<pub>&before=<ts>&limit=N` | Merged лента подписок |
|
||||
| `GET /feed/trending?window=24&limit=N` | Топ по `likes × 3 + views` за окно |
|
||||
| `GET /feed/foryou?pub=<pub>&limit=N` | Рекомендации (неподписанные авторы) |
|
||||
| `GET /feed/hashtag/{tag}?limit=N` | Посты по хэштегу |
|
||||
|
||||
Детали + спецификация — [`docs/api/feed.md`](docs/api/feed.md).
|
||||
|
||||
### Docs / UI
|
||||
- `GET /swagger` — **Swagger UI** (рендерится через swagger-ui-dist).
|
||||
- `GET /swagger/openapi.json` — сырая OpenAPI 3.0 спека.
|
||||
|
||||
@@ -40,13 +40,20 @@ const (
|
||||
prefixHeight = "height" // height → uint64
|
||||
prefixBalance = "balance:" // balance:<pubkey> → uint64
|
||||
prefixIdentity = "id:" // id:<pubkey> → RegisterKeyPayload JSON
|
||||
prefixChannel = "chan:" // chan:<channelID> → CreateChannelPayload JSON
|
||||
prefixChanMember = "chan-member:" // chan-member:<channelID>:<memberPubKey> → "" (presence = member)
|
||||
|
||||
// Social feed (v2.0.0). Replaced the old channel keys (chan:, chan-member:).
|
||||
prefixPost = "post:" // post:<postID> → PostRecord JSON
|
||||
prefixPostByAuthor = "postbyauthor:" // postbyauthor:<author>:<ts_20d>:<postID> → "" (chrono index)
|
||||
prefixFollow = "follow:" // follow:<follower>:<target> → "" (presence = follows)
|
||||
prefixFollowInbound = "followin:" // followin:<target>:<follower> → "" (reverse index — counts followers)
|
||||
prefixLike = "like:" // like:<postID>:<liker> → "" (presence = liked)
|
||||
prefixLikeCount = "likecount:" // likecount:<postID> → uint64 (cached count)
|
||||
prefixWalletBind = "walletbind:" // walletbind:<node_pubkey> → wallet_pubkey (string)
|
||||
prefixReputation = "rep:" // rep:<pubkey> → RepStats JSON
|
||||
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
|
||||
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
|
||||
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
|
||||
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
|
||||
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
|
||||
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
|
||||
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
|
||||
@@ -537,11 +544,13 @@ func (c *Chain) Identity(pubKeyHex string) (*RegisterKeyPayload, error) {
|
||||
return &p, err
|
||||
}
|
||||
|
||||
// Channel returns the CreateChannelPayload for a channel ID, or nil.
|
||||
func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) {
|
||||
var p CreateChannelPayload
|
||||
// ── Feed queries (v2.0.0) ──────────────────────────────────────────────────
|
||||
|
||||
// Post returns the PostRecord for a post ID, or nil if not found.
|
||||
func (c *Chain) Post(postID string) (*PostRecord, error) {
|
||||
var p PostRecord
|
||||
err := c.db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get([]byte(prefixChannel + channelID))
|
||||
item, err := txn.Get([]byte(prefixPost + postID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -552,13 +561,69 @@ func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) {
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &p, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ChannelMembers returns the public keys of all members added to channelID.
|
||||
func (c *Chain) ChannelMembers(channelID string) ([]string, error) {
|
||||
prefix := []byte(fmt.Sprintf("%s%s:", prefixChanMember, channelID))
|
||||
var members []string
|
||||
// PostsByAuthor returns the last `limit` posts by the given author, newest
|
||||
// first. Iterates `postbyauthor:<author>:...` in reverse order. If limit
|
||||
// ≤ 0, defaults to 50; capped at 200.
|
||||
//
|
||||
// If beforeTs > 0, skip posts with CreatedAt >= beforeTs — used by the
|
||||
// timeline/author endpoints to paginate older results. Pass 0 for the
|
||||
// first page (everything, newest first).
|
||||
func (c *Chain) PostsByAuthor(authorPub string, beforeTs int64, limit int) ([]*PostRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
prefix := []byte(prefixPostByAuthor + authorPub + ":")
|
||||
var out []*PostRecord
|
||||
|
||||
err := c.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = prefix
|
||||
opts.Reverse = true // newest (higher ts) first — reverse iteration
|
||||
opts.PrefetchValues = false
|
||||
// For reverse iteration Badger requires seeking past the prefix range.
|
||||
seek := append([]byte{}, prefix...)
|
||||
seek = append(seek, 0xff)
|
||||
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
for it.Seek(seek); it.ValidForPrefix(prefix) && len(out) < limit; it.Next() {
|
||||
key := string(it.Item().Key())
|
||||
// key = "postbyauthor:<author>:<ts_20d>:<postID>"
|
||||
parts := strings.Split(key, ":")
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
postID := parts[len(parts)-1]
|
||||
rec, err := c.postInTxn(txn, postID)
|
||||
if err != nil || rec == nil {
|
||||
continue
|
||||
}
|
||||
if rec.Deleted {
|
||||
continue
|
||||
}
|
||||
if beforeTs > 0 && rec.CreatedAt >= beforeTs {
|
||||
continue
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Following returns the Ed25519 pubkeys that `follower` subscribes to.
|
||||
func (c *Chain) Following(followerPub string) ([]string, error) {
|
||||
prefix := []byte(prefixFollow + followerPub + ":")
|
||||
var out []string
|
||||
err := c.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.PrefetchValues = false
|
||||
@@ -567,15 +632,95 @@ func (c *Chain) ChannelMembers(channelID string) ([]string, error) {
|
||||
defer it.Close()
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
key := string(it.Item().Key())
|
||||
// key = "chan-member:<channelID>:<memberPubKey>"
|
||||
// key = "follow:<follower>:<target>"
|
||||
parts := strings.SplitN(key, ":", 3)
|
||||
if len(parts) == 3 {
|
||||
members = append(members, parts[2])
|
||||
out = append(out, parts[2])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return members, err
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Followers returns the Ed25519 pubkeys that follow `target`.
|
||||
func (c *Chain) Followers(targetPub string) ([]string, error) {
|
||||
prefix := []byte(prefixFollowInbound + targetPub + ":")
|
||||
var out []string
|
||||
err := c.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.PrefetchValues = false
|
||||
opts.Prefix = prefix
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
key := string(it.Item().Key())
|
||||
parts := strings.SplitN(key, ":", 3)
|
||||
if len(parts) == 3 {
|
||||
out = append(out, parts[2])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// LikeCount returns the cached count of likes for a post (O(1)).
|
||||
func (c *Chain) LikeCount(postID string) (uint64, error) {
|
||||
var count uint64
|
||||
err := c.db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get([]byte(prefixLikeCount + postID))
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return item.Value(func(val []byte) error {
|
||||
if len(val) == 8 {
|
||||
count = binary.BigEndian.Uint64(val)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// HasLiked reports whether `liker` has liked the given post.
|
||||
func (c *Chain) HasLiked(postID, likerPub string) (bool, error) {
|
||||
key := []byte(prefixLike + postID + ":" + likerPub)
|
||||
var ok bool
|
||||
err := c.db.View(func(txn *badger.Txn) error {
|
||||
_, err := txn.Get(key)
|
||||
if err == nil {
|
||||
ok = true
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
})
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// postInTxn is the internal helper used by iteration paths to fetch a full
|
||||
// PostRecord without opening a new View transaction.
|
||||
func (c *Chain) postInTxn(txn *badger.Txn, postID string) (*PostRecord, error) {
|
||||
item, err := txn.Get([]byte(prefixPost + postID))
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p PostRecord
|
||||
if err := item.Value(func(val []byte) error {
|
||||
return json.Unmarshal(val, &p)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// WalletBinding returns the payout wallet pub key bound to a node, or "" if none.
|
||||
@@ -740,41 +885,197 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
case EventCreateChannel:
|
||||
var p CreateChannelPayload
|
||||
// ── Feed events (v2.0.0) ──────────────────────────────────────────
|
||||
case EventCreatePost:
|
||||
var p CreatePostPayload
|
||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||
return 0, fmt.Errorf("%w: CREATE_CHANNEL bad payload: %v", ErrTxFailed, err)
|
||||
return 0, fmt.Errorf("%w: CREATE_POST bad payload: %v", ErrTxFailed, err)
|
||||
}
|
||||
if p.PostID == "" {
|
||||
return 0, fmt.Errorf("%w: CREATE_POST: post_id required", ErrTxFailed)
|
||||
}
|
||||
if len(p.ContentHash) != 32 {
|
||||
return 0, fmt.Errorf("%w: CREATE_POST: content_hash must be 32 bytes", ErrTxFailed)
|
||||
}
|
||||
if p.HostingRelay == "" {
|
||||
return 0, fmt.Errorf("%w: CREATE_POST: hosting_relay required", ErrTxFailed)
|
||||
}
|
||||
if p.Size == 0 || p.Size > MaxPostSize {
|
||||
return 0, fmt.Errorf("%w: CREATE_POST: size %d out of range (0, %d]",
|
||||
ErrTxFailed, p.Size, MaxPostSize)
|
||||
}
|
||||
if p.ReplyTo != "" && p.QuoteOf != "" {
|
||||
return 0, fmt.Errorf("%w: CREATE_POST: reply_to and quote_of are mutually exclusive", ErrTxFailed)
|
||||
}
|
||||
// Duplicate check — same post_id may only commit once.
|
||||
if _, err := txn.Get([]byte(prefixPost + p.PostID)); err == nil {
|
||||
return 0, fmt.Errorf("%w: CREATE_POST: post %s already exists", ErrTxFailed, p.PostID)
|
||||
}
|
||||
// Fee formula: BasePostFee + size × PostByteFee. tx.Fee carries the
|
||||
// full amount; we validate it matches and the sender can afford it.
|
||||
expectedFee := BasePostFee + p.Size*PostByteFee
|
||||
if tx.Fee < expectedFee {
|
||||
return 0, fmt.Errorf("%w: CREATE_POST: fee %d < required %d (base %d + %d × %d bytes)",
|
||||
ErrTxFailed, tx.Fee, expectedFee, BasePostFee, PostByteFee, p.Size)
|
||||
}
|
||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||
return 0, fmt.Errorf("CREATE_CHANNEL debit: %w", err)
|
||||
return 0, fmt.Errorf("CREATE_POST debit: %w", err)
|
||||
}
|
||||
val, _ := json.Marshal(p)
|
||||
if err := txn.Set([]byte(prefixChannel+p.ChannelID), val); err != nil {
|
||||
// Full fee goes to the hosting relay (storage compensation). No
|
||||
// validator cut on posts — validators earn from other tx types. This
|
||||
// incentivises nodes to actually host posts.
|
||||
relayTarget, err := c.resolveRewardTarget(txn, p.HostingRelay)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := c.creditBalance(txn, relayTarget, tx.Fee); err != nil {
|
||||
return 0, fmt.Errorf("credit hosting relay: %w", err)
|
||||
}
|
||||
rec := PostRecord{
|
||||
PostID: p.PostID,
|
||||
Author: tx.From,
|
||||
ContentHash: p.ContentHash,
|
||||
Size: p.Size,
|
||||
HostingRelay: p.HostingRelay,
|
||||
ReplyTo: p.ReplyTo,
|
||||
QuoteOf: p.QuoteOf,
|
||||
CreatedAt: tx.Timestamp.Unix(),
|
||||
FeeUT: tx.Fee,
|
||||
}
|
||||
recBytes, _ := json.Marshal(rec)
|
||||
if err := txn.Set([]byte(prefixPost+p.PostID), recBytes); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Chrono index — allows PostsByAuthor to list newest-first in O(N).
|
||||
idxKey := fmt.Sprintf("%s%s:%020d:%s", prefixPostByAuthor, tx.From, rec.CreatedAt, p.PostID)
|
||||
if err := txn.Set([]byte(idxKey), []byte{}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
case EventAddMember:
|
||||
var p AddMemberPayload
|
||||
case EventDeletePost:
|
||||
var p DeletePostPayload
|
||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||
return 0, fmt.Errorf("%w: ADD_MEMBER bad payload: %v", ErrTxFailed, err)
|
||||
return 0, fmt.Errorf("%w: DELETE_POST bad payload: %v", ErrTxFailed, err)
|
||||
}
|
||||
if p.ChannelID == "" {
|
||||
return 0, fmt.Errorf("%w: ADD_MEMBER: channel_id required", ErrTxFailed)
|
||||
if p.PostID == "" {
|
||||
return 0, fmt.Errorf("%w: DELETE_POST: post_id required", ErrTxFailed)
|
||||
}
|
||||
if _, err := txn.Get([]byte(prefixChannel + p.ChannelID)); err != nil {
|
||||
item, err := txn.Get([]byte(prefixPost + p.PostID))
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return 0, fmt.Errorf("%w: DELETE_POST: post %s not found", ErrTxFailed, p.PostID)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var rec PostRecord
|
||||
if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if rec.Author != tx.From {
|
||||
return 0, fmt.Errorf("%w: DELETE_POST: only author can delete", ErrTxFailed)
|
||||
}
|
||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||
return 0, fmt.Errorf("DELETE_POST debit: %w", err)
|
||||
}
|
||||
rec.Deleted = true
|
||||
val, _ := json.Marshal(rec)
|
||||
if err := txn.Set([]byte(prefixPost+p.PostID), val); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
case EventFollow:
|
||||
if tx.To == "" {
|
||||
return 0, fmt.Errorf("%w: FOLLOW: target (to) is required", ErrTxFailed)
|
||||
}
|
||||
if tx.To == tx.From {
|
||||
return 0, fmt.Errorf("%w: FOLLOW: cannot follow yourself", ErrTxFailed)
|
||||
}
|
||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||
return 0, fmt.Errorf("FOLLOW debit: %w", err)
|
||||
}
|
||||
// follow:<follower>:<target> + reverse index followin:<target>:<follower>
|
||||
fKey := []byte(prefixFollow + tx.From + ":" + tx.To)
|
||||
if _, err := txn.Get(fKey); err == nil {
|
||||
return 0, fmt.Errorf("%w: FOLLOW: already following", ErrTxFailed)
|
||||
}
|
||||
if err := txn.Set(fKey, []byte{}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := txn.Set([]byte(prefixFollowInbound+tx.To+":"+tx.From), []byte{}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
case EventUnfollow:
|
||||
if tx.To == "" {
|
||||
return 0, fmt.Errorf("%w: UNFOLLOW: target (to) is required", ErrTxFailed)
|
||||
}
|
||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||
return 0, fmt.Errorf("UNFOLLOW debit: %w", err)
|
||||
}
|
||||
fKey := []byte(prefixFollow + tx.From + ":" + tx.To)
|
||||
if _, err := txn.Get(fKey); err != nil {
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return 0, fmt.Errorf("%w: ADD_MEMBER: channel %q not found", ErrTxFailed, p.ChannelID)
|
||||
return 0, fmt.Errorf("%w: UNFOLLOW: not following", ErrTxFailed)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if err := txn.Delete(fKey); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := txn.Delete([]byte(prefixFollowInbound + tx.To + ":" + tx.From)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
case EventLikePost:
|
||||
var p LikePostPayload
|
||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||
return 0, fmt.Errorf("%w: LIKE_POST bad payload: %v", ErrTxFailed, err)
|
||||
}
|
||||
if p.PostID == "" {
|
||||
return 0, fmt.Errorf("%w: LIKE_POST: post_id required", ErrTxFailed)
|
||||
}
|
||||
if _, err := txn.Get([]byte(prefixPost + p.PostID)); err != nil {
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return 0, fmt.Errorf("%w: LIKE_POST: post %s not found", ErrTxFailed, p.PostID)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
lKey := []byte(prefixLike + p.PostID + ":" + tx.From)
|
||||
if _, err := txn.Get(lKey); err == nil {
|
||||
return 0, fmt.Errorf("%w: LIKE_POST: already liked", ErrTxFailed)
|
||||
}
|
||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||
return 0, fmt.Errorf("LIKE_POST debit: %w", err)
|
||||
}
|
||||
if err := txn.Set(lKey, []byte{}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := bumpLikeCount(txn, p.PostID, +1); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
case EventUnlikePost:
|
||||
var p UnlikePostPayload
|
||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||
return 0, fmt.Errorf("%w: UNLIKE_POST bad payload: %v", ErrTxFailed, err)
|
||||
}
|
||||
if p.PostID == "" {
|
||||
return 0, fmt.Errorf("%w: UNLIKE_POST: post_id required", ErrTxFailed)
|
||||
}
|
||||
lKey := []byte(prefixLike + p.PostID + ":" + tx.From)
|
||||
if _, err := txn.Get(lKey); err != nil {
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return 0, fmt.Errorf("%w: UNLIKE_POST: not liked", ErrTxFailed)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||
return 0, fmt.Errorf("ADD_MEMBER debit: %w", err)
|
||||
return 0, fmt.Errorf("UNLIKE_POST debit: %w", err)
|
||||
}
|
||||
member := tx.To
|
||||
if member == "" {
|
||||
member = tx.From
|
||||
if err := txn.Delete(lKey); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := txn.Set([]byte(fmt.Sprintf("%s%s:%s", prefixChanMember, p.ChannelID, member)), []byte{}); err != nil {
|
||||
if err := bumpLikeCount(txn, p.PostID, -1); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -795,9 +1096,21 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||
return 0, fmt.Errorf("%w: RELAY_PROOF bad payload: %v", ErrTxFailed, err)
|
||||
}
|
||||
if p.EnvelopeID == "" {
|
||||
return 0, fmt.Errorf("%w: RELAY_PROOF: envelope_id is required", ErrTxFailed)
|
||||
}
|
||||
if p.SenderPubKey == "" || p.FeeUT == 0 || len(p.FeeSig) == 0 {
|
||||
return 0, fmt.Errorf("%w: relay proof missing fee authorization fields", ErrTxFailed)
|
||||
}
|
||||
// Per-envelope dedup — only one relay may claim the fee for a given
|
||||
// envelope. Without this check, every relay that saw the gossipsub
|
||||
// re-broadcast could extract the sender's FeeSig and submit its own
|
||||
// RELAY_PROOF, draining the sender's balance by N× for one message.
|
||||
proofKey := []byte(prefixRelayProof + p.EnvelopeID)
|
||||
if _, err := txn.Get(proofKey); err == nil {
|
||||
return 0, fmt.Errorf("%w: RELAY_PROOF: envelope %s already claimed",
|
||||
ErrTxFailed, p.EnvelopeID)
|
||||
}
|
||||
authBytes := FeeAuthBytes(p.EnvelopeID, p.FeeUT)
|
||||
ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig)
|
||||
if err != nil || !ok {
|
||||
@@ -818,6 +1131,10 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Mark envelope as claimed — prevents replay by other relays.
|
||||
if err := txn.Set(proofKey, []byte(p.RelayPubKey)); err != nil {
|
||||
return 0, fmt.Errorf("mark relay proof: %w", err)
|
||||
}
|
||||
|
||||
case EventBindWallet:
|
||||
var p BindWalletPayload
|
||||
@@ -956,6 +1273,19 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
||||
return 0, fmt.Errorf("%w: CONTACT_REQUEST: amount %d < MinContactFee %d",
|
||||
ErrTxFailed, tx.Amount, MinContactFee)
|
||||
}
|
||||
// Sticky block — if recipient previously blocked this sender, refuse
|
||||
// the new request instead of silently overwriting the blocked status
|
||||
// back to pending. Prevents unblock-via-respam.
|
||||
key := prefixContactIn + tx.To + ":" + tx.From
|
||||
if item, err := txn.Get([]byte(key)); err == nil {
|
||||
var prev contactRecord
|
||||
if verr := item.Value(func(val []byte) error {
|
||||
return json.Unmarshal(val, &prev)
|
||||
}); verr == nil && prev.Status == string(ContactBlocked) {
|
||||
return 0, fmt.Errorf("%w: CONTACT_REQUEST: recipient has blocked sender",
|
||||
ErrTxFailed)
|
||||
}
|
||||
}
|
||||
if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil {
|
||||
return 0, fmt.Errorf("CONTACT_REQUEST debit: %w", err)
|
||||
}
|
||||
@@ -970,7 +1300,6 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
||||
CreatedAt: tx.Timestamp.Unix(),
|
||||
}
|
||||
val, _ := json.Marshal(rec)
|
||||
key := prefixContactIn + tx.To + ":" + tx.From
|
||||
if err := txn.Set([]byte(key), val); err != nil {
|
||||
return 0, fmt.Errorf("store contact record: %w", err)
|
||||
}
|
||||
@@ -2307,6 +2636,35 @@ func (c *Chain) isValidatorTxn(txn *badger.Txn, pubKey string) (bool, error) {
|
||||
|
||||
// verifyEd25519 verifies an Ed25519 signature without importing the identity package
|
||||
// (which would create a circular dependency).
|
||||
// bumpLikeCount adjusts the cached like counter for a post. delta = ±1.
|
||||
// Clamps at zero so a corrupt unlike without prior like can't underflow.
|
||||
func bumpLikeCount(txn *badger.Txn, postID string, delta int64) error {
|
||||
key := []byte(prefixLikeCount + postID)
|
||||
var cur uint64
|
||||
item, err := txn.Get(key)
|
||||
if err == nil {
|
||||
if verr := item.Value(func(val []byte) error {
|
||||
if len(val) == 8 {
|
||||
cur = binary.BigEndian.Uint64(val)
|
||||
}
|
||||
return nil
|
||||
}); verr != nil {
|
||||
return verr
|
||||
}
|
||||
} else if !errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case delta < 0 && cur > 0:
|
||||
cur--
|
||||
case delta > 0:
|
||||
cur++
|
||||
}
|
||||
var buf [8]byte
|
||||
binary.BigEndian.PutUint64(buf[:], cur)
|
||||
return txn.Set(key, buf[:])
|
||||
}
|
||||
|
||||
func verifyEd25519(pubKeyHex string, msg, sig []byte) (bool, error) {
|
||||
pubBytes, err := hex.DecodeString(pubKeyHex)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,8 +11,6 @@ type EventType string
|
||||
|
||||
const (
|
||||
EventRegisterKey EventType = "REGISTER_KEY"
|
||||
EventCreateChannel EventType = "CREATE_CHANNEL"
|
||||
EventAddMember EventType = "ADD_MEMBER"
|
||||
EventOpenPayChan EventType = "OPEN_PAY_CHAN"
|
||||
EventClosePayChan EventType = "CLOSE_PAY_CHAN"
|
||||
EventTransfer EventType = "TRANSFER"
|
||||
@@ -37,6 +35,17 @@ const (
|
||||
EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token
|
||||
EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership
|
||||
EventBurnNFT EventType = "BURN_NFT" // burn (destroy) an NFT
|
||||
|
||||
// ── Social feed (v2.0.0) ──────────────────────────────────────────────
|
||||
// Replaces the old channel model with a VK/Twitter-style timeline.
|
||||
// Posts are plaintext, publicly readable, size-priced. Bodies live in
|
||||
// the relay feed-mailbox; on-chain we only keep metadata + author.
|
||||
EventCreatePost EventType = "CREATE_POST" // author publishes a post
|
||||
EventDeletePost EventType = "DELETE_POST" // author soft-deletes their post
|
||||
EventFollow EventType = "FOLLOW" // follow another author's feed
|
||||
EventUnfollow EventType = "UNFOLLOW" // unfollow an author
|
||||
EventLikePost EventType = "LIKE_POST" // like a post
|
||||
EventUnlikePost EventType = "UNLIKE_POST" // remove a previous like
|
||||
)
|
||||
|
||||
// Token amounts are stored in micro-tokens (µT).
|
||||
@@ -64,6 +73,31 @@ const (
|
||||
// MinContactFee is the minimum amount a sender must pay the recipient when
|
||||
// submitting an EventContactRequest (anti-spam; goes directly to recipient).
|
||||
MinContactFee uint64 = 5_000 // 0.005 T
|
||||
|
||||
// ── Feed pricing (v2.0.0) ─────────────────────────────────────────────
|
||||
// A post's on-chain fee is BasePostFee + bytes_on_disk × PostByteFee.
|
||||
// The fee is paid by the author and credited in full to the hosting
|
||||
// relay (the node that received POST /feed/publish and stored the body).
|
||||
// Size-based pricing is what aligns incentives: a 200-byte tweet is
|
||||
// cheap, a 256 KB video costs ~0.26 T — node operators' storage cost
|
||||
// is covered.
|
||||
//
|
||||
// Note: BasePostFee is set to MinFee (1000 µT) because chain-level block
|
||||
// validation requires every tx's Fee ≥ MinFee. So the true minimum a
|
||||
// post can cost is MinFee + size × PostByteFee. A 0-byte post is
|
||||
// rejected (Size must be > 0), so in practice a ~50-byte text post
|
||||
// costs ~1050 µT (~$0.001 depending on token price).
|
||||
BasePostFee uint64 = 1_000 // 0.001 T flat per post — aligned with MinFee floor
|
||||
PostByteFee uint64 = 1 // 1 µT per byte of stored content
|
||||
|
||||
// MaxPostSize caps a single post's on-wire size (text + attachment, post
|
||||
// compression). Hard limit — node refuses larger envelopes to protect
|
||||
// storage and bandwidth.
|
||||
MaxPostSize uint64 = 256 * 1024 // 256 KiB
|
||||
|
||||
// LikeFee / FollowFee / UnlikeFee / UnfollowFee / DeletePostFee all use
|
||||
// MinFee (1000 µT) — standard tx fee paid to the validator. No extra
|
||||
// cost; these events carry no body.
|
||||
)
|
||||
|
||||
// Transaction is the atomic unit recorded in a block.
|
||||
@@ -90,11 +124,66 @@ type RegisterKeyPayload struct {
|
||||
X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging
|
||||
}
|
||||
|
||||
// CreateChannelPayload is embedded in EventCreateChannel transactions.
|
||||
type CreateChannelPayload struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
Title string `json:"title"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
// ── Feed payloads (v2.0.0) ─────────────────────────────────────────────────
|
||||
|
||||
// CreatePostPayload is embedded in EventCreatePost transactions. The body
|
||||
// itself is NOT stored on-chain — it lives in the relay feed-mailbox keyed
|
||||
// by PostID. On-chain we only keep author, size, hash, timestamp and any
|
||||
// reply/quote reference for ordering and proof of authorship.
|
||||
//
|
||||
// PostID is computed client-side as hex(sha256(author || content_hash || ts)[:16])
|
||||
// — same scheme as envelope IDs. Clients include it so the relay can store
|
||||
// the body under a stable key before the chain commit lands.
|
||||
//
|
||||
// HostingRelay is the node pubkey (Ed25519 hex) that accepted the POST
|
||||
// /feed/publish call and holds the body. Readers resolve it via the chain
|
||||
// and fetch the body directly from that relay (or via gossipsub replicas).
|
||||
// The fee is credited to this pub.
|
||||
//
|
||||
// QuoteOf / ReplyTo are mutually exclusive; set at most one. ReplyTo makes
|
||||
// the post a reply in a thread; QuoteOf creates a link/reference block.
|
||||
type CreatePostPayload struct {
|
||||
PostID string `json:"post_id"`
|
||||
ContentHash []byte `json:"content_hash"` // sha256 of body-bytes, 32 B
|
||||
Size uint64 `json:"size"` // bytes on disk (compressed)
|
||||
HostingRelay string `json:"hosting_relay"` // hex Ed25519 of storing node
|
||||
ReplyTo string `json:"reply_to,omitempty"` // parent post ID
|
||||
QuoteOf string `json:"quote_of,omitempty"` // referenced post ID
|
||||
}
|
||||
|
||||
// DeletePostPayload — author soft-deletes their own post. Stored marker
|
||||
// lets clients hide the post; relay can GC the body on the next sweep.
|
||||
type DeletePostPayload struct {
|
||||
PostID string `json:"post_id"`
|
||||
}
|
||||
|
||||
// FollowPayload / UnfollowPayload — follow graph. tx.From = follower,
|
||||
// tx.To = target. No body.
|
||||
type FollowPayload struct{}
|
||||
type UnfollowPayload struct{}
|
||||
|
||||
// LikePostPayload / UnlikePostPayload — per-post like indicator. tx.From
|
||||
// = liker. The counter is derived on read.
|
||||
type LikePostPayload struct {
|
||||
PostID string `json:"post_id"`
|
||||
}
|
||||
type UnlikePostPayload struct {
|
||||
PostID string `json:"post_id"`
|
||||
}
|
||||
|
||||
// PostRecord is what we store on-chain under post:<postID>. Consumers of
|
||||
// PostsByAuthor / query endpoints decode this.
|
||||
type PostRecord struct {
|
||||
PostID string `json:"post_id"`
|
||||
Author string `json:"author"` // hex Ed25519
|
||||
ContentHash []byte `json:"content_hash"`
|
||||
Size uint64 `json:"size"`
|
||||
HostingRelay string `json:"hosting_relay"`
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
QuoteOf string `json:"quote_of,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"` // unix seconds (tx timestamp)
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
FeeUT uint64 `json:"fee_ut"` // total fee paid
|
||||
}
|
||||
|
||||
// RegisterRelayPayload is embedded in EventRegisterRelay transactions.
|
||||
@@ -241,24 +330,6 @@ type BlockContactPayload struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelMember records a participant in a channel together with their
|
||||
// X25519 public key. The key is cached on-chain (written during ADD_MEMBER)
|
||||
// so channel senders don't have to fan out a separate /api/identity lookup
|
||||
// per recipient on every message — they GET /api/channels/:id/members
|
||||
// once and seal N envelopes in a loop.
|
||||
type ChannelMember struct {
|
||||
PubKey string `json:"pub_key"` // Ed25519 hex
|
||||
X25519PubKey string `json:"x25519_pub_key"` // optional; empty if member hasn't registered
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// AddMemberPayload is embedded in EventAddMember transactions.
|
||||
// tx.From adds tx.To as a member of the specified channel.
|
||||
// If tx.To is empty, tx.From is added (self-join for public channels).
|
||||
type AddMemberPayload struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
|
||||
// AddValidatorPayload is embedded in EventAddValidator transactions.
|
||||
// tx.From must already be a validator; tx.To is the new validator's pub key.
|
||||
//
|
||||
|
||||
42
client-app/.gitignore
vendored
Normal file
42
client-app/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# ── Client-app local state ─────────────────────────────────────────────
|
||||
|
||||
# Dependencies (install via npm ci)
|
||||
node_modules/
|
||||
|
||||
# Expo / Metro caches
|
||||
.expo/
|
||||
.expo-shared/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
web-build/
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
|
||||
# TypeScript incremental build
|
||||
*.tsbuildinfo
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# Native prebuild output (Expo managed)
|
||||
/android
|
||||
/ios
|
||||
93
client-app/README.md
Normal file
93
client-app/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# DChain Messenger — React Native Client
|
||||
|
||||
E2E-encrypted mobile/desktop messenger built on the DChain blockchain stack.
|
||||
|
||||
**Stack:** React Native · Expo · NativeWind (Tailwind) · TweetNaCl · Zustand
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd client-app
|
||||
npm install
|
||||
npx expo start # opens Expo Dev Tools
|
||||
# Press 'i' for iOS simulator, 'a' for Android, 'w' for web
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18+
|
||||
- [Expo Go](https://expo.dev/client) on your phone (for Expo tunnel), or iOS/Android emulator
|
||||
- A running DChain node (see root README for `docker compose up --build -d`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
client-app/
|
||||
├── app/
|
||||
│ ├── _layout.tsx # Root layout — loads keys, sets up nav
|
||||
│ ├── index.tsx # Welcome / onboarding
|
||||
│ ├── (auth)/
|
||||
│ │ ├── create.tsx # Generate new Ed25519 + X25519 keys
|
||||
│ │ ├── created.tsx # Key created — export reminder
|
||||
│ │ └── import.tsx # Import existing key.json
|
||||
│ └── (app)/
|
||||
│ ├── _layout.tsx # Tab bar — Chats · Wallet · Settings
|
||||
│ ├── chats/
|
||||
│ │ ├── index.tsx # Chat list with contacts
|
||||
│ │ └── [id].tsx # Individual chat with E2E encryption
|
||||
│ ├── requests.tsx # Incoming contact requests
|
||||
│ ├── new-contact.tsx # Add contact by @username or address
|
||||
│ ├── wallet.tsx # Balance + TX history + send
|
||||
│ └── settings.tsx # Node URL, key export, profile
|
||||
├── components/ui/ # shadcn-style components (Button, Card, Input…)
|
||||
├── hooks/
|
||||
│ ├── useMessages.ts # Poll relay inbox, decrypt messages
|
||||
│ ├── useBalance.ts # Poll token balance
|
||||
│ └── useContacts.ts # Load contacts + poll contact requests
|
||||
└── lib/
|
||||
├── api.ts # REST client for all DChain endpoints
|
||||
├── crypto.ts # NaCl box encrypt/decrypt, Ed25519 sign
|
||||
├── storage.ts # SecureStore (keys) + AsyncStorage (data)
|
||||
├── store.ts # Zustand global state
|
||||
├── types.ts # TypeScript interfaces
|
||||
└── utils.ts # cn(), formatAmount(), relativeTime()
|
||||
```
|
||||
|
||||
## Cryptography
|
||||
|
||||
| Operation | Algorithm | Library |
|
||||
|-----------|-----------|---------|
|
||||
| Transaction signing | Ed25519 | TweetNaCl `sign` |
|
||||
| Key exchange | X25519 (Curve25519) | TweetNaCl `box` |
|
||||
| Message encryption | NaCl box (XSalsa20-Poly1305) | TweetNaCl `box` |
|
||||
| Key storage | Device secure enclave | expo-secure-store |
|
||||
|
||||
Messages are encrypted as:
|
||||
```
|
||||
Envelope {
|
||||
sender_pub: <X25519 hex> // sender's public key
|
||||
recipient_pub: <X25519 hex> // recipient's public key
|
||||
nonce: <24-byte hex> // random per message
|
||||
ciphertext: <hex> // NaCl box(plaintext, nonce, sender_priv, recipient_pub)
|
||||
}
|
||||
```
|
||||
|
||||
## Connect to your node
|
||||
|
||||
1. Start the DChain node: `docker compose up --build -d`
|
||||
2. Open the app → Settings → Node URL → `http://YOUR_IP:8081`
|
||||
3. If using Expo Go on physical device: your PC and phone must be on the same network, or use `npx expo start --tunnel`
|
||||
|
||||
## Key File Format
|
||||
|
||||
The `key.json` exported/imported by the app:
|
||||
```json
|
||||
{
|
||||
"pub_key": "26018d40...", // Ed25519 public key (64 hex chars)
|
||||
"priv_key": "...", // Ed25519 private key (128 hex chars)
|
||||
"x25519_pub": "...", // X25519 public key (64 hex chars)
|
||||
"x25519_priv": "..." // X25519 private key (64 hex chars)
|
||||
}
|
||||
```
|
||||
|
||||
This is the same format as the Go node's `--key` flag.
|
||||
69
client-app/app.json
Normal file
69
client-app/app.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "DChain Messenger",
|
||||
"slug": "dchain-messenger",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "dark",
|
||||
"backgroundColor": "#000000",
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.dchain.messenger",
|
||||
"infoPlist": {
|
||||
"NSMicrophoneUsageDescription": "Allow DChain to record voice messages and video.",
|
||||
"NSCameraUsageDescription": "Allow DChain to record video messages and scan QR codes.",
|
||||
"NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "com.dchain.messenger",
|
||||
"softwareKeyboardLayoutMode": "pan",
|
||||
"permissions": [
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow DChain to record video messages and scan QR codes.",
|
||||
"microphonePermission": "Allow DChain to record audio with video."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Allow DChain to attach photos and videos.",
|
||||
"cameraPermission": "Allow DChain to take photos."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-audio",
|
||||
{
|
||||
"microphonePermission": "Allow DChain to record voice messages."
|
||||
}
|
||||
],
|
||||
"expo-video"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": false
|
||||
},
|
||||
"scheme": "dchain",
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "28d7743e-6745-460f-8ce5-c971c5c297b6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
client-app/app/(app)/_layout.tsx
Normal file
81
client-app/app/(app)/_layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Main app layout — кастомный `<AnimatedSlot>` + `<NavBar>`.
|
||||
*
|
||||
* AnimatedSlot — обёртка над Slot'ом, анимирующая переход при смене
|
||||
* pathname'а. Направление анимации вычисляется по TAB_ORDER: если
|
||||
* целевой tab "справа" — слайд из правой стороны, "слева" — из левой.
|
||||
*
|
||||
* Intra-tab навигация (chats/index → chats/[id]) обслуживается вложенным
|
||||
* Stack'ом в chats/_layout.tsx — там остаётся нативная slide-from-right
|
||||
* анимация, чтобы chat detail "выезжал" поверх списка.
|
||||
*
|
||||
* Side-effects (balance, contacts, WS auth, dev seed) — монтируются здесь
|
||||
* один раз; переходы между tab'ами их не перезапускают.
|
||||
*/
|
||||
import React, { useEffect } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { router, usePathname } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { useBalance } from '@/hooks/useBalance';
|
||||
import { useContacts } from '@/hooks/useContacts';
|
||||
import { useWellKnownContracts } from '@/hooks/useWellKnownContracts';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useGlobalInbox } from '@/hooks/useGlobalInbox';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { useDevSeed } from '@/lib/devSeed';
|
||||
import { NavBar } from '@/components/NavBar';
|
||||
import { AnimatedSlot } from '@/components/AnimatedSlot';
|
||||
|
||||
export default function AppLayout() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const requests = useStore(s => s.requests);
|
||||
const insets = useSafeAreaInsets();
|
||||
const pathname = usePathname();
|
||||
|
||||
// NavBar прячется на full-screen экранах:
|
||||
// - chat detail
|
||||
// - compose (new post modal)
|
||||
// - feed sub-routes (post detail, hashtag search)
|
||||
const hideNav =
|
||||
/^\/chats\/[^/]+/.test(pathname) ||
|
||||
pathname === '/compose' ||
|
||||
/^\/feed\/.+/.test(pathname);
|
||||
|
||||
useBalance();
|
||||
useContacts();
|
||||
useWellKnownContracts();
|
||||
useDevSeed();
|
||||
useNotifications(); // permission + tap-handler
|
||||
useGlobalInbox(); // global inbox listener → notifications on new peer msg
|
||||
|
||||
useEffect(() => {
|
||||
const ws = getWSClient();
|
||||
if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });
|
||||
else ws.setAuthCreds(null);
|
||||
}, [keyFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyFile === null) {
|
||||
const t = setTimeout(() => {
|
||||
if (!useStore.getState().keyFile) router.replace('/');
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [keyFile]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<AnimatedSlot />
|
||||
</View>
|
||||
{!hideNav && (
|
||||
<NavBar
|
||||
bottomInset={insets.bottom}
|
||||
requestCount={requests.length}
|
||||
notifCount={0}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
519
client-app/app/(app)/chats/[id].tsx
Normal file
519
client-app/app/(app)/chats/[id].tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Chat detail screen — верстка по референсу (X-style Messages).
|
||||
*
|
||||
* Структура:
|
||||
* [Header: back + avatar + name + typing-status | ⋯]
|
||||
* [FlatList: MessageBubble + DaySeparator, group-aware]
|
||||
* [Composer: floating, supports edit/reply banner]
|
||||
*
|
||||
* Весь presentational код вынесен в components/chat/*:
|
||||
* - MessageBubble (own/peer rendering)
|
||||
* - DaySeparator (day label между группами)
|
||||
* - buildRows (чистая функция группировки)
|
||||
* Date-форматирование — lib/dates.ts.
|
||||
*/
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View, Text, FlatList, KeyboardAvoidingView, Platform, Alert, Pressable,
|
||||
} from 'react-native';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { useMessages } from '@/hooks/useMessages';
|
||||
import { encryptMessage } from '@/lib/crypto';
|
||||
import { sendEnvelope } from '@/lib/api';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||
import { randomId } from '@/lib/utils';
|
||||
import type { Message } from '@/lib/types';
|
||||
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { Composer, ComposerMode } from '@/components/Composer';
|
||||
import { AttachmentMenu } from '@/components/chat/AttachmentMenu';
|
||||
import { VideoCircleRecorder } from '@/components/chat/VideoCircleRecorder';
|
||||
import { clearContactNotifications } from '@/hooks/useNotifications';
|
||||
import { MessageBubble } from '@/components/chat/MessageBubble';
|
||||
import { DaySeparator } from '@/components/chat/DaySeparator';
|
||||
import { buildRows, Row } from '@/components/chat/rows';
|
||||
import type { Attachment } from '@/lib/types';
|
||||
|
||||
function shortAddr(a: string, n = 6) {
|
||||
if (!a) return '—';
|
||||
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||
}
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { id: contactAddress } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const messages = useStore(s => s.messages);
|
||||
const setMsgs = useStore(s => s.setMessages);
|
||||
const appendMsg = useStore(s => s.appendMessage);
|
||||
const clearUnread = useStore(s => s.clearUnread);
|
||||
|
||||
// При открытии чата: сбрасываем unread-счётчик и dismiss'им банер.
|
||||
useEffect(() => {
|
||||
if (!contactAddress) return;
|
||||
clearUnread(contactAddress);
|
||||
clearContactNotifications(contactAddress);
|
||||
}, [contactAddress, clearUnread]);
|
||||
|
||||
const contact = contacts.find(c => c.address === contactAddress);
|
||||
const chatMsgs = messages[contactAddress ?? ''] ?? [];
|
||||
const listRef = useRef<FlatList>(null);
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [peerTyping, setPeerTyping] = useState(false);
|
||||
const [composeMode, setComposeMode] = useState<ComposerMode>({ kind: 'new' });
|
||||
const [pendingAttach, setPendingAttach] = useState<Attachment | null>(null);
|
||||
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
|
||||
const [videoCircleOpen, setVideoCircleOpen] = useState(false);
|
||||
/**
|
||||
* ID сообщения, которое сейчас подсвечено (после jump-to-reply). На
|
||||
* ~2 секунды backgroundColor bubble'а мерцает accent-цветом.
|
||||
* `null` — ничего не подсвечено.
|
||||
*/
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const highlightClearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// ── Selection mode ───────────────────────────────────────────────────
|
||||
// Активируется первым long-press'ом на bubble'е. Header меняется на
|
||||
// toolbar с Forward/Delete/Cancel. Tap по bubble'у в selection mode
|
||||
// toggle'ит принадлежность к выборке. Cancel сбрасывает всё.
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const selectionMode = selectedIds.size > 0;
|
||||
|
||||
useMessages(contact?.x25519Pub ?? '');
|
||||
|
||||
// ── Typing indicator от peer'а ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!keyFile?.x25519_pub) return;
|
||||
const ws = getWSClient();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const off = ws.subscribe('typing:' + keyFile.x25519_pub, (frame) => {
|
||||
if (frame.event !== 'typing') return;
|
||||
const d = frame.data as { from?: string } | undefined;
|
||||
if (!contact?.x25519Pub || d?.from !== contact.x25519Pub) return;
|
||||
setPeerTyping(true);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => setPeerTyping(false), 3_000);
|
||||
});
|
||||
return () => { off(); if (timer) clearTimeout(timer); };
|
||||
}, [keyFile?.x25519_pub, contact?.x25519Pub]);
|
||||
|
||||
// Throttled типinginisi-ping собеседнику.
|
||||
const lastTypingSent = useRef(0);
|
||||
const onChange = useCallback((t: string) => {
|
||||
setText(t);
|
||||
if (!contact?.x25519Pub || !t.trim()) return;
|
||||
const now = Date.now();
|
||||
if (now - lastTypingSent.current < 2_000) return;
|
||||
lastTypingSent.current = now;
|
||||
getWSClient().sendTyping(contact.x25519Pub);
|
||||
}, [contact?.x25519Pub]);
|
||||
|
||||
// Восстановить сообщения из persistent-storage при первом заходе в чат.
|
||||
//
|
||||
// Важно: НЕ перезаписываем store пустым массивом — это стёрло бы
|
||||
// содержимое, которое уже лежит в zustand (например, из devSeed или
|
||||
// только что полученные по WS сообщения пока монтировались). Если
|
||||
// в кэше что-то есть — мержим: берём max(cached, in-store) по id.
|
||||
useEffect(() => {
|
||||
if (!contactAddress) return;
|
||||
loadMessages(contactAddress).then(cached => {
|
||||
if (!cached || cached.length === 0) return; // кэш пуст → оставляем store
|
||||
const existing = useStore.getState().messages[contactAddress] ?? [];
|
||||
const byId = new Map<string, Message>();
|
||||
for (const m of cached as Message[]) byId.set(m.id, m);
|
||||
for (const m of existing) byId.set(m.id, m); // store-версия свежее
|
||||
const merged = Array.from(byId.values()).sort((a, b) => a.timestamp - b.timestamp);
|
||||
setMsgs(contactAddress, merged);
|
||||
});
|
||||
}, [contactAddress, setMsgs]);
|
||||
|
||||
const name = contact?.username
|
||||
? `@${contact.username}`
|
||||
: contact?.alias ?? shortAddr(contactAddress ?? '');
|
||||
|
||||
// ── Compose actions ────────────────────────────────────────────────────
|
||||
const cancelCompose = useCallback(() => {
|
||||
setComposeMode({ kind: 'new' });
|
||||
setText('');
|
||||
setPendingAttach(null);
|
||||
}, []);
|
||||
|
||||
// buildRows выдаёт chronological [old → new]. FlatList работает
|
||||
// inverted, поэтому reverse'им: newest = data[0] = снизу экрана.
|
||||
// Определено тут (не позже) чтобы handlers типа onJumpToReply могли
|
||||
// искать индексы по id без forward-declaration.
|
||||
const rows = useMemo(() => {
|
||||
const chrono = buildRows(chatMsgs);
|
||||
return [...chrono].reverse();
|
||||
}, [chatMsgs]);
|
||||
|
||||
/**
|
||||
* Core send logic. Принимает явные text + attachment чтобы избегать
|
||||
* race'а со state updates при моментальной отправке голоса/видео.
|
||||
* Если передано null/undefined — берём из текущего state.
|
||||
*/
|
||||
const sendCore = useCallback(async (
|
||||
textArg: string | null = null,
|
||||
attachArg: Attachment | null | undefined = undefined,
|
||||
) => {
|
||||
if (!keyFile || !contact) return;
|
||||
const actualText = textArg !== null ? textArg : text;
|
||||
const actualAttach = attachArg !== undefined ? attachArg : pendingAttach;
|
||||
const hasText = !!actualText.trim();
|
||||
const hasAttach = !!actualAttach;
|
||||
if (!hasText && !hasAttach) return;
|
||||
if (!contact.x25519Pub) {
|
||||
Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (composeMode.kind === 'edit') {
|
||||
const target = chatMsgs.find(m => m.text === composeMode.text && m.mine);
|
||||
if (!target) { cancelCompose(); return; }
|
||||
const updated: Message = { ...target, text: actualText.trim(), edited: true };
|
||||
setMsgs(contact.address, chatMsgs.map(m => m.id === target.id ? updated : m));
|
||||
cancelCompose();
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
if (hasText) {
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
|
||||
);
|
||||
await sendEnvelope({
|
||||
senderPub: keyFile.x25519_pub,
|
||||
recipientPub: contact.x25519Pub,
|
||||
senderEd25519Pub: keyFile.pub_key,
|
||||
nonce, ciphertext,
|
||||
});
|
||||
}
|
||||
|
||||
const msg: Message = {
|
||||
id: randomId(),
|
||||
from: keyFile.x25519_pub,
|
||||
text: actualText.trim(),
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
mine: true,
|
||||
read: false,
|
||||
edited: false,
|
||||
attachment: actualAttach ?? undefined,
|
||||
replyTo: composeMode.kind === 'reply'
|
||||
? { id: composeMode.msgId, text: composeMode.preview, author: composeMode.author }
|
||||
: undefined,
|
||||
};
|
||||
appendMsg(contact.address, msg);
|
||||
await appendMessage(contact.address, msg);
|
||||
setText('');
|
||||
setPendingAttach(null);
|
||||
setComposeMode({ kind: 'new' });
|
||||
} catch (e: any) {
|
||||
Alert.alert('Send failed', e?.message ?? 'Unknown error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [
|
||||
text, keyFile, contact, composeMode, chatMsgs,
|
||||
setMsgs, cancelCompose, appendMsg, pendingAttach,
|
||||
]);
|
||||
|
||||
// UI send button
|
||||
const send = useCallback(() => sendCore(), [sendCore]);
|
||||
|
||||
// ── Selection handlers ───────────────────────────────────────────────
|
||||
// Long-press — входим в selection mode и сразу отмечаем это сообщение.
|
||||
const onMessageLongPress = useCallback((m: Message) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(m.id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Tap в selection mode — toggle принадлежности.
|
||||
const onMessageTap = useCallback((m: Message) => {
|
||||
if (!selectionMode) return;
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(m.id)) next.delete(m.id); else next.add(m.id);
|
||||
return next;
|
||||
});
|
||||
}, [selectionMode]);
|
||||
|
||||
const cancelSelection = useCallback(() => setSelectedIds(new Set()), []);
|
||||
|
||||
// ── Swipe-to-reply ──────────────────────────────────────────────────
|
||||
const onMessageReply = useCallback((m: Message) => {
|
||||
if (selectionMode) return;
|
||||
setComposeMode({
|
||||
kind: 'reply',
|
||||
msgId: m.id,
|
||||
author: m.mine ? 'You' : name,
|
||||
preview: m.text || (m.attachment ? `(${m.attachment.kind})` : ''),
|
||||
});
|
||||
}, [name, selectionMode]);
|
||||
|
||||
// ── Profile navigation (tap на аватарке / имени peer'а) ──────────────
|
||||
const onOpenPeerProfile = useCallback(() => {
|
||||
if (!contactAddress) return;
|
||||
router.push(`/(app)/profile/${contactAddress}` as never);
|
||||
}, [contactAddress]);
|
||||
|
||||
// ── Jump to reply: tap по quoted-блоку в bubble'е ────────────────────
|
||||
// Скроллим FlatList к оригинальному сообщению и зажигаем highlight
|
||||
// на ~2 секунды (highlightedId state + useEffect-driven анимация в
|
||||
// MessageBubble.highlightAnim).
|
||||
const onJumpToReply = useCallback((originalId: string) => {
|
||||
const idx = rows.findIndex(r => r.kind === 'msg' && r.msg.id === originalId);
|
||||
if (idx < 0) {
|
||||
// Сообщение не найдено (возможно удалено или ушло за пагинацию).
|
||||
// Silently no-op.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
listRef.current?.scrollToIndex({
|
||||
index: idx,
|
||||
animated: true,
|
||||
viewPosition: 0.3, // оригинал — чуть выше середины экрана, не прямо в центре
|
||||
});
|
||||
} catch {
|
||||
// scrollToIndex может throw'нуть если индекс за пределами рендера;
|
||||
// fallback: scrollToOffset на приблизительную позицию.
|
||||
}
|
||||
setHighlightedId(originalId);
|
||||
if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current);
|
||||
highlightClearTimer.current = setTimeout(() => {
|
||||
setHighlightedId(null);
|
||||
highlightClearTimer.current = null;
|
||||
}, 2000);
|
||||
}, [rows]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Selection actions ────────────────────────────────────────────────
|
||||
const deleteSelected = useCallback(() => {
|
||||
if (selectedIds.size === 0 || !contact) return;
|
||||
Alert.alert(
|
||||
`Delete ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}?`,
|
||||
'This removes them from your device. Other participants keep their copies.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
setMsgs(contact.address, chatMsgs.filter(m => !selectedIds.has(m.id)));
|
||||
setSelectedIds(new Set());
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [selectedIds, contact, chatMsgs, setMsgs]);
|
||||
|
||||
const forwardSelected = useCallback(() => {
|
||||
// Forward UI ещё не реализован — показываем stub. Пример потока:
|
||||
// 1. открыть "Forward to…" screen со списком контактов
|
||||
// 2. для каждого выбранного контакта — sendEnvelope с оригинальным
|
||||
// текстом, timestamp=now
|
||||
Alert.alert(
|
||||
`Forward ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}`,
|
||||
'Contact-picker screen is coming in the next iteration. For now, copy the text and paste.',
|
||||
[{ text: 'OK' }],
|
||||
);
|
||||
}, [selectedIds]);
|
||||
|
||||
// Copy доступен только когда выделено ровно одно сообщение.
|
||||
const copySelected = useCallback(async () => {
|
||||
if (selectedIds.size !== 1) return;
|
||||
const id = [...selectedIds][0];
|
||||
const msg = chatMsgs.find(m => m.id === id);
|
||||
if (!msg) return;
|
||||
await Clipboard.setStringAsync(msg.text);
|
||||
setSelectedIds(new Set());
|
||||
}, [selectedIds, chatMsgs]);
|
||||
|
||||
// В group-чатах над peer-сообщениями рисуется имя отправителя и его
|
||||
// аватар (group = несколько участников). В DM (direct) и каналах
|
||||
// отправитель ровно один, поэтому имя/аватар не нужны — убираем.
|
||||
const withSenderMeta = contact?.kind === 'group';
|
||||
|
||||
const renderRow = ({ item }: { item: Row }) => {
|
||||
if (item.kind === 'sep') return <DaySeparator label={item.label} />;
|
||||
return (
|
||||
<MessageBubble
|
||||
msg={item.msg}
|
||||
peerName={name}
|
||||
peerAddress={contactAddress}
|
||||
withSenderMeta={withSenderMeta}
|
||||
showName={item.showName}
|
||||
showAvatar={item.showAvatar}
|
||||
onReply={onMessageReply}
|
||||
onLongPress={onMessageLongPress}
|
||||
onTap={onMessageTap}
|
||||
onOpenProfile={onOpenPeerProfile}
|
||||
onJumpToReply={onJumpToReply}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(item.msg.id)}
|
||||
highlighted={highlightedId === item.msg.id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: '#000000' }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
// Увеличенный offset: composer поднимается выше клавиатуры с заметным
|
||||
// зазором (20px на iOS, 10px на Android) — пользователь не видит
|
||||
// прилипания к верхнему краю клавиатуры.
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 20 : 10}
|
||||
>
|
||||
{/* Header — использует общий компонент <Header>, чтобы соблюдать
|
||||
правила шапки приложения (left slot / centered title / right slot). */}
|
||||
<View style={{ paddingTop: insets.top, backgroundColor: '#000000' }}>
|
||||
{selectionMode ? (
|
||||
<Header
|
||||
divider
|
||||
left={<IconButton icon="close" size={36} onPress={cancelSelection} />}
|
||||
title={`${selectedIds.size} selected`}
|
||||
right={
|
||||
<>
|
||||
{selectedIds.size === 1 && (
|
||||
<IconButton icon="copy-outline" size={36} onPress={copySelected} />
|
||||
)}
|
||||
<IconButton icon="arrow-redo-outline" size={36} onPress={forwardSelected} />
|
||||
<IconButton icon="trash-outline" size={36} onPress={deleteSelected} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Header
|
||||
divider
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
title={
|
||||
<Pressable
|
||||
onPress={onOpenPeerProfile}
|
||||
hitSlop={4}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
|
||||
>
|
||||
<Avatar name={name} address={contactAddress} size={28} />
|
||||
<View style={{ minWidth: 0, flexShrink: 1 }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.2,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
{peerTyping && (
|
||||
<Text style={{ color: '#1d9bf0', fontSize: 11, fontWeight: '500' }}>
|
||||
typing…
|
||||
</Text>
|
||||
)}
|
||||
{!peerTyping && !contact?.x25519Pub && (
|
||||
<Text style={{ color: '#f0b35a', fontSize: 11 }}>
|
||||
waiting for key
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
}
|
||||
right={<IconButton icon="ellipsis-horizontal" size={36} />}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Messages — inverted: data[0] рендерится снизу, последующее —
|
||||
выше. Это стандартный chat-паттерн: FlatList сразу монтируется
|
||||
с "scroll position at bottom" без ручного scrollToEnd, и новые
|
||||
сообщения (добавляемые в начало reversed-массива) появляются
|
||||
внизу естественно. Никаких jerk'ов при открытии. */}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
inverted
|
||||
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
|
||||
renderItem={renderRow}
|
||||
contentContainerStyle={{ paddingVertical: 10 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
// Lazy render: only mount ~1.5 screens of bubbles initially,
|
||||
// render further batches as the user scrolls older. Keeps
|
||||
// initial paint fast on chats with thousands of messages.
|
||||
initialNumToRender={25}
|
||||
maxToRenderPerBatch={12}
|
||||
windowSize={10}
|
||||
removeClippedSubviews
|
||||
ListEmptyComponent={() => (
|
||||
<View style={{
|
||||
flex: 1, alignItems: 'center', justifyContent: 'center',
|
||||
paddingHorizontal: 32, gap: 10,
|
||||
transform: [{ scaleY: -1 }], // inverted flips cells; un-flip empty state
|
||||
}}>
|
||||
<Avatar name={name} address={contactAddress} size={72} />
|
||||
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
|
||||
Say hi to {name}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||
Your messages are end-to-end encrypted.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Composer — floating, прибит к низу. */}
|
||||
<View style={{ paddingBottom: Math.max(insets.bottom, 4) + 6, backgroundColor: '#000000' }}>
|
||||
<Composer
|
||||
mode={composeMode}
|
||||
onCancelMode={cancelCompose}
|
||||
text={text}
|
||||
onChangeText={onChange}
|
||||
onSend={send}
|
||||
sending={sending}
|
||||
onAttach={() => setAttachMenuOpen(true)}
|
||||
attachment={pendingAttach}
|
||||
onClearAttach={() => setPendingAttach(null)}
|
||||
onFinishVoice={(att) => {
|
||||
// Voice отправляется сразу — sendCore получает attachment
|
||||
// явным аргументом, минуя state-задержку.
|
||||
sendCore('', att);
|
||||
}}
|
||||
onStartVideoCircle={() => setVideoCircleOpen(true)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<AttachmentMenu
|
||||
visible={attachMenuOpen}
|
||||
onClose={() => setAttachMenuOpen(false)}
|
||||
onPick={(att) => setPendingAttach(att)}
|
||||
/>
|
||||
|
||||
<VideoCircleRecorder
|
||||
visible={videoCircleOpen}
|
||||
onClose={() => setVideoCircleOpen(false)}
|
||||
onFinish={(att) => {
|
||||
// Video-circle тоже отправляется сразу.
|
||||
sendCore('', att);
|
||||
}}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
28
client-app/app/(app)/chats/_layout.tsx
Normal file
28
client-app/app/(app)/chats/_layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* chats/_layout — вложенный Stack для chats/index и chats/[id].
|
||||
*
|
||||
* animation: 'none' — переходы между index и [id] анимирует родительский
|
||||
* AnimatedSlot (140ms, Easing.out cubic), обеспечивая единую скорость и
|
||||
* кривую между:
|
||||
* - chat open/close (index ↔ [id])
|
||||
* - tab switches (chats ↔ wallet и т.д.)
|
||||
* - sub-route open/close (settings, profile)
|
||||
*
|
||||
* gestureEnabled: true оставлен на случай если пользователь использует
|
||||
* нативный iOS edge-swipe — он вызовет router.back(), анимация пройдёт
|
||||
* через AnimatedSlot.
|
||||
*/
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ChatsLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: 'none',
|
||||
contentStyle: { backgroundColor: '#000000' },
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
105
client-app/app/(app)/chats/index.tsx
Normal file
105
client-app/app/(app)/chats/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Messages screen — список чатов в стиле референса.
|
||||
*
|
||||
* ┌ safe-area top
|
||||
* │ TabHeader (title зависит от connection state)
|
||||
* │ ─ FlatList (chat tiles) ─
|
||||
* └ NavBar (external)
|
||||
*
|
||||
* Фильтры и search убраны — лист один поток; requests доступны через
|
||||
* NavBar → notifications tab. FAB composer'а тоже убран (чат-лист
|
||||
* просто отражает существующие беседы, создание новых — через tab
|
||||
* "New chat" в NavBar'е).
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, Text, FlatList } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
|
||||
import type { Contact, Message } from '@/lib/types';
|
||||
|
||||
import { TabHeader } from '@/components/TabHeader';
|
||||
import { ChatTile } from '@/components/ChatTile';
|
||||
|
||||
export default function ChatsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const messages = useStore(s => s.messages);
|
||||
|
||||
// Статус подключения: online / connecting / offline.
|
||||
// Название шапки и цвет pip'а на аватаре зависят от него.
|
||||
const connStatus = useConnectionStatus();
|
||||
|
||||
const headerTitle =
|
||||
connStatus === 'online' ? 'Messages' :
|
||||
connStatus === 'connecting' ? 'Connecting…' :
|
||||
'Waiting for internet';
|
||||
|
||||
const dotColor =
|
||||
connStatus === 'online' ? '#3ba55d' : // green
|
||||
connStatus === 'connecting' ? '#f0b35a' : // amber
|
||||
'#f4212e'; // red
|
||||
|
||||
const lastOf = (c: Contact): Message | null => {
|
||||
const msgs = messages[c.address];
|
||||
return msgs && msgs.length ? msgs[msgs.length - 1] : null;
|
||||
};
|
||||
|
||||
// Сортировка по последней активности.
|
||||
const sorted = useMemo(() => {
|
||||
return [...contacts]
|
||||
.map(c => ({ c, last: lastOf(c) }))
|
||||
.sort((a, b) => {
|
||||
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
|
||||
const kb = b.last ? b.last.timestamp : b.c.addedAt / 1000;
|
||||
return kb - ka;
|
||||
})
|
||||
.map(x => x.c);
|
||||
}, [contacts, messages]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<TabHeader title={headerTitle} profileDotColor={dotColor} />
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<FlatList
|
||||
data={sorted}
|
||||
keyExtractor={c => c.address}
|
||||
renderItem={({ item }) => (
|
||||
<ChatTile
|
||||
contact={item}
|
||||
lastMessage={lastOf(item)}
|
||||
onPress={() => router.push(`/(app)/chats/${item.address}` as never)}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={{ paddingBottom: 40, flexGrow: 1 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
|
||||
{sorted.length === 0 && (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0, right: 0, top: 0, bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="chatbubbles-outline" size={42} color="#3a3a3a" style={{ marginBottom: 10 }} />
|
||||
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginBottom: 6 }}>
|
||||
No chats yet
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||
Use the search tab in the navbar to add your first contact.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
390
client-app/app/(app)/compose.tsx
Normal file
390
client-app/app/(app)/compose.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Post composer — full-screen modal for writing a new post.
|
||||
*
|
||||
* Twitter-style layout:
|
||||
* Header: [✕] (draft-ish) [Опубликовать button]
|
||||
* Body: [avatar] [multiline TextInput autogrow]
|
||||
* [hashtags preview chips]
|
||||
* [attachment preview + remove button]
|
||||
* Footer: [📷 attach] ··· [<count / 4000>] [~fee estimate]
|
||||
*
|
||||
* The flow:
|
||||
* 1. User types content; hashtags auto-parse for preview
|
||||
* 2. (Optional) pick image — client-side compression (expo-image-manipulator)
|
||||
* → resize to 1080px max, JPEG quality 50
|
||||
* 3. Tap "Опубликовать" → confirmation modal with fee
|
||||
* 4. Confirm → publishAndCommit() → navigate to post detail
|
||||
*
|
||||
* Failure modes:
|
||||
* - Size overflow (>256 KiB): blocked client-side with hint to compress
|
||||
* further or drop attachment
|
||||
* - Insufficient balance: show humanised error from submitTx
|
||||
* - Network down: toast "нет связи, попробуйте снова"
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, TextInput, Pressable, Alert, Image, KeyboardAvoidingView,
|
||||
Platform, ActivityIndicator, ScrollView, Linking,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { publishAndCommit, formatFee } from '@/lib/feed';
|
||||
import { humanizeTxError, getBalance } from '@/lib/api';
|
||||
|
||||
const MAX_CONTENT_LENGTH = 4000;
|
||||
const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize
|
||||
const IMAGE_MAX_DIM = 1080;
|
||||
const IMAGE_QUALITY = 0.5; // JPEG Q=50 — small, still readable
|
||||
|
||||
interface Attachment {
|
||||
uri: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
bytes: Uint8Array;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export default function ComposeScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const username = useStore(s => s.username);
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const [attach, setAttach] = useState<Attachment | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [balance, setBalance] = useState<number | null>(null);
|
||||
|
||||
// Fetch balance once so we can warn before publishing.
|
||||
useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
|
||||
}, [keyFile]);
|
||||
|
||||
// Estimated fee mirrors server's formula exactly. Displayed to the user
|
||||
// so they aren't surprised by a debit.
|
||||
const estimatedFee = useMemo(() => {
|
||||
const size = (new TextEncoder().encode(content)).length + (attach?.size ?? 0) + 128;
|
||||
return 1000 + size; // base 1000 + 1 µT/byte (matches blockchain constants)
|
||||
}, [content, attach]);
|
||||
|
||||
const totalBytes = useMemo(() => {
|
||||
return (new TextEncoder().encode(content)).length + (attach?.size ?? 0) + 128;
|
||||
}, [content, attach]);
|
||||
|
||||
const hashtags = useMemo(() => {
|
||||
const matches = content.match(/#[A-Za-z0-9_\u0400-\u04FF]{1,40}/g) || [];
|
||||
const seen = new Set<string>();
|
||||
return matches
|
||||
.map(m => m.slice(1).toLowerCase())
|
||||
.filter(t => !seen.has(t) && seen.add(t));
|
||||
}, [content]);
|
||||
|
||||
const canPublish = !busy && (content.trim().length > 0 || attach !== null)
|
||||
&& totalBytes <= MAX_POST_BYTES;
|
||||
|
||||
const onPickImage = async () => {
|
||||
if (picking) return;
|
||||
setPicking(true);
|
||||
try {
|
||||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert(
|
||||
'Нужен доступ к фото',
|
||||
'Откройте настройки и разрешите доступ к галерее.',
|
||||
[
|
||||
{ text: 'Отмена' },
|
||||
{ text: 'Настройки', onPress: () => Linking.openSettings() },
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
quality: 1,
|
||||
exif: false, // privacy: ask picker not to return EXIF
|
||||
});
|
||||
if (result.canceled || !result.assets[0]) return;
|
||||
|
||||
const asset = result.assets[0];
|
||||
|
||||
// Client-side compression: resize + re-encode. This is the FIRST
|
||||
// scrub pass — server will do another one (mandatory) before storing.
|
||||
const manipulated = await ImageManipulator.manipulateAsync(
|
||||
asset.uri,
|
||||
[{ resize: { width: IMAGE_MAX_DIM } }],
|
||||
{ compress: IMAGE_QUALITY, format: ImageManipulator.SaveFormat.JPEG },
|
||||
);
|
||||
|
||||
// Read the compressed bytes.
|
||||
const b64 = await FileSystem.readAsStringAsync(manipulated.uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
const bytes = base64ToBytes(b64);
|
||||
|
||||
if (bytes.length > MAX_POST_BYTES - 512) {
|
||||
Alert.alert(
|
||||
'Слишком большое',
|
||||
`Картинка ${Math.round(bytes.length / 1024)} KB — лимит ${MAX_POST_BYTES / 1024} KB. Попробуйте выбрать поменьше.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setAttach({
|
||||
uri: manipulated.uri,
|
||||
mime: 'image/jpeg',
|
||||
size: bytes.length,
|
||||
bytes,
|
||||
width: manipulated.width,
|
||||
height: manipulated.height,
|
||||
});
|
||||
} catch (e: any) {
|
||||
Alert.alert('Не удалось', String(e?.message ?? e));
|
||||
} finally {
|
||||
setPicking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPublish = async () => {
|
||||
if (!keyFile || !canPublish) return;
|
||||
|
||||
// Balance guard.
|
||||
if (balance !== null && balance < estimatedFee) {
|
||||
Alert.alert(
|
||||
'Недостаточно средств',
|
||||
`Нужно ${formatFee(estimatedFee)}, на балансе ${formatFee(balance)}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Опубликовать пост?',
|
||||
`Цена: ${formatFee(estimatedFee)}\nРазмер: ${Math.round(totalBytes / 1024 * 10) / 10} KB`,
|
||||
[
|
||||
{ text: 'Отмена', style: 'cancel' },
|
||||
{
|
||||
text: 'Опубликовать',
|
||||
onPress: async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const postID = await publishAndCommit({
|
||||
author: keyFile.pub_key,
|
||||
privKey: keyFile.priv_key,
|
||||
content: content.trim(),
|
||||
attachment: attach?.bytes,
|
||||
attachmentMIME: attach?.mime,
|
||||
});
|
||||
// Close composer and open the new post.
|
||||
router.replace(`/(app)/feed/${postID}` as never);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Не удалось опубликовать', humanizeTxError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: '#000000' }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
paddingTop: insets.top + 8,
|
||||
paddingBottom: 12,
|
||||
paddingHorizontal: 14,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#141414',
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={() => router.back()} hitSlop={8}>
|
||||
<Ionicons name="close" size={26} color="#ffffff" />
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Pressable
|
||||
onPress={onPublish}
|
||||
disabled={!canPublish}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 18, paddingVertical: 9,
|
||||
borderRadius: 999,
|
||||
backgroundColor: canPublish ? (pressed ? '#1a8cd8' : '#1d9bf0') : '#1f1f1f',
|
||||
})}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
color: canPublish ? '#ffffff' : '#5a5a5a',
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Опубликовать
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 80 }}
|
||||
>
|
||||
{/* Avatar + TextInput row */}
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<Avatar name={username ?? '?'} address={keyFile?.pub_key} size={40} />
|
||||
<TextInput
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Что происходит?"
|
||||
placeholderTextColor="#5a5a5a"
|
||||
multiline
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
marginLeft: 10,
|
||||
color: '#ffffff',
|
||||
fontSize: 17,
|
||||
lineHeight: 22,
|
||||
minHeight: 120,
|
||||
paddingTop: 4,
|
||||
textAlignVertical: 'top',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Hashtag preview */}
|
||||
{hashtags.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 14, marginLeft: 50 }}>
|
||||
{hashtags.map(tag => (
|
||||
<View
|
||||
key={tag}
|
||||
style={{
|
||||
paddingHorizontal: 10, paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#081a2a',
|
||||
borderWidth: 1, borderColor: '#11385a',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
|
||||
#{tag}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Attachment preview */}
|
||||
{attach && (
|
||||
<View style={{ marginTop: 14, marginLeft: 50 }}>
|
||||
<View
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: attach.uri }}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: attach.width && attach.height
|
||||
? attach.width / attach.height
|
||||
: 4 / 3,
|
||||
backgroundColor: '#0a0a0a',
|
||||
}}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => setAttach(null)}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({
|
||||
position: 'absolute',
|
||||
top: 8, right: 8,
|
||||
width: 28, height: 28, borderRadius: 14,
|
||||
backgroundColor: pressed ? 'rgba(0,0,0,0.9)' : 'rgba(0,0,0,0.75)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<Ionicons name="close" size={16} color="#ffffff" />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6 }}>
|
||||
{Math.round(attach.size / 1024)} KB · метаданные удалят на сервере
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer: attach / counter / fee */}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
paddingBottom: Math.max(insets.bottom, 10),
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#141414',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={onPickImage}
|
||||
disabled={picking || !!attach}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed || picking || attach ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
{picking
|
||||
? <ActivityIndicator color="#1d9bf0" size="small" />
|
||||
: <Ionicons name="image-outline" size={22} color="#1d9bf0" />}
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text
|
||||
style={{
|
||||
color: totalBytes > MAX_POST_BYTES ? '#f4212e'
|
||||
: totalBytes > MAX_POST_BYTES * 0.85 ? '#f0b35a'
|
||||
: '#6a6a6a',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{Math.round(totalBytes / 1024 * 10) / 10} / {MAX_POST_BYTES / 1024} KB
|
||||
</Text>
|
||||
<View style={{ width: 1, height: 14, backgroundColor: '#1f1f1f' }} />
|
||||
<Text style={{ color: '#6a6a6a', fontSize: 12 }}>
|
||||
≈ {formatFee(estimatedFee)}
|
||||
</Text>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const binary = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
206
client-app/app/(app)/feed/[id].tsx
Normal file
206
client-app/app/(app)/feed/[id].tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Post detail — full view of one post with stats, thread context, and a
|
||||
* lazy-rendered image attachment.
|
||||
*
|
||||
* Why a dedicated screen?
|
||||
* - PostCard in the timeline intentionally doesn't render attachments
|
||||
* (would explode initial render time with N images).
|
||||
* - Per-post stats (views, likes, liked_by_me) want a fresh refresh
|
||||
* on open; timeline batches but not at the per-second cadence a
|
||||
* reader expects when they just tapped in.
|
||||
*
|
||||
* Layout:
|
||||
* [← back · Пост]
|
||||
* [PostCard (full — with attachment)]
|
||||
* [stats bar: views · likes · fee]
|
||||
* [— reply affordance below (future)]
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { PostCard } from '@/components/feed/PostCard';
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
fetchPost, fetchStats, bumpView, formatCount, formatFee,
|
||||
type FeedPostItem, type PostStats,
|
||||
} from '@/lib/feed';
|
||||
|
||||
export default function PostDetailScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { id: postID } = useLocalSearchParams<{ id: string }>();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [post, setPost] = useState<FeedPostItem | null>(null);
|
||||
const [stats, setStats] = useState<PostStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!postID) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [p, s] = await Promise.all([
|
||||
fetchPost(postID),
|
||||
fetchStats(postID, keyFile?.pub_key),
|
||||
]);
|
||||
setPost(p);
|
||||
setStats(s);
|
||||
if (p) bumpView(postID); // fire-and-forget
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [postID, keyFile]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const onStatsChanged = useCallback(async () => {
|
||||
if (!postID) return;
|
||||
const s = await fetchStats(postID, keyFile?.pub_key);
|
||||
if (s) setStats(s);
|
||||
}, [postID, keyFile]);
|
||||
|
||||
const onDeleted = useCallback(() => {
|
||||
// Go back to feed — the post is gone.
|
||||
router.back();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
divider
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
title="Пост"
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ActivityIndicator color="#1d9bf0" />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={{ padding: 24 }}>
|
||||
<Text style={{ color: '#f4212e' }}>{error}</Text>
|
||||
</View>
|
||||
) : !post ? (
|
||||
<View style={{ padding: 24, alignItems: 'center' }}>
|
||||
<Ionicons name="trash-outline" size={32} color="#6a6a6a" />
|
||||
<Text style={{ color: '#8b8b8b', marginTop: 10 }}>
|
||||
Пост удалён или больше недоступен
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView>
|
||||
{/* `compact` tells PostCard to drop the 5-line body cap and
|
||||
render the attachment at its natural aspect ratio instead
|
||||
of the portrait-cropped timeline preview. */}
|
||||
<PostCard
|
||||
compact
|
||||
post={{ ...post, likes: stats?.likes ?? post.likes, views: stats?.views ?? post.views }}
|
||||
likedByMe={stats?.liked_by_me ?? false}
|
||||
onStatsChanged={onStatsChanged}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
|
||||
{/* Detailed stats block */}
|
||||
<View
|
||||
style={{
|
||||
marginHorizontal: 14,
|
||||
marginTop: 12,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: '#5a5a5a',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1.2,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
Информация о посте
|
||||
</Text>
|
||||
|
||||
<DetailRow label="Просмотров" value={formatCount(stats?.views ?? post.views)} />
|
||||
<DetailRow label="Лайков" value={formatCount(stats?.likes ?? post.likes)} />
|
||||
<DetailRow label="Размер" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} />
|
||||
<DetailRow
|
||||
label="Стоимость публикации"
|
||||
value={formatFee(1000 + post.size)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Хостинг"
|
||||
value={shortAddr(post.hosting_relay)}
|
||||
mono
|
||||
/>
|
||||
|
||||
{post.hashtags && post.hashtags.length > 0 && (
|
||||
<>
|
||||
<View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 10 }} />
|
||||
<Text style={{ color: '#5a5a5a', fontSize: 11, marginBottom: 6 }}>
|
||||
Хештеги
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
||||
{post.hashtags.map(tag => (
|
||||
<Text
|
||||
key={tag}
|
||||
onPress={() => router.push(`/(app)/feed/tag/${encodeURIComponent(tag)}` as never)}
|
||||
style={{
|
||||
color: '#1d9bf0',
|
||||
fontSize: 13,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
backgroundColor: '#081a2a',
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
#{tag}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ height: 80 }} />
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', paddingVertical: 3 }}>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
fontFamily: mono ? 'monospace' : undefined,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function shortAddr(a: string, n = 6): string {
|
||||
if (!a) return '—';
|
||||
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||
}
|
||||
23
client-app/app/(app)/feed/_layout.tsx
Normal file
23
client-app/app/(app)/feed/_layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Feed sub-routes layout — native Stack for /(app)/feed/[id] and
|
||||
* /(app)/feed/tag/[tag]. The tab root itself (app/(app)/feed.tsx) lives
|
||||
* OUTSIDE this folder so it keeps the outer Slot-level navigation.
|
||||
*
|
||||
* Why a Stack here? AnimatedSlot in the parent is stack-less; without
|
||||
* this nested Stack, `router.back()` from a post detail / hashtag feed
|
||||
* couldn't find its caller.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function FeedLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: '#000000' },
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
431
client-app/app/(app)/feed/index.tsx
Normal file
431
client-app/app/(app)/feed/index.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Feed tab — Twitter-style timeline with three sources:
|
||||
*
|
||||
* Подписки → /feed/timeline?follower=me (posts from people I follow)
|
||||
* Для вас → /feed/foryou?pub=me (recommendations)
|
||||
* В тренде → /feed/trending?window=24 (most-engaged in last 24h)
|
||||
*
|
||||
* Floating compose button (bottom-right) → /(app)/compose modal.
|
||||
*
|
||||
* Uses a single FlatList per tab with pull-to-refresh + optimistic
|
||||
* local updates. Stats (likes, likedByMe) are fetched once per refresh
|
||||
* and piggy-backed onto each PostCard via props; the card does the
|
||||
* optimistic toggle locally until the next refresh reconciles.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, FlatList, Pressable, RefreshControl, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
import { TabHeader } from '@/components/TabHeader';
|
||||
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView,
|
||||
type FeedPostItem,
|
||||
} from '@/lib/feed';
|
||||
import { getDevSeedFeed } from '@/lib/devSeedFeed';
|
||||
|
||||
type TabKey = 'following' | 'foryou' | 'trending';
|
||||
|
||||
const TAB_LABELS: Record<TabKey, string> = {
|
||||
following: 'Подписки',
|
||||
foryou: 'Для вас',
|
||||
trending: 'В тренде',
|
||||
};
|
||||
|
||||
export default function FeedScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [tab, setTab] = useState<TabKey>('foryou'); // default: discovery
|
||||
const [posts, setPosts] = useState<FeedPostItem[]>([]);
|
||||
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [exhausted, setExhausted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// Guard against rapid tab switches overwriting each other's results.
|
||||
const requestRef = useRef(0);
|
||||
|
||||
const loadPosts = useCallback(async (isRefresh = false) => {
|
||||
if (!keyFile) return;
|
||||
if (isRefresh) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
setError(null);
|
||||
setExhausted(false);
|
||||
|
||||
const seq = ++requestRef.current;
|
||||
try {
|
||||
let items: FeedPostItem[] = [];
|
||||
switch (tab) {
|
||||
case 'following':
|
||||
items = await fetchTimeline(keyFile.pub_key, { limit: PAGE_SIZE });
|
||||
break;
|
||||
case 'foryou':
|
||||
items = await fetchForYou(keyFile.pub_key, PAGE_SIZE);
|
||||
break;
|
||||
case 'trending':
|
||||
items = await fetchTrending(24, PAGE_SIZE);
|
||||
break;
|
||||
}
|
||||
if (seq !== requestRef.current) return; // stale response
|
||||
|
||||
// Dev-only fallback: if the node has no real posts yet, surface
|
||||
// synthetic ones so we can scroll + tap. Stripped from production.
|
||||
if (items.length === 0) {
|
||||
items = getDevSeedFeed();
|
||||
}
|
||||
|
||||
setPosts(items);
|
||||
// If the server returned fewer than PAGE_SIZE, we already have
|
||||
// everything — disable further paginated fetches.
|
||||
if (items.length < PAGE_SIZE) setExhausted(true);
|
||||
|
||||
// Batch-fetch liked_by_me (bounded concurrency — 6 at a time).
|
||||
const liked = new Set<string>();
|
||||
const chunks = chunk(items, 6);
|
||||
for (const group of chunks) {
|
||||
const results = await Promise.all(
|
||||
group.map(p => fetchStats(p.post_id, keyFile.pub_key)),
|
||||
);
|
||||
results.forEach((s, i) => {
|
||||
if (s?.liked_by_me) liked.add(group[i].post_id);
|
||||
});
|
||||
}
|
||||
if (seq !== requestRef.current) return;
|
||||
setLikedSet(liked);
|
||||
} catch (e: any) {
|
||||
if (seq !== requestRef.current) return;
|
||||
const msg = String(e?.message ?? e);
|
||||
// Network / 404 is benign — node just unreachable or empty. In __DEV__
|
||||
// fall back to synthetic seed posts so the scroll / tap UI stays
|
||||
// testable; in production this path shows the empty state.
|
||||
if (/Network request failed|→\s*404/.test(msg)) {
|
||||
setPosts(getDevSeedFeed());
|
||||
setExhausted(true);
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
} finally {
|
||||
if (seq !== requestRef.current) return;
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [keyFile, tab]);
|
||||
|
||||
/**
|
||||
* loadMore — paginate older posts when the user scrolls to the end
|
||||
* of the list. Only the "following" and "foryou"/trending-less-useful
|
||||
* paths actually support server-side pagination via the `before`
|
||||
* cursor; foryou/trending return their ranked top-N which is by
|
||||
* design not paginated (users very rarely scroll past 20 hot posts).
|
||||
*
|
||||
* We key the next page off the oldest post currently in state. If
|
||||
* the server returns less than PAGE_SIZE items, we mark the list as
|
||||
* exhausted to stop further fetches.
|
||||
*/
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!keyFile || loadingMore || exhausted || refreshing || loading) return;
|
||||
if (posts.length === 0) return;
|
||||
// foryou / trending are ranked, not ordered — no stable cursor to
|
||||
// paginate against in v2.0.0. Skip.
|
||||
if (tab === 'foryou' || tab === 'trending') return;
|
||||
|
||||
const oldest = posts[posts.length - 1];
|
||||
const before = oldest?.created_at;
|
||||
if (!before) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
const seq = requestRef.current; // don't bump — this is additive
|
||||
try {
|
||||
const next = await fetchTimeline(keyFile.pub_key, {
|
||||
limit: PAGE_SIZE, before,
|
||||
});
|
||||
if (seq !== requestRef.current) return;
|
||||
if (next.length === 0) {
|
||||
setExhausted(true);
|
||||
return;
|
||||
}
|
||||
// Dedup by post_id (could overlap on the boundary ts).
|
||||
setPosts(prev => {
|
||||
const have = new Set(prev.map(p => p.post_id));
|
||||
const merged = [...prev];
|
||||
for (const p of next) {
|
||||
if (!have.has(p.post_id)) merged.push(p);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
if (next.length < PAGE_SIZE) setExhausted(true);
|
||||
} catch {
|
||||
// Don't escalate to error UI for pagination failures — just stop.
|
||||
setExhausted(true);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [keyFile, loadingMore, exhausted, refreshing, loading, posts, tab]);
|
||||
|
||||
useEffect(() => { loadPosts(false); }, [loadPosts]);
|
||||
|
||||
const onStatsChanged = useCallback(async (postID: string) => {
|
||||
if (!keyFile) return;
|
||||
const stats = await fetchStats(postID, keyFile.pub_key);
|
||||
if (!stats) return;
|
||||
setPosts(ps => ps.map(p => p.post_id === postID
|
||||
? { ...p, likes: stats.likes, views: stats.views }
|
||||
: p));
|
||||
setLikedSet(s => {
|
||||
const next = new Set(s);
|
||||
if (stats.liked_by_me) next.add(postID);
|
||||
else next.delete(postID);
|
||||
return next;
|
||||
});
|
||||
}, [keyFile]);
|
||||
|
||||
const onDeleted = useCallback((postID: string) => {
|
||||
setPosts(ps => ps.filter(p => p.post_id !== postID));
|
||||
}, []);
|
||||
|
||||
// View counter: fire bumpView once when a card scrolls into view.
|
||||
const viewedRef = useRef<Set<string>>(new Set());
|
||||
const onViewableItemsChanged = useRef(({ viewableItems }: { viewableItems: Array<{ item: FeedPostItem; isViewable: boolean }> }) => {
|
||||
for (const { item, isViewable } of viewableItems) {
|
||||
if (isViewable && !viewedRef.current.has(item.post_id)) {
|
||||
viewedRef.current.add(item.post_id);
|
||||
bumpView(item.post_id);
|
||||
}
|
||||
}
|
||||
}).current;
|
||||
|
||||
const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 60, minimumViewTime: 1000 }).current;
|
||||
|
||||
const emptyHint = useMemo(() => {
|
||||
switch (tab) {
|
||||
case 'following': return 'Подпишитесь на кого-нибудь, чтобы увидеть их посты здесь.';
|
||||
case 'foryou': return 'Пока нет рекомендаций — возвращайтесь позже.';
|
||||
case 'trending': return 'В этой ленте пока тихо.';
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<TabHeader title="Лента" />
|
||||
|
||||
{/* Tab strip — три таба, равномерно распределены по ширине
|
||||
(justifyContent: space-between). Каждый Pressable hug'ает
|
||||
свой контент — табы НЕ тянутся на 1/3 ширины, а жмутся к
|
||||
своему лейблу, что даёт воздух между ними. Индикатор активной
|
||||
вкладки — тонкая полоска под лейблом. */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
{(Object.keys(TAB_LABELS) as TabKey[]).map(key => (
|
||||
<Pressable
|
||||
key={key}
|
||||
onPress={() => setTab(key)}
|
||||
style={({ pressed }) => ({
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 6,
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: tab === key ? '#ffffff' : '#6a6a6a',
|
||||
fontWeight: tab === key ? '700' : '500',
|
||||
fontSize: 15,
|
||||
letterSpacing: -0.1,
|
||||
}}
|
||||
>
|
||||
{TAB_LABELS[key]}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
marginTop: 10,
|
||||
width: tab === key ? 28 : 0,
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: '#1d9bf0',
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Feed list */}
|
||||
<FlatList
|
||||
data={posts}
|
||||
keyExtractor={p => p.post_id}
|
||||
renderItem={({ item }) => (
|
||||
<PostCard
|
||||
post={item}
|
||||
likedByMe={likedSet.has(item.post_id)}
|
||||
onStatsChanged={onStatsChanged}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
)}
|
||||
ItemSeparatorComponent={PostSeparator}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.6}
|
||||
ListFooterComponent={
|
||||
loadingMore ? (
|
||||
<View style={{ paddingVertical: 20, alignItems: 'center' }}>
|
||||
<ActivityIndicator color="#1d9bf0" size="small" />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
// Lazy-render tuning: start with one viewport's worth of posts,
|
||||
// keep a small window around the visible area. Works together
|
||||
// with onEndReached pagination for smooth long-feed scroll.
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={8}
|
||||
windowSize={7}
|
||||
removeClippedSubviews
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => loadPosts(true)}
|
||||
tintColor="#1d9bf0"
|
||||
/>
|
||||
}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
viewabilityConfig={viewabilityConfig}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<View style={{ paddingTop: 80, alignItems: 'center' }}>
|
||||
<ActivityIndicator color="#1d9bf0" />
|
||||
</View>
|
||||
) : error ? (
|
||||
<EmptyState
|
||||
icon="alert-circle-outline"
|
||||
title="Не удалось загрузить ленту"
|
||||
subtitle={error}
|
||||
onRetry={() => loadPosts(false)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="newspaper-outline"
|
||||
title="Здесь пока пусто"
|
||||
subtitle={emptyHint}
|
||||
/>
|
||||
)
|
||||
}
|
||||
contentContainerStyle={
|
||||
posts.length === 0
|
||||
? { flexGrow: 1 }
|
||||
: { paddingTop: 8, paddingBottom: 24 }
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Floating compose button.
|
||||
*
|
||||
* Pressable's dynamic-function style sometimes drops absolute
|
||||
* positioning on re-render on some RN versions — we've seen the
|
||||
* button slide to the left edge after the first render. Wrap it
|
||||
* in a plain absolute-positioned View so positioning lives on a
|
||||
* stable element; the Pressable inside only declares its size
|
||||
* and visuals. The parent Feed screen's container ends at the
|
||||
* NavBar top (see (app)/_layout.tsx), so bottom: 12 means 12px
|
||||
* above the NavBar on every device. */}
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => router.push('/(app)/compose' as never)}
|
||||
style={({ pressed }) => ({
|
||||
width: 56, height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 6,
|
||||
elevation: 8,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="create-outline" size={24} color="#ffffff" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty state ─────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({
|
||||
icon, title, subtitle, onRetry,
|
||||
}: {
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={{
|
||||
flex: 1,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
paddingHorizontal: 32, paddingVertical: 80,
|
||||
}}>
|
||||
<View
|
||||
style={{
|
||||
width: 64, height: 64, borderRadius: 16,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<Ionicons name={icon} size={28} color="#6a6a6a" />
|
||||
</View>
|
||||
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginBottom: 6 }}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
{onRetry && (
|
||||
<Pressable
|
||||
onPress={onRetry}
|
||||
style={({ pressed }) => ({
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 20, paddingVertical: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
|
||||
Попробовать снова
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function chunk<T>(arr: T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
138
client-app/app/(app)/feed/tag/[tag].tsx
Normal file
138
client-app/app/(app)/feed/tag/[tag].tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Hashtag feed — all posts tagged with #tag, newest first.
|
||||
*
|
||||
* Route: /(app)/feed/tag/[tag]
|
||||
* Triggered by tapping a hashtag inside any PostCard's body.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, FlatList, RefreshControl, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed';
|
||||
|
||||
export default function HashtagScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { tag: rawTag } = useLocalSearchParams<{ tag: string }>();
|
||||
const tag = (rawTag ?? '').replace(/^#/, '').toLowerCase();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [posts, setPosts] = useState<FeedPostItem[]>([]);
|
||||
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const seq = useRef(0);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (!tag) return;
|
||||
if (isRefresh) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
|
||||
const id = ++seq.current;
|
||||
try {
|
||||
const items = await fetchHashtag(tag, 60);
|
||||
if (id !== seq.current) return;
|
||||
setPosts(items);
|
||||
|
||||
const liked = new Set<string>();
|
||||
if (keyFile) {
|
||||
for (const p of items) {
|
||||
const s = await fetchStats(p.post_id, keyFile.pub_key);
|
||||
if (s?.liked_by_me) liked.add(p.post_id);
|
||||
}
|
||||
}
|
||||
if (id !== seq.current) return;
|
||||
setLikedSet(liked);
|
||||
} catch {
|
||||
if (id !== seq.current) return;
|
||||
setPosts([]);
|
||||
} finally {
|
||||
if (id !== seq.current) return;
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [tag, keyFile]);
|
||||
|
||||
useEffect(() => { load(false); }, [load]);
|
||||
|
||||
const onStatsChanged = useCallback(async (postID: string) => {
|
||||
if (!keyFile) return;
|
||||
const s = await fetchStats(postID, keyFile.pub_key);
|
||||
if (!s) return;
|
||||
setPosts(ps => ps.map(p => p.post_id === postID
|
||||
? { ...p, likes: s.likes, views: s.views } : p));
|
||||
setLikedSet(set => {
|
||||
const next = new Set(set);
|
||||
if (s.liked_by_me) next.add(postID); else next.delete(postID);
|
||||
return next;
|
||||
});
|
||||
}, [keyFile]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
divider
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
title={`#${tag}`}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={posts}
|
||||
keyExtractor={p => p.post_id}
|
||||
renderItem={({ item }) => (
|
||||
<PostCard
|
||||
post={item}
|
||||
likedByMe={likedSet.has(item.post_id)}
|
||||
onStatsChanged={onStatsChanged}
|
||||
/>
|
||||
)}
|
||||
ItemSeparatorComponent={PostSeparator}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={8}
|
||||
windowSize={7}
|
||||
removeClippedSubviews
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => load(true)}
|
||||
tintColor="#1d9bf0"
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<View style={{ paddingTop: 80, alignItems: 'center' }}>
|
||||
<ActivityIndicator color="#1d9bf0" />
|
||||
</View>
|
||||
) : (
|
||||
<View style={{
|
||||
flex: 1,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
paddingHorizontal: 32, paddingVertical: 80,
|
||||
}}>
|
||||
<Ionicons name="pricetag-outline" size={32} color="#6a6a6a" />
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
|
||||
Пока нет постов с этим тегом
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}>
|
||||
Будьте первым — напишите пост с #{tag}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
contentContainerStyle={
|
||||
posts.length === 0
|
||||
? { flexGrow: 1 }
|
||||
: { paddingTop: 8, paddingBottom: 24 }
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
288
client-app/app/(app)/new-contact.tsx
Normal file
288
client-app/app/(app)/new-contact.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Add new contact — dark minimalist, inspired by the reference.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Пользователь вводит @username или hex pubkey / DC-address.
|
||||
* 2. Жмёт Search → resolveUsername → getIdentity.
|
||||
* 3. Показываем preview (avatar + имя + address + наличие x25519).
|
||||
* 4. Выбирает fee (chip-selector) + вводит intro.
|
||||
* 5. Submit → CONTACT_REQUEST tx.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, Alert, Pressable, TextInput, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getIdentity, buildContactRequestTx, submitTx, resolveUsername, humanizeTxError } from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { formatAmount } from '@/lib/utils';
|
||||
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { SearchBar } from '@/components/SearchBar';
|
||||
|
||||
const MIN_CONTACT_FEE = 5000;
|
||||
const FEE_TIERS = [
|
||||
{ value: 5_000, label: 'Min' },
|
||||
{ value: 10_000, label: 'Standard' },
|
||||
{ value: 50_000, label: 'Priority' },
|
||||
];
|
||||
|
||||
interface Resolved {
|
||||
address: string;
|
||||
nickname?: string;
|
||||
x25519?: string;
|
||||
}
|
||||
|
||||
export default function NewContactScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const settings = useStore(s => s.settings);
|
||||
const balance = useStore(s => s.balance);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [intro, setIntro] = useState('');
|
||||
const [fee, setFee] = useState(MIN_CONTACT_FEE);
|
||||
const [resolved, setResolved] = useState<Resolved | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function search() {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
setSearching(true); setResolved(null); setError(null);
|
||||
try {
|
||||
let address = q;
|
||||
if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) {
|
||||
const name = q.replace('@', '');
|
||||
const addr = await resolveUsername(settings.contractId, name);
|
||||
if (!addr) { setError(`@${name} is not registered on this chain`); return; }
|
||||
address = addr;
|
||||
}
|
||||
const identity = await getIdentity(address);
|
||||
setResolved({
|
||||
address: identity?.pub_key ?? address,
|
||||
nickname: identity?.nickname || undefined,
|
||||
x25519: identity?.x25519_pub || undefined,
|
||||
});
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? 'Lookup failed');
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRequest() {
|
||||
if (!resolved || !keyFile) return;
|
||||
if (balance < fee + 1000) {
|
||||
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (fee + network).`);
|
||||
return;
|
||||
}
|
||||
setSending(true); setError(null);
|
||||
try {
|
||||
const tx = buildContactRequestTx({
|
||||
from: keyFile.pub_key,
|
||||
to: resolved.address,
|
||||
contactFee: fee,
|
||||
intro: intro.trim() || undefined,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
Alert.alert(
|
||||
'Request sent',
|
||||
`A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
|
||||
[{ text: 'OK', onPress: () => router.back() }],
|
||||
);
|
||||
} catch (e: any) {
|
||||
setError(humanizeTxError(e));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const displayName = resolved
|
||||
? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address))
|
||||
: '';
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="New chat"
|
||||
divider={false}
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
/>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 14, paddingBottom: 120 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 14 }}>
|
||||
Enter a <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text>, a
|
||||
hex pubkey or a <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC…</Text> address.
|
||||
</Text>
|
||||
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="@alice or hex / DC address"
|
||||
onSubmitEditing={search}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
onPress={search}
|
||||
disabled={searching || !query.trim()}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 11, borderRadius: 999, marginTop: 12,
|
||||
backgroundColor: !query.trim() || searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
})}
|
||||
>
|
||||
{searching ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{error && (
|
||||
<View style={{
|
||||
marginTop: 14,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(244,33,46,0.08)',
|
||||
borderWidth: 1, borderColor: 'rgba(244,33,46,0.25)',
|
||||
}}>
|
||||
<Text style={{ color: '#f4212e', fontSize: 13 }}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Resolved profile card */}
|
||||
{resolved && (
|
||||
<>
|
||||
<View style={{
|
||||
marginTop: 18,
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
}}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
||||
<Avatar
|
||||
name={displayName}
|
||||
address={resolved.address}
|
||||
size={52}
|
||||
dotColor={resolved.x25519 ? '#3ba55d' : '#f0b35a'}
|
||||
/>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 16 }}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace', marginTop: 2 }} numberOfLines={1}>
|
||||
{shortAddr(resolved.address, 10)}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 5, gap: 4 }}>
|
||||
<Ionicons
|
||||
name={resolved.x25519 ? 'lock-closed' : 'time-outline'}
|
||||
size={11}
|
||||
color={resolved.x25519 ? '#3ba55d' : '#f0b35a'}
|
||||
/>
|
||||
<Text style={{
|
||||
color: resolved.x25519 ? '#3ba55d' : '#f0b35a',
|
||||
fontSize: 11, fontWeight: '500',
|
||||
}}>
|
||||
{resolved.x25519 ? 'E2E-ready' : 'Key not published yet'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Intro */}
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 18, marginBottom: 6 }}>
|
||||
Intro (optional, plaintext on-chain)
|
||||
</Text>
|
||||
<TextInput
|
||||
value={intro}
|
||||
onChangeText={setIntro}
|
||||
placeholder="Hey, it's Jordan from the conference"
|
||||
placeholderTextColor="#5a5a5a"
|
||||
multiline
|
||||
maxLength={140}
|
||||
style={{
|
||||
color: '#ffffff', fontSize: 14,
|
||||
backgroundColor: '#0a0a0a', borderRadius: 10,
|
||||
paddingHorizontal: 12, paddingVertical: 10,
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
minHeight: 80, textAlignVertical: 'top',
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: '#5a5a5a', fontSize: 11, textAlign: 'right', marginTop: 4 }}>
|
||||
{intro.length}/140
|
||||
</Text>
|
||||
|
||||
{/* Fee tier */}
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 14, marginBottom: 6 }}>
|
||||
Anti-spam fee (goes to recipient)
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
{FEE_TIERS.map(t => {
|
||||
const active = fee === t.value;
|
||||
return (
|
||||
<Pressable
|
||||
key={t.value}
|
||||
onPress={() => setFee(t.value)}
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: active ? '#ffffff' : pressed ? '#1a1a1a' : '#111111',
|
||||
borderWidth: 1, borderColor: active ? '#ffffff' : '#1f1f1f',
|
||||
})}
|
||||
>
|
||||
<Text style={{
|
||||
color: active ? '#000' : '#ffffff',
|
||||
fontWeight: '700', fontSize: 13,
|
||||
}}>
|
||||
{t.label}
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: active ? '#333' : '#8b8b8b',
|
||||
fontSize: 11, marginTop: 2,
|
||||
}}>
|
||||
{formatAmount(t.value)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Submit */}
|
||||
<Pressable
|
||||
onPress={sendRequest}
|
||||
disabled={sending}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 13, borderRadius: 999, marginTop: 20,
|
||||
backgroundColor: sending ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
})}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
|
||||
Send request · {formatAmount(fee + 1000)}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
323
client-app/app/(app)/profile/[address].tsx
Normal file
323
client-app/app/(app)/profile/[address].tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Profile screen — info card about any address (yours or someone else's),
|
||||
* plus a Follow/Unfollow button. Posts are intentionally NOT shown here
|
||||
* — this screen is chat-oriented ("who is on the other side of this
|
||||
* conversation"); the feed tab + /feed/author/{pub} is where you go to
|
||||
* browse someone's timeline.
|
||||
*
|
||||
* Route:
|
||||
* /(app)/profile/<ed25519-hex>
|
||||
*
|
||||
* Back behaviour:
|
||||
* Nested Stack layout in app/(app)/profile/_layout.tsx preserves the
|
||||
* push stack, so tapping Back returns the user to whatever screen
|
||||
* pushed them here (feed card tap, chat header tap, etc.).
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, Pressable, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { followUser, unfollowUser } from '@/lib/feed';
|
||||
import { humanizeTxError } from '@/lib/api';
|
||||
|
||||
function shortAddr(a: string, n = 10): string {
|
||||
if (!a) return '—';
|
||||
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||
}
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { address } = useLocalSearchParams<{ address: string }>();
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const contact = contacts.find(c => c.address === address);
|
||||
|
||||
const [following, setFollowing] = useState(false);
|
||||
const [followingBusy, setFollowingBusy] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const isMe = !!keyFile && keyFile.pub_key === address;
|
||||
const displayName = contact?.username
|
||||
? `@${contact.username}`
|
||||
: contact?.alias ?? (isMe ? 'Вы' : shortAddr(address ?? '', 6));
|
||||
|
||||
const copyAddress = async () => {
|
||||
if (!address) return;
|
||||
await Clipboard.setStringAsync(address);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
};
|
||||
|
||||
const openChat = () => {
|
||||
if (!address) return;
|
||||
router.replace(`/(app)/chats/${address}` as never);
|
||||
};
|
||||
|
||||
const onToggleFollow = async () => {
|
||||
if (!keyFile || !address || isMe || followingBusy) return;
|
||||
setFollowingBusy(true);
|
||||
const wasFollowing = following;
|
||||
setFollowing(!wasFollowing);
|
||||
try {
|
||||
if (wasFollowing) {
|
||||
await unfollowUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address });
|
||||
} else {
|
||||
await followUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address });
|
||||
}
|
||||
} catch (e: any) {
|
||||
setFollowing(wasFollowing);
|
||||
// Surface the error via alert — feed lib already formats humanizeTxError.
|
||||
alert(humanizeTxError(e));
|
||||
} finally {
|
||||
setFollowingBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="Профиль"
|
||||
divider
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
/>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>
|
||||
{/* ── Hero: avatar + Follow button ──────────────────────────── */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12 }}>
|
||||
<Avatar name={displayName} address={address} size={72} />
|
||||
<View style={{ flex: 1 }} />
|
||||
{!isMe ? (
|
||||
<Pressable
|
||||
onPress={onToggleFollow}
|
||||
disabled={followingBusy}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 18, paddingVertical: 9,
|
||||
borderRadius: 999,
|
||||
backgroundColor: following
|
||||
? (pressed ? '#1a1a1a' : '#111111')
|
||||
: (pressed ? '#e7e7e7' : '#ffffff'),
|
||||
borderWidth: following ? 1 : 0,
|
||||
borderColor: '#1f1f1f',
|
||||
minWidth: 120,
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{followingBusy ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={following ? '#ffffff' : '#000000'}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
color: following ? '#ffffff' : '#000000',
|
||||
fontWeight: '700',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{following ? 'Вы подписаны' : 'Подписаться'}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => router.push('/(app)/settings' as never)}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 18, paddingVertical: 9,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
|
||||
Редактировать
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Name + verified tick */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text
|
||||
style={{ color: '#ffffff', fontSize: 22, fontWeight: '800', letterSpacing: -0.3 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{contact?.username && (
|
||||
<Ionicons name="checkmark-circle" size={18} color="#1d9bf0" style={{ marginLeft: 5 }} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Open chat — single CTA, full width, icon inline with text.
|
||||
Only when we know this is a contact (direct chat exists). */}
|
||||
{!isMe && contact && (
|
||||
<Pressable
|
||||
onPress={openChat}
|
||||
style={({ pressed }) => ({
|
||||
marginTop: 14,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 11,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
})}
|
||||
>
|
||||
<Ionicons name="chatbubble-outline" size={15} color="#ffffff" />
|
||||
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
|
||||
Открыть чат
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* ── Info card ───────────────────────────────────────────────── */}
|
||||
<View
|
||||
style={{
|
||||
marginTop: 18,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Address — entire row is tappable → copies */}
|
||||
<Pressable
|
||||
onPress={copyAddress}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14, paddingVertical: 12,
|
||||
backgroundColor: pressed ? '#0f0f0f' : 'transparent',
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>
|
||||
Адрес
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: copied ? '#3ba55d' : '#ffffff',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{copied ? 'Скопировано' : shortAddr(address ?? '')}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={copied ? 'checkmark' : 'copy-outline'}
|
||||
size={14}
|
||||
color={copied ? '#3ba55d' : '#6a6a6a'}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* Encryption status */}
|
||||
{contact && (
|
||||
<>
|
||||
<Divider />
|
||||
<InfoRow
|
||||
label="Шифрование"
|
||||
value={contact.x25519Pub
|
||||
? 'end-to-end (NaCl)'
|
||||
: 'ключ ещё не опубликован'}
|
||||
danger={!contact.x25519Pub}
|
||||
icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<InfoRow
|
||||
label="Добавлен"
|
||||
value={new Date(contact.addedAt).toLocaleDateString()}
|
||||
icon="calendar-outline"
|
||||
/>
|
||||
|
||||
{/* Group-only: participant count. DMs always have exactly two
|
||||
people so the row would be noise. Groups would show real
|
||||
member count here from chain state once v2.1.0 ships groups. */}
|
||||
{contact.kind === 'group' && (
|
||||
<>
|
||||
<Divider />
|
||||
<InfoRow
|
||||
label="Участников"
|
||||
value="—"
|
||||
icon="people-outline"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!contact && !isMe && (
|
||||
<Text style={{
|
||||
color: '#6a6a6a',
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
marginTop: 14,
|
||||
paddingHorizontal: 24,
|
||||
lineHeight: 17,
|
||||
}}>
|
||||
Этот пользователь пока не в ваших контактах. Нажмите «Подписаться», чтобы видеть его посты в ленте, или добавьте в чаты через @username.
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />;
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label, value, icon, danger,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: React.ComponentProps<typeof Ionicons>['name'];
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
}}
|
||||
>
|
||||
{icon && (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={14}
|
||||
color={danger ? '#f0b35a' : '#6a6a6a'}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: danger ? '#f0b35a' : '#ffffff',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
24
client-app/app/(app)/profile/_layout.tsx
Normal file
24
client-app/app/(app)/profile/_layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Profile group layout — provides a dedicated native Stack for the
|
||||
* /(app)/profile/* routes so that `router.back()` returns to the screen
|
||||
* that pushed us here (post detail, chat, feed tab, etc.) instead of
|
||||
* falling through to the root.
|
||||
*
|
||||
* The parent (app)/_layout.tsx uses AnimatedSlot → <Slot>, which is
|
||||
* stack-less. Nesting a <Stack> here gives profile routes proper back
|
||||
* history without affecting the outer tabs.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ProfileLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: '#000000' },
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
173
client-app/app/(app)/requests.tsx
Normal file
173
client-app/app/(app)/requests.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Contact requests / notifications — dark minimalist.
|
||||
*
|
||||
* В референсе нижний таб «notifications» ведёт сюда. Пока это только
|
||||
* incoming CONTACT_REQUEST'ы; позже сюда же придут другие системные
|
||||
* уведомления (slash, ADD_VALIDATOR со-sig-ing, и т.д.).
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, FlatList, Alert, Pressable, ActivityIndicator } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
buildAcceptContactTx, submitTx, getIdentity, humanizeTxError,
|
||||
} from '@/lib/api';
|
||||
import { saveContact } from '@/lib/storage';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { relativeTime } from '@/lib/utils';
|
||||
import type { ContactRequest } from '@/lib/types';
|
||||
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { TabHeader } from '@/components/TabHeader';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
|
||||
export default function RequestsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const requests = useStore(s => s.requests);
|
||||
const setRequests = useStore(s => s.setRequests);
|
||||
const upsertContact = useStore(s => s.upsertContact);
|
||||
|
||||
const [accepting, setAccepting] = useState<string | null>(null);
|
||||
|
||||
async function accept(req: ContactRequest) {
|
||||
if (!keyFile) return;
|
||||
setAccepting(req.txHash);
|
||||
try {
|
||||
const identity = await getIdentity(req.from);
|
||||
const x25519Pub = identity?.x25519_pub ?? '';
|
||||
|
||||
const tx = buildAcceptContactTx({
|
||||
from: keyFile.pub_key, to: req.from, privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
|
||||
const contact = { address: req.from, x25519Pub, username: req.username, addedAt: Date.now() };
|
||||
upsertContact(contact);
|
||||
await saveContact(contact);
|
||||
|
||||
setRequests(requests.filter(r => r.txHash !== req.txHash));
|
||||
router.replace(`/(app)/chats/${req.from}` as never);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Accept failed', humanizeTxError(e));
|
||||
} finally {
|
||||
setAccepting(null);
|
||||
}
|
||||
}
|
||||
|
||||
function decline(req: ContactRequest) {
|
||||
Alert.alert(
|
||||
'Decline request',
|
||||
`Decline request from ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Decline',
|
||||
style: 'destructive',
|
||||
onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const renderItem = ({ item: req }: { item: ContactRequest }) => {
|
||||
const name = req.username ? `@${req.username}` : shortAddr(req.from);
|
||||
const isAccepting = accepting === req.txHash;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#0f0f0f',
|
||||
}}
|
||||
>
|
||||
<Avatar name={name} address={req.from} size={44} />
|
||||
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
|
||||
wants to message you · {relativeTime(req.timestamp)}
|
||||
</Text>
|
||||
{req.intro ? (
|
||||
<Text
|
||||
style={{
|
||||
color: '#d0d0d0', fontSize: 13, lineHeight: 18,
|
||||
marginTop: 6,
|
||||
padding: 8,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
}}
|
||||
numberOfLines={3}
|
||||
>
|
||||
{req.intro}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 10 }}>
|
||||
<Pressable
|
||||
onPress={() => accept(req)}
|
||||
disabled={isAccepting}
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 9, borderRadius: 999,
|
||||
backgroundColor: isAccepting ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
})}
|
||||
>
|
||||
{isAccepting ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>Accept</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => decline(req)}
|
||||
disabled={isAccepting}
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 9, borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>Decline</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<TabHeader title="Notifications" />
|
||||
|
||||
{requests.length === 0 ? (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
|
||||
<Ionicons name="notifications-outline" size={42} color="#3a3a3a" />
|
||||
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
|
||||
All caught up
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', marginTop: 6, lineHeight: 19 }}>
|
||||
Contact requests and network events will appear here.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={requests}
|
||||
keyExtractor={r => r.txHash}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={{ paddingBottom: 120 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
595
client-app/app/(app)/settings.tsx
Normal file
595
client-app/app/(app)/settings.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* Settings screen — sub-route, открывается по tap'у на profile-avatar в
|
||||
* TabHeader. Использует обычный `<Header>` с back-кнопкой.
|
||||
*
|
||||
* Секции:
|
||||
* 1. Профиль — avatar, @username, short-address, Copy row.
|
||||
* 2. Username — регистрация в native:username_registry (если не куплено).
|
||||
* 3. Node — URL + contract ID + Save + Status.
|
||||
* 4. Account — Export key, Delete account.
|
||||
*
|
||||
* Весь Pressable'овый layout живёт на ВНЕШНЕМ View с static style —
|
||||
* Pressable handle-ит только background change (через вложенный View
|
||||
* в ({pressed}) callback'е), никаких layout props в callback-style.
|
||||
* Это лечит web-баг, где Pressable style-функция не применяет
|
||||
* percentage/padding layout надёжно.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, TextInput, Alert, Pressable, ActivityIndicator, Share,
|
||||
} from 'react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { saveSettings, deleteKeyFile } from '@/lib/storage';
|
||||
import {
|
||||
setNodeUrl, getNetStats, resolveUsername, reverseResolve,
|
||||
buildCallContractTx, submitTx,
|
||||
USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
|
||||
humanizeTxError,
|
||||
} from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { formatAmount } from '@/lib/utils';
|
||||
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
|
||||
type NodeStatus = 'idle' | 'checking' | 'ok' | 'error';
|
||||
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
|
||||
// ─── Shared layout primitives ─────────────────────────────────────
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
color: '#5a5a5a',
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.2,
|
||||
textTransform: 'uppercase',
|
||||
marginTop: 18,
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 14,
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 14,
|
||||
marginHorizontal: 14,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Row — clickable / non-clickable list item внутри Card'а.
|
||||
*
|
||||
* Layout живёт на ВНЕШНЕМ контейнере (View если read-only, Pressable
|
||||
* если tappable). Для pressed-стейта используется вложенный `<View>`
|
||||
* с background-color, чтобы не полагаться на style-функцию Pressable'а
|
||||
* (web-баг).
|
||||
*/
|
||||
function Row({
|
||||
icon, label, value, onPress, right, danger, first,
|
||||
}: {
|
||||
icon: IoniconName;
|
||||
label: string;
|
||||
value?: string;
|
||||
onPress?: () => void;
|
||||
right?: React.ReactNode;
|
||||
danger?: boolean;
|
||||
first?: boolean;
|
||||
}) {
|
||||
const body = (pressed: boolean) => (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 13,
|
||||
backgroundColor: pressed ? '#151515' : 'transparent',
|
||||
borderTopWidth: first ? 0 : 1,
|
||||
borderTopColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
backgroundColor: danger ? 'rgba(244,33,46,0.12)' : '#1a1a1a',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name={icon} size={16} color={danger ? '#f4212e' : '#ffffff'} />
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: danger ? '#f4212e' : '#ffffff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{value !== undefined && (
|
||||
<Text numberOfLines={1} style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{right}
|
||||
{onPress && !right && (
|
||||
<Ionicons name="chevron-forward" size={16} color="#5a5a5a" />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!onPress) return <View>{body(false)}</View>;
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
{({ pressed }) => body(pressed)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Screen ───────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const settings = useStore(s => s.settings);
|
||||
const setSettings = useStore(s => s.setSettings);
|
||||
const username = useStore(s => s.username);
|
||||
const setUsername = useStore(s => s.setUsername);
|
||||
const balance = useStore(s => s.balance);
|
||||
|
||||
const [nodeUrl, setNodeUrlInput] = useState(settings.nodeUrl);
|
||||
const [contractId, setContractId] = useState(settings.contractId);
|
||||
const [nodeStatus, setNodeStatus] = useState<NodeStatus>('idle');
|
||||
const [peerCount, setPeerCount] = useState<number | null>(null);
|
||||
const [blockCount, setBlockCount] = useState<number | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [savingNode, setSavingNode] = useState(false);
|
||||
|
||||
// Username registration state
|
||||
const [nameInput, setNameInput] = useState('');
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [registering, setRegistering] = useState(false);
|
||||
|
||||
useEffect(() => { checkNode(); }, []);
|
||||
useEffect(() => { setContractId(settings.contractId); }, [settings.contractId]);
|
||||
useEffect(() => {
|
||||
if (!settings.contractId || !keyFile) { setUsername(null); return; }
|
||||
(async () => {
|
||||
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
|
||||
setUsername(name);
|
||||
})();
|
||||
}, [settings.contractId, keyFile, setUsername]);
|
||||
|
||||
async function checkNode() {
|
||||
setNodeStatus('checking');
|
||||
try {
|
||||
const stats = await getNetStats();
|
||||
setNodeStatus('ok');
|
||||
setPeerCount(stats.peer_count);
|
||||
setBlockCount(stats.total_blocks);
|
||||
} catch {
|
||||
setNodeStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNode() {
|
||||
setSavingNode(true);
|
||||
const url = nodeUrl.trim().replace(/\/$/, '');
|
||||
setNodeUrl(url);
|
||||
const next = { nodeUrl: url, contractId: contractId.trim() };
|
||||
setSettings(next);
|
||||
await saveSettings(next);
|
||||
await checkNode();
|
||||
setSavingNode(false);
|
||||
Alert.alert('Saved', 'Node settings updated.');
|
||||
}
|
||||
|
||||
async function copyAddress() {
|
||||
if (!keyFile) return;
|
||||
await Clipboard.setStringAsync(keyFile.pub_key);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
}
|
||||
|
||||
async function exportKey() {
|
||||
if (!keyFile) return;
|
||||
try {
|
||||
await Share.share({
|
||||
message: JSON.stringify(keyFile, null, 2),
|
||||
title: 'DChain key file',
|
||||
});
|
||||
} catch (e: any) {
|
||||
Alert.alert('Export failed', e?.message ?? 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
Alert.alert(
|
||||
'Delete account',
|
||||
'Your key will be removed from this device. Make sure you have a backup!',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await deleteKeyFile();
|
||||
setKeyFile(null);
|
||||
router.replace('/');
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const onNameChange = (v: string) => {
|
||||
const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH);
|
||||
setNameInput(cleaned);
|
||||
setNameError(null);
|
||||
};
|
||||
const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput);
|
||||
|
||||
async function registerUsername() {
|
||||
if (!keyFile) return;
|
||||
const name = nameInput.trim();
|
||||
if (!nameIsValid) {
|
||||
setNameError(`Min ${MIN_USERNAME_LENGTH} chars, starts with a-z`);
|
||||
return;
|
||||
}
|
||||
if (!settings.contractId) {
|
||||
setNameError('No registry contract in node settings');
|
||||
return;
|
||||
}
|
||||
const total = USERNAME_REGISTRATION_FEE + 1000 + 2000;
|
||||
if (balance < total) {
|
||||
setNameError(`Need ${formatAmount(total)}, have ${formatAmount(balance)}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const existing = await resolveUsername(settings.contractId, name);
|
||||
if (existing) { setNameError(`@${name} already taken`); return; }
|
||||
} catch { /* ignore */ }
|
||||
|
||||
Alert.alert(
|
||||
`Buy @${name}?`,
|
||||
`Cost: ${formatAmount(USERNAME_REGISTRATION_FEE)} + fee ${formatAmount(1000)}.\nBinds to your address until released.`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Buy',
|
||||
onPress: async () => {
|
||||
setRegistering(true);
|
||||
setNameError(null);
|
||||
try {
|
||||
const tx = buildCallContractTx({
|
||||
from: keyFile.pub_key,
|
||||
contractId: settings.contractId,
|
||||
method: 'register',
|
||||
args: [name],
|
||||
amount: USERNAME_REGISTRATION_FEE,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
setNameInput('');
|
||||
Alert.alert('Submitted', 'Registration tx accepted. Name appears in a few seconds.');
|
||||
let attempts = 0;
|
||||
const iv = setInterval(async () => {
|
||||
attempts++;
|
||||
const got = keyFile
|
||||
? await reverseResolve(settings.contractId, keyFile.pub_key)
|
||||
: null;
|
||||
if (got) { setUsername(got); clearInterval(iv); }
|
||||
else if (attempts >= 10) clearInterval(iv);
|
||||
}, 2000);
|
||||
} catch (e: any) {
|
||||
setNameError(humanizeTxError(e));
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const statusColor =
|
||||
nodeStatus === 'ok' ? '#3ba55d' :
|
||||
nodeStatus === 'error' ? '#f4212e' :
|
||||
'#f0b35a';
|
||||
const statusLabel =
|
||||
nodeStatus === 'ok' ? 'Connected' :
|
||||
nodeStatus === 'error' ? 'Unreachable' :
|
||||
'Checking…';
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="Settings"
|
||||
divider={false}
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
/>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: 120 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* ── Profile ── */}
|
||||
<SectionLabel>Profile</SectionLabel>
|
||||
<Card>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 14,
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
name={username ?? keyFile?.pub_key ?? '?'}
|
||||
address={keyFile?.pub_key}
|
||||
size={56}
|
||||
/>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
{username ? (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 17 }}>
|
||||
@{username}
|
||||
</Text>
|
||||
<Ionicons name="checkmark-circle" size={15} color="#1d9bf0" style={{ marginLeft: 4 }} />
|
||||
</View>
|
||||
) : (
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 14 }}>No username yet</Text>
|
||||
)}
|
||||
<Text
|
||||
style={{
|
||||
color: '#8b8b8b',
|
||||
fontSize: 11,
|
||||
marginTop: 2,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{keyFile ? shortAddr(keyFile.pub_key, 10) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Row
|
||||
icon={copied ? 'checkmark-outline' : 'copy-outline'}
|
||||
label={copied ? 'Copied!' : 'Copy address'}
|
||||
onPress={copyAddress}
|
||||
right={<View style={{ width: 16 }} />}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ── Username (только если ещё нет) ── */}
|
||||
{!username && (
|
||||
<>
|
||||
<SectionLabel>Username</SectionLabel>
|
||||
<Card>
|
||||
<View style={{ padding: 14 }}>
|
||||
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14, marginBottom: 4 }}>
|
||||
Buy a username
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 10 }}>
|
||||
Flat {formatAmount(USERNAME_REGISTRATION_FEE)} fee + {formatAmount(1000)} network.
|
||||
Only a-z, 0-9, _, -. Starts with a letter.
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#000000',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: nameError ? '#f4212e' : '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#5a5a5a', fontSize: 15, marginRight: 2 }}>@</Text>
|
||||
<TextInput
|
||||
value={nameInput}
|
||||
onChangeText={onNameChange}
|
||||
placeholder="alice"
|
||||
placeholderTextColor="#5a5a5a"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={MAX_USERNAME_LENGTH}
|
||||
style={{
|
||||
flex: 1,
|
||||
color: '#ffffff',
|
||||
fontSize: 15,
|
||||
paddingVertical: 8,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{nameError && (
|
||||
<Text style={{ color: '#f4212e', fontSize: 12, marginTop: 6 }}>
|
||||
{nameError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<PrimaryButton
|
||||
onPress={registerUsername}
|
||||
disabled={registering || !nameIsValid || !settings.contractId}
|
||||
loading={registering}
|
||||
label={`Buy @${nameInput || 'username'}`}
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Node ── */}
|
||||
<SectionLabel>Node</SectionLabel>
|
||||
<Card>
|
||||
<View style={{ padding: 14, gap: 10 }}>
|
||||
<LabeledInput
|
||||
label="Node URL"
|
||||
value={nodeUrl}
|
||||
onChangeText={setNodeUrlInput}
|
||||
placeholder="http://localhost:8080"
|
||||
/>
|
||||
<LabeledInput
|
||||
label="Username contract"
|
||||
value={contractId}
|
||||
onChangeText={setContractId}
|
||||
placeholder="auto-discovered via /api/well-known-contracts"
|
||||
monospace
|
||||
/>
|
||||
<PrimaryButton
|
||||
onPress={saveNode}
|
||||
disabled={savingNode}
|
||||
loading={savingNode}
|
||||
label="Save"
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Row
|
||||
icon="pulse-outline"
|
||||
label="Status"
|
||||
value={
|
||||
nodeStatus === 'ok'
|
||||
? `${statusLabel} · ${blockCount ?? 0} blocks · ${peerCount ?? 0} peers`
|
||||
: statusLabel
|
||||
}
|
||||
right={
|
||||
<View
|
||||
style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: statusColor }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ── Account ── */}
|
||||
<SectionLabel>Account</SectionLabel>
|
||||
<Card>
|
||||
<Row
|
||||
icon="download-outline"
|
||||
label="Export key"
|
||||
value="Save your private key as JSON"
|
||||
onPress={exportKey}
|
||||
first
|
||||
/>
|
||||
<Row
|
||||
icon="trash-outline"
|
||||
label="Delete account"
|
||||
value="Remove key from this device"
|
||||
onPress={logout}
|
||||
danger
|
||||
/>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Form primitives ──────────────────────────────────────────────
|
||||
|
||||
function LabeledInput({
|
||||
label, value, onChangeText, placeholder, monospace,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
placeholder?: string;
|
||||
monospace?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginBottom: 6 }}>{label}</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#5a5a5a"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: monospace ? 13 : 14,
|
||||
fontFamily: monospace ? 'monospace' : undefined,
|
||||
backgroundColor: '#000000',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryButton({
|
||||
label, onPress, disabled, loading, style,
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
style?: object;
|
||||
}) {
|
||||
return (
|
||||
<Pressable onPress={onPress} disabled={disabled} style={style}>
|
||||
{({ pressed }) => (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 11,
|
||||
borderRadius: 999,
|
||||
backgroundColor: disabled
|
||||
? '#1a1a1a'
|
||||
: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
color: disabled ? '#5a5a5a' : '#ffffff',
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
652
client-app/app/(app)/wallet.tsx
Normal file
652
client-app/app/(app)/wallet.tsx
Normal file
@@ -0,0 +1,652 @@
|
||||
/**
|
||||
* Wallet screen — dark minimalist.
|
||||
*
|
||||
* Сетка:
|
||||
* [TabHeader: profile-avatar | Wallet | refresh]
|
||||
* [Balance hero card — gradient-ish dark card, big number, address chip, action row]
|
||||
* [SectionLabel: Recent transactions]
|
||||
* [TX list card — tiles per tx, in/out coloring, relative time]
|
||||
* [Send modal: slide-up sheet с полями recipient/amount/fee + total preview]
|
||||
*
|
||||
* Все кнопки и инпуты — те же плоские стили, что на других экранах.
|
||||
* Никаких style-функций у Pressable'ов с layout-пропсами (избегаем web
|
||||
* layout-баги, которые мы уже ловили на ChatTile/MessageBubble).
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, Modal, Alert, RefreshControl, Pressable, TextInput, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { useBalance } from '@/hooks/useBalance';
|
||||
import { buildTransferTx, submitTx, getTxHistory, getBalance, humanizeTxError } from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { formatAmount, relativeTime } from '@/lib/utils';
|
||||
import type { TxRecord } from '@/lib/types';
|
||||
|
||||
import { TabHeader } from '@/components/TabHeader';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
|
||||
// ─── TX meta (icon + label + tone) ─────────────────────────────────
|
||||
|
||||
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
|
||||
interface TxMeta {
|
||||
label: string;
|
||||
icon: IoniconName;
|
||||
tone: 'in' | 'out' | 'neutral';
|
||||
}
|
||||
|
||||
const TX_META: Record<string, TxMeta> = {
|
||||
TRANSFER: { label: 'Transfer', icon: 'swap-horizontal-outline', tone: 'neutral' },
|
||||
CONTACT_REQUEST: { label: 'Contact request', icon: 'person-add-outline', tone: 'out' },
|
||||
ACCEPT_CONTACT: { label: 'Contact accepted', icon: 'person-outline', tone: 'in' },
|
||||
BLOCK_CONTACT: { label: 'Block', icon: 'ban-outline', tone: 'out' },
|
||||
DEPLOY_CONTRACT: { label: 'Deploy', icon: 'document-text-outline', tone: 'out' },
|
||||
CALL_CONTRACT: { label: 'Call contract', icon: 'flash-outline', tone: 'out' },
|
||||
STAKE: { label: 'Stake', icon: 'lock-closed-outline', tone: 'out' },
|
||||
UNSTAKE: { label: 'Unstake', icon: 'lock-open-outline', tone: 'in' },
|
||||
REGISTER_KEY: { label: 'Register key', icon: 'key-outline', tone: 'neutral' },
|
||||
BLOCK_REWARD: { label: 'Block reward', icon: 'diamond-outline', tone: 'in' },
|
||||
};
|
||||
|
||||
function txMeta(type: string): TxMeta {
|
||||
return TX_META[type] ?? { label: type.replace(/_/g, ' '), icon: 'ellipse-outline', tone: 'neutral' };
|
||||
}
|
||||
|
||||
const toneColor = (tone: TxMeta['tone']): string =>
|
||||
tone === 'in' ? '#3ba55d' : tone === 'out' ? '#f4212e' : '#e7e7e7';
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function WalletScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const balance = useStore(s => s.balance);
|
||||
const setBalance = useStore(s => s.setBalance);
|
||||
|
||||
useBalance();
|
||||
|
||||
const [txHistory, setTxHistory] = useState<TxRecord[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showSend, setShowSend] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!keyFile) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const [hist, bal] = await Promise.all([
|
||||
getTxHistory(keyFile.pub_key),
|
||||
getBalance(keyFile.pub_key),
|
||||
]);
|
||||
setTxHistory(hist);
|
||||
setBalance(bal);
|
||||
} catch { /* ignore — WS/HTTP retries sample */ }
|
||||
setRefreshing(false);
|
||||
}, [keyFile, setBalance]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const copyAddress = async () => {
|
||||
if (!keyFile) return;
|
||||
await Clipboard.setStringAsync(keyFile.pub_key);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
};
|
||||
|
||||
const mine = keyFile?.pub_key ?? '';
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<TabHeader
|
||||
title="Wallet"
|
||||
right={<IconButton icon="refresh-outline" size={36} onPress={load} />}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} tintColor="#1d9bf0" />}
|
||||
contentContainerStyle={{ paddingBottom: 120 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<BalanceHero
|
||||
balance={balance}
|
||||
address={mine}
|
||||
copied={copied}
|
||||
onCopy={copyAddress}
|
||||
onSend={() => setShowSend(true)}
|
||||
/>
|
||||
|
||||
<SectionLabel>Recent transactions</SectionLabel>
|
||||
|
||||
{txHistory.length === 0 ? (
|
||||
<EmptyTx />
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
marginHorizontal: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{txHistory.map((tx, i) => (
|
||||
<TxTile
|
||||
key={tx.hash + i}
|
||||
tx={tx}
|
||||
first={i === 0}
|
||||
mine={mine}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<SendModal
|
||||
visible={showSend}
|
||||
onClose={() => setShowSend(false)}
|
||||
balance={balance}
|
||||
keyFile={keyFile}
|
||||
onSent={() => {
|
||||
setShowSend(false);
|
||||
setTimeout(load, 1200);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hero card ─────────────────────────────────────────────────────
|
||||
|
||||
function BalanceHero({
|
||||
balance, address, copied, onCopy, onSend,
|
||||
}: {
|
||||
balance: number;
|
||||
address: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
onSend: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginHorizontal: 14,
|
||||
marginTop: 10,
|
||||
padding: 20,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, letterSpacing: 0.3 }}>
|
||||
Balance
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: 36,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.8,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{formatAmount(balance)}
|
||||
</Text>
|
||||
|
||||
{/* Address chip */}
|
||||
<Pressable onPress={onCopy} style={{ marginTop: 14 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#111111',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 9,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={copied ? 'checkmark-outline' : 'copy-outline'}
|
||||
size={14}
|
||||
color={copied ? '#3ba55d' : '#8b8b8b'}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: copied ? '#3ba55d' : '#8b8b8b',
|
||||
fontSize: 12,
|
||||
marginLeft: 6,
|
||||
fontFamily: 'monospace',
|
||||
flex: 1,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{copied ? 'Copied!' : shortAddr(address, 10)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={{ flexDirection: 'row', gap: 10, marginTop: 14 }}>
|
||||
<HeroButton icon="paper-plane-outline" label="Send" primary onPress={onSend} />
|
||||
<HeroButton icon="download-outline" label="Receive" onPress={onCopy} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroButton({
|
||||
icon, label, primary, onPress,
|
||||
}: {
|
||||
icon: IoniconName;
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const base = {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 11,
|
||||
borderRadius: 999,
|
||||
gap: 6,
|
||||
} as const;
|
||||
return (
|
||||
<Pressable onPress={onPress} style={{ flex: 1 }}>
|
||||
{({ pressed }) => (
|
||||
<View
|
||||
style={{
|
||||
...base,
|
||||
backgroundColor: primary
|
||||
? (pressed ? '#1a8cd8' : '#1d9bf0')
|
||||
: (pressed ? '#202020' : '#111111'),
|
||||
borderWidth: primary ? 0 : 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<Ionicons name={icon} size={15} color="#ffffff" />
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section label ────────────────────────────────────────────────
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
color: '#5a5a5a',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1.2,
|
||||
textTransform: 'uppercase',
|
||||
marginTop: 22,
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 14,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────────────
|
||||
|
||||
function EmptyTx() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginHorizontal: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
paddingVertical: 36,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="receipt-outline" size={32} color="#5a5a5a" />
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 8 }}>
|
||||
No transactions yet
|
||||
</Text>
|
||||
<Text style={{ color: '#5a5a5a', fontSize: 11, marginTop: 2 }}>
|
||||
Pull to refresh
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TX tile ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Pressable с ВНЕШНИМ плоским style (background через static object),
|
||||
// внутренняя View handles row-layout. Избегаем web-баг со style-функциями
|
||||
// Pressable'а.
|
||||
|
||||
function TxTile({
|
||||
tx, first, mine,
|
||||
}: {
|
||||
tx: TxRecord;
|
||||
first: boolean;
|
||||
mine: string;
|
||||
}) {
|
||||
const m = txMeta(tx.type);
|
||||
const isMineTx = tx.from === mine;
|
||||
const amt = tx.amount ?? 0;
|
||||
const sign = m.tone === 'in' ? '+' : m.tone === 'out' ? '−' : '';
|
||||
const color = toneColor(m.tone);
|
||||
|
||||
return (
|
||||
<Pressable>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
borderTopWidth: first ? 0 : 1,
|
||||
borderTopColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#111111',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name={m.icon} size={16} color="#e7e7e7" />
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}>
|
||||
{m.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: '#8b8b8b',
|
||||
fontSize: 11,
|
||||
marginTop: 1,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{tx.type === 'TRANSFER'
|
||||
? (isMineTx ? `→ ${shortAddr(tx.to ?? '', 5)}` : `← ${shortAddr(tx.from, 5)}`)
|
||||
: shortAddr(tx.hash, 8)}
|
||||
{' · '}
|
||||
{relativeTime(tx.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
{amt > 0 && (
|
||||
<Text style={{ color, fontWeight: '700', fontSize: 14 }}>
|
||||
{sign}{formatAmount(amt)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Send modal ───────────────────────────────────────────────────
|
||||
|
||||
function SendModal({
|
||||
visible, onClose, balance, keyFile, onSent,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
balance: number;
|
||||
keyFile: { pub_key: string; priv_key: string } | null;
|
||||
onSent: () => void;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [to, setTo] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [fee, setFee] = useState('1000');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
// reset при закрытии
|
||||
setTo(''); setAmount(''); setFee('1000'); setSending(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const amt = parseInt(amount || '0', 10) || 0;
|
||||
const f = parseInt(fee || '0', 10) || 0;
|
||||
const total = amt + f;
|
||||
const ok = !!to.trim() && amt > 0 && total <= balance;
|
||||
|
||||
const send = async () => {
|
||||
if (!keyFile) return;
|
||||
if (!ok) {
|
||||
Alert.alert('Check inputs', total > balance
|
||||
? `Need ${formatAmount(total)}, have ${formatAmount(balance)}.`
|
||||
: 'Recipient and amount are required.');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
const tx = buildTransferTx({
|
||||
from: keyFile.pub_key,
|
||||
to: to.trim(),
|
||||
amount: amt,
|
||||
fee: f,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
onSent();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Send failed', humanizeTxError(e));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.82)', justifyContent: 'flex-end' }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => { /* block bubble-close */ }}
|
||||
style={{
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingTop: 10,
|
||||
paddingBottom: Math.max(insets.bottom, 14) + 12,
|
||||
paddingHorizontal: 14,
|
||||
borderTopWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
width: 40, height: 4, borderRadius: 2,
|
||||
backgroundColor: '#2a2a2a',
|
||||
marginBottom: 14,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: '#ffffff', fontSize: 18, fontWeight: '700', marginBottom: 14 }}>
|
||||
Send tokens
|
||||
</Text>
|
||||
|
||||
<Field label="Recipient address">
|
||||
<TextInput
|
||||
value={to}
|
||||
onChangeText={setTo}
|
||||
placeholder="DC… or pub_key hex"
|
||||
placeholderTextColor="#5a5a5a"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
paddingVertical: 0,
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 10, marginTop: 10 }}>
|
||||
<View style={{ flex: 2 }}>
|
||||
<Field label="Amount (µT)">
|
||||
<TextInput
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
placeholder="1000"
|
||||
placeholderTextColor="#5a5a5a"
|
||||
keyboardType="numeric"
|
||||
style={{ color: '#ffffff', fontSize: 14, paddingVertical: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Field label="Fee (µT)">
|
||||
<TextInput
|
||||
value={fee}
|
||||
onChangeText={setFee}
|
||||
placeholder="1000"
|
||||
placeholderTextColor="#5a5a5a"
|
||||
keyboardType="numeric"
|
||||
style={{ color: '#ffffff', fontSize: 14, paddingVertical: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Summary */}
|
||||
<View
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#111111',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<SummaryRow label="Amount" value={formatAmount(amt)} />
|
||||
<SummaryRow label="Fee" value={formatAmount(f)} muted />
|
||||
<View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 6 }} />
|
||||
<SummaryRow
|
||||
label="Total"
|
||||
value={formatAmount(total)}
|
||||
accent={total > balance ? '#f4212e' : '#ffffff'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 10, marginTop: 16 }}>
|
||||
<Pressable onPress={onClose} style={{ flex: 1 }}>
|
||||
{({ pressed }) => (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
|
||||
Cancel
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable onPress={send} disabled={!ok || sending} style={{ flex: 2 }}>
|
||||
{({ pressed }) => (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 999,
|
||||
backgroundColor: !ok || sending
|
||||
? '#1a1a1a'
|
||||
: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
}}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
|
||||
Send
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginBottom: 6 }}>{label}</Text>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#000000',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({
|
||||
label, value, muted, accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
muted?: boolean;
|
||||
accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 3,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12 }}>{label}</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: accent ?? (muted ? '#8b8b8b' : '#ffffff'),
|
||||
fontSize: 13,
|
||||
fontWeight: muted ? '500' : '700',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
139
client-app/app/(auth)/create.tsx
Normal file
139
client-app/app/(auth)/create.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Create Account — dark minimalist.
|
||||
* Генерирует Ed25519 + X25519 keypair локально, сохраняет в SecureStore.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, ScrollView, Alert, Pressable, ActivityIndicator } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { generateKeyFile } from '@/lib/crypto';
|
||||
import { saveKeyFile } from '@/lib/storage';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
|
||||
export default function CreateAccountScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleCreate() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const kf = generateKeyFile();
|
||||
await saveKeyFile(kf);
|
||||
setKeyFile(kf);
|
||||
router.replace('/(auth)/created' as never);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Error', e?.message ?? 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="Create account"
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
/>
|
||||
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700', marginBottom: 4 }}>
|
||||
A new identity is created locally
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 18 }}>
|
||||
Your private key never leaves this device. The app encrypts it in the
|
||||
platform secure store.
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<InfoRow icon="key-outline" label="Ed25519 signing key" desc="Your on-chain address and tx signer" first />
|
||||
<InfoRow icon="lock-closed-outline" label="X25519 encryption key" desc="End-to-end encryption for messages" />
|
||||
<InfoRow icon="phone-portrait-outline" label="Stored on device" desc="Encrypted in SecureStore / Keystore" />
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(240,179,90,0.08)',
|
||||
borderWidth: 1, borderColor: 'rgba(240,179,90,0.25)',
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
|
||||
<Ionicons name="warning-outline" size={14} color="#f0b35a" style={{ marginRight: 6 }} />
|
||||
<Text style={{ color: '#f0b35a', fontSize: 13, fontWeight: '700' }}>Important</Text>
|
||||
</View>
|
||||
<Text style={{ color: '#d0a26a', fontSize: 12, lineHeight: 17 }}>
|
||||
Export and backup your key file right after creation. If you lose
|
||||
it there is no recovery — blockchain has no password reset.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={handleCreate}
|
||||
disabled={loading}
|
||||
style={({ pressed }) => ({
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 13, borderRadius: 999, marginTop: 20,
|
||||
backgroundColor: loading ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
})}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}>
|
||||
Generate keys & continue
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
icon, label, desc, first,
|
||||
}: {
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
label: string;
|
||||
desc: string;
|
||||
first?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 14,
|
||||
gap: 12,
|
||||
borderTopWidth: first ? 0 : 1,
|
||||
borderTopColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
backgroundColor: '#111111',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name={icon} size={16} color="#ffffff" />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}>{label}</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>{desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
196
client-app/app/(auth)/created.tsx
Normal file
196
client-app/app/(auth)/created.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Account Created confirmation screen — dark minimalist.
|
||||
* Показывает адрес + x25519, кнопки copy и export (share) key.json.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, ScrollView, Alert, Pressable, Share } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
import { Header } from '@/components/Header';
|
||||
|
||||
export default function AccountCreatedScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
if (!keyFile) {
|
||||
router.replace('/');
|
||||
return null;
|
||||
}
|
||||
|
||||
async function copy(value: string, label: string) {
|
||||
await Clipboard.setStringAsync(value);
|
||||
setCopied(label);
|
||||
setTimeout(() => setCopied(null), 1800);
|
||||
}
|
||||
|
||||
async function exportKey() {
|
||||
try {
|
||||
const json = JSON.stringify(keyFile, null, 2);
|
||||
// Используем плоский Share API — без записи во временный файл.
|
||||
// Получатель (mail, notes, etc.) получит текст целиком; юзер сам
|
||||
// сохраняет как .json если нужно.
|
||||
await Share.share({
|
||||
message: json,
|
||||
title: 'DChain key file',
|
||||
});
|
||||
} catch (e: any) {
|
||||
Alert.alert('Export failed', e?.message ?? 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header title="Account created" />
|
||||
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
|
||||
{/* Success badge */}
|
||||
<View style={{ alignItems: 'center', marginTop: 10, marginBottom: 18 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 64, height: 64, borderRadius: 32,
|
||||
backgroundColor: 'rgba(59,165,93,0.15)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="checkmark" size={32} color="#3ba55d" />
|
||||
</View>
|
||||
<Text style={{ color: '#ffffff', fontSize: 20, fontWeight: '800' }}>
|
||||
Welcome aboard
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 6, textAlign: 'center' }}>
|
||||
Keys have been generated and stored securely.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Address */}
|
||||
<KeyCard
|
||||
title="Your address (Ed25519)"
|
||||
value={keyFile.pub_key}
|
||||
copied={copied === 'address'}
|
||||
onCopy={() => copy(keyFile.pub_key, 'address')}
|
||||
/>
|
||||
|
||||
{/* X25519 */}
|
||||
<View style={{ height: 10 }} />
|
||||
<KeyCard
|
||||
title="Encryption key (X25519)"
|
||||
value={keyFile.x25519_pub}
|
||||
copied={copied === 'x25519'}
|
||||
onCopy={() => copy(keyFile.x25519_pub, 'x25519')}
|
||||
/>
|
||||
|
||||
{/* Backup */}
|
||||
<View
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(240,179,90,0.08)',
|
||||
borderWidth: 1, borderColor: 'rgba(240,179,90,0.25)',
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 6 }}>
|
||||
<Ionicons name="lock-closed-outline" size={14} color="#f0b35a" />
|
||||
<Text style={{ color: '#f0b35a', fontSize: 13, fontWeight: '700', marginLeft: 6 }}>
|
||||
Backup your key file
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ color: '#d0a26a', fontSize: 12, lineHeight: 17, marginBottom: 10 }}>
|
||||
Export it now and store somewhere safe — password managers, cold
|
||||
storage, printed paper. If you lose it, you lose the account.
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={exportKey}
|
||||
style={({ pressed }) => ({
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 10, borderRadius: 999,
|
||||
backgroundColor: pressed ? '#2a1f0f' : '#1a1409',
|
||||
borderWidth: 1, borderColor: 'rgba(240,179,90,0.35)',
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#f0b35a', fontWeight: '700', fontSize: 14 }}>
|
||||
Export key.json
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Continue */}
|
||||
<Pressable
|
||||
onPress={() => router.replace('/(app)/chats' as never)}
|
||||
style={({ pressed }) => ({
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 14, borderRadius: 999, marginTop: 20,
|
||||
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}>
|
||||
Open messenger
|
||||
</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyCard({
|
||||
title, value, copied, onCopy,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
padding: 14,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
|
||||
textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={{ color: '#ffffff', fontSize: 12, fontFamily: 'monospace', lineHeight: 18 }}>
|
||||
{value}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={onCopy}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 9, borderRadius: 999,
|
||||
marginTop: 10,
|
||||
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
})}
|
||||
>
|
||||
<Ionicons
|
||||
name={copied ? 'checkmark' : 'copy-outline'}
|
||||
size={14}
|
||||
color={copied ? '#3ba55d' : '#ffffff'}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: copied ? '#3ba55d' : '#ffffff',
|
||||
fontSize: 13, fontWeight: '600', marginLeft: 6,
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
230
client-app/app/(auth)/import.tsx
Normal file
230
client-app/app/(auth)/import.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Import existing key — dark minimalist.
|
||||
* Два пути:
|
||||
* 1. Paste JSON напрямую в textarea.
|
||||
* 2. Pick файл .json через DocumentPicker.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, TextInput, Alert, Pressable, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { saveKeyFile } from '@/lib/storage';
|
||||
import { useStore } from '@/lib/store';
|
||||
import type { KeyFile } from '@/lib/types';
|
||||
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
|
||||
type Tab = 'paste' | 'file';
|
||||
|
||||
const REQUIRED_FIELDS: (keyof KeyFile)[] = ['pub_key', 'priv_key', 'x25519_pub', 'x25519_priv'];
|
||||
|
||||
function validateKeyFile(raw: string): KeyFile {
|
||||
let parsed: any;
|
||||
try { parsed = JSON.parse(raw.trim()); }
|
||||
catch { throw new Error('Invalid JSON — check that you copied the full key file.'); }
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (!parsed[field] || typeof parsed[field] !== 'string') {
|
||||
throw new Error(`Missing or invalid field: "${field}"`);
|
||||
}
|
||||
if (!/^[0-9a-f]+$/i.test(parsed[field])) {
|
||||
throw new Error(`Field "${field}" must be a hex string.`);
|
||||
}
|
||||
}
|
||||
return parsed as KeyFile;
|
||||
}
|
||||
|
||||
export default function ImportKeyScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
|
||||
const [tab, setTab] = useState<Tab>('paste');
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function applyKey(kf: KeyFile) {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
await saveKeyFile(kf);
|
||||
setKeyFile(kf);
|
||||
router.replace('/(app)/chats' as never);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? 'Import failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasteImport() {
|
||||
setError(null);
|
||||
const text = jsonText.trim();
|
||||
if (!text) {
|
||||
const clip = await Clipboard.getStringAsync();
|
||||
if (clip) setJsonText(clip);
|
||||
return;
|
||||
}
|
||||
try { await applyKey(validateKeyFile(text)); }
|
||||
catch (e: any) { setError(e?.message ?? 'Import failed'); }
|
||||
}
|
||||
|
||||
async function pickFile() {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: ['application/json', 'text/plain', '*/*'],
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
if (result.canceled) return;
|
||||
const asset = result.assets[0];
|
||||
setFileName(asset.name);
|
||||
const response = await fetch(asset.uri);
|
||||
const raw = await response.text();
|
||||
await applyKey(validateKeyFile(raw));
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? 'Import failed');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="Import key"
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||
/>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 14, paddingBottom: 40 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 14 }}>
|
||||
Restore your account from a previously exported{' '}
|
||||
<Text style={{ color: '#ffffff', fontWeight: '600' }}>dchain_key.json</Text>.
|
||||
</Text>
|
||||
|
||||
{/* Tabs */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
padding: 4,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
{(['paste', 'file'] as Tab[]).map(t => (
|
||||
<Pressable
|
||||
key={t}
|
||||
onPress={() => setTab(t)}
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
backgroundColor: tab === t ? '#1d9bf0' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: tab === t ? '#ffffff' : '#8b8b8b',
|
||||
fontWeight: '700', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{t === 'paste' ? 'Paste JSON' : 'Pick file'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{tab === 'paste' ? (
|
||||
<>
|
||||
<TextInput
|
||||
value={jsonText}
|
||||
onChangeText={setJsonText}
|
||||
placeholder='{"pub_key":"…","priv_key":"…","x25519_pub":"…","x25519_priv":"…"}'
|
||||
placeholderTextColor="#5a5a5a"
|
||||
multiline
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
minHeight: 180,
|
||||
textAlignVertical: 'top',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
}}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={handlePasteImport}
|
||||
disabled={loading}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 12, borderRadius: 999, marginTop: 12,
|
||||
backgroundColor: loading ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
})}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
|
||||
{jsonText.trim() ? 'Import key' : 'Paste from clipboard'}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={pickFile}
|
||||
disabled={loading}
|
||||
style={({ pressed }) => ({
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 40, borderRadius: 14,
|
||||
backgroundColor: pressed ? '#111111' : '#0a0a0a',
|
||||
borderWidth: 1, borderStyle: 'dashed', borderColor: '#1f1f1f',
|
||||
})}
|
||||
>
|
||||
<Ionicons name="document-outline" size={32} color="#8b8b8b" />
|
||||
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700', marginTop: 10 }}>
|
||||
{fileName ?? 'Tap to pick key.json'}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 4 }}>
|
||||
Will auto-import on selection
|
||||
</Text>
|
||||
</Pressable>
|
||||
{loading && (
|
||||
<View style={{ alignItems: 'center', marginTop: 12 }}>
|
||||
<ActivityIndicator color="#1d9bf0" />
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(244,33,46,0.08)',
|
||||
borderWidth: 1, borderColor: 'rgba(244,33,46,0.25)',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#f4212e', fontSize: 13 }}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
59
client-app/app/_layout.tsx
Normal file
59
client-app/app/_layout.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import '../global.css';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { View } from 'react-native';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
// GestureHandlerRootView обязателен для работы gesture-handler'а
|
||||
// на всех страницах: Pan/LongPress/Tap жестах внутри чатов.
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { loadKeyFile, loadSettings } from '@/lib/storage';
|
||||
import { setNodeUrl } from '@/lib/api';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
export default function RootLayout() {
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const setSettings = useStore(s => s.setSettings);
|
||||
const booted = useStore(s => s.booted);
|
||||
const setBooted = useStore(s => s.setBooted);
|
||||
|
||||
// Bootstrap: load key + settings from storage синхронно до первого
|
||||
// render'а экранов. Пока `booted=false` мы рендерим чёрный экран —
|
||||
// это убирает "мелькание" welcome'а при старте, когда ключи уже есть
|
||||
// в AsyncStorage, но ещё не успели загрузиться в store.
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [kf, settings] = await Promise.all([loadKeyFile(), loadSettings()]);
|
||||
if (kf) setKeyFile(kf);
|
||||
setSettings(settings);
|
||||
setNodeUrl(settings.nodeUrl);
|
||||
} finally {
|
||||
setBooted(true);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<View className="flex-1 bg-background">
|
||||
<StatusBar style="light" />
|
||||
{booted ? (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: '#000000' },
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
// Пустой чёрный экран пока bootstrap идёт — без flicker'а.
|
||||
<View style={{ flex: 1, backgroundColor: '#000000' }} />
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
519
client-app/app/index.tsx
Normal file
519
client-app/app/index.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Onboarding — 3-слайдовый pager перед auth-экранами.
|
||||
*
|
||||
* Slide 1 — "Why DChain": value-proposition, 3 пункта с иконками.
|
||||
* Slide 2 — "How it works": выбор релей-ноды (public paid vs свой node),
|
||||
* ссылка на Gitea, + node URL input с live ping.
|
||||
* Slide 3 — "Your keys": кнопки Create / Import.
|
||||
*
|
||||
* Если `keyFile` в store уже есть (bootstrap из RootLayout загрузил) —
|
||||
* делаем <Redirect /> в (app), чтобы пользователь не видел вообще никакого
|
||||
* мелькания onboarding'а. До загрузки `booted === false` root показывает
|
||||
* чёрный экран.
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View, Text, TextInput, Pressable, ScrollView,
|
||||
Alert, ActivityIndicator, Linking, Dimensions,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { router, Redirect } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { saveSettings } from '@/lib/storage';
|
||||
import { setNodeUrl, getNetStats } from '@/lib/api';
|
||||
|
||||
const { width: SCREEN_W } = Dimensions.get('window');
|
||||
const GITEA_URL = 'https://git.vsecoder.vodka/vsecoder/dchain';
|
||||
|
||||
export default function WelcomeScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height: SCREEN_H } = useWindowDimensions();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const booted = useStore(s => s.booted);
|
||||
const settings = useStore(s => s.settings);
|
||||
const setSettings = useStore(s => s.setSettings);
|
||||
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [nodeInput, setNodeInput] = useState('');
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [nodeOk, setNodeOk] = useState<boolean | null>(null);
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
|
||||
useEffect(() => { setNodeInput(settings.nodeUrl); }, [settings.nodeUrl]);
|
||||
|
||||
// ВСЕ hooks должны быть объявлены ДО любого early-return, иначе
|
||||
// React на следующем render'е посчитает разное число hooks и выкинет
|
||||
// "Rendered fewer hooks than expected". useCallback ниже — тоже hook.
|
||||
const applyNode = useCallback(async (url: string) => {
|
||||
const clean = url.trim().replace(/\/$/, '');
|
||||
if (!clean) return;
|
||||
setChecking(true);
|
||||
setNodeOk(null);
|
||||
setNodeUrl(clean);
|
||||
try {
|
||||
await getNetStats();
|
||||
setNodeOk(true);
|
||||
const next = { ...settings, nodeUrl: clean };
|
||||
setSettings(next);
|
||||
await saveSettings(next);
|
||||
} catch {
|
||||
setNodeOk(false);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [settings, setSettings]);
|
||||
|
||||
const onQrScanned = useCallback(({ data }: { data: string }) => {
|
||||
setScanning(false);
|
||||
let url = data.trim();
|
||||
try { const p = JSON.parse(url); if (p.nodeUrl) url = p.nodeUrl; } catch {}
|
||||
setNodeInput(url);
|
||||
applyNode(url);
|
||||
}, [applyNode]);
|
||||
|
||||
// Bootstrap ещё не закончился — ничего не рендерим, RootLayout покажет
|
||||
// чёрный экран (single source of truth для splash-state'а).
|
||||
if (!booted) return null;
|
||||
|
||||
// Ключи уже загружены — сразу в main app, без мелькания onboarding'а.
|
||||
if (keyFile) return <Redirect href={'/(app)/chats' as never} />;
|
||||
|
||||
const openScanner = async () => {
|
||||
if (!permission?.granted) {
|
||||
const { granted } = await requestPermission();
|
||||
if (!granted) {
|
||||
Alert.alert('Camera permission required', 'Allow camera access to scan QR codes.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setScanning(true);
|
||||
};
|
||||
|
||||
const goToPage = (p: number) => {
|
||||
scrollRef.current?.scrollTo({ x: p * SCREEN_W, animated: true });
|
||||
setPage(p);
|
||||
};
|
||||
|
||||
if (scanning) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000' }}>
|
||||
<CameraView
|
||||
style={{ flex: 1 }}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={onQrScanned}
|
||||
/>
|
||||
<View style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<View style={{ width: 240, height: 240, borderWidth: 2, borderColor: '#fff', borderRadius: 16 }} />
|
||||
<Text style={{ color: '#fff', marginTop: 20, opacity: 0.8 }}>
|
||||
Point at a DChain node QR code
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => setScanning(false)}
|
||||
style={{
|
||||
position: 'absolute', top: 56, left: 16,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20,
|
||||
paddingHorizontal: 16, paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontSize: 16 }}>✕ Cancel</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const statusColor = nodeOk === true ? '#3ba55d' : nodeOk === false ? '#f4212e' : '#8b8b8b';
|
||||
|
||||
// Высота footer'а (dots + inset) — резервируем под неё снизу каждого
|
||||
// слайда, чтобы CTA-кнопки оказывались прямо над индикатором страниц,
|
||||
// а не залезали под него.
|
||||
const FOOTER_H = Math.max(insets.bottom, 20) + 8 + 12 + 7; // = padBottom + padTop + dot
|
||||
const PAGE_H = SCREEN_H - FOOTER_H;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000' }}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={e => {
|
||||
const p = Math.round(e.nativeEvent.contentOffset.x / SCREEN_W);
|
||||
setPage(p);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* ───────── Slide 1: Why DChain ───────── */}
|
||||
<View style={{ width: SCREEN_W, height: PAGE_H }}>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 28,
|
||||
paddingTop: insets.top + 60,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={{ alignItems: 'center', marginBottom: 36 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 88, height: 88, borderRadius: 24,
|
||||
backgroundColor: '#1d9bf0',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="chatbubbles" size={44} color="#ffffff" />
|
||||
</View>
|
||||
<Text style={{ color: '#ffffff', fontSize: 30, fontWeight: '800', letterSpacing: -0.8 }}>
|
||||
DChain
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 14, lineHeight: 20, marginTop: 6 }}>
|
||||
A messenger that belongs to you.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<FeatureRow
|
||||
icon="lock-closed"
|
||||
title="End-to-end encryption"
|
||||
text="X25519 + NaCl на каждом сообщении. Даже релей-нода не может прочитать переписку."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon="key"
|
||||
title="Твои ключи — твой аккаунт"
|
||||
text="Без телефона, email и серверных паролей. Ключи никогда не покидают устройство."
|
||||
/>
|
||||
<FeatureRow
|
||||
icon="git-network"
|
||||
title="Decentralised"
|
||||
text="Любой может поднять свою ноду. Нет единой точки отказа и цензуры."
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* CTA — прижата к правому нижнему краю. */}
|
||||
<View style={{
|
||||
flexDirection: 'row', justifyContent: 'flex-end',
|
||||
paddingHorizontal: 24, paddingBottom: 8,
|
||||
}}>
|
||||
<CTAPrimary label="Продолжить" onPress={() => goToPage(1)} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ───────── Slide 2: How it works ───────── */}
|
||||
<View style={{ width: SCREEN_W, height: PAGE_H }}>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 28,
|
||||
paddingTop: insets.top + 40,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5 }}>
|
||||
Как это работает
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, marginBottom: 22 }}>
|
||||
Сообщения проходят через релей-ноду в зашифрованном виде.
|
||||
Выбери публичную или подключи свою.
|
||||
</Text>
|
||||
|
||||
<OptionCard
|
||||
icon="globe"
|
||||
title="Публичная нода"
|
||||
text="Удобно и быстро — нода хостится комьюнити, небольшая комиссия за каждое отправленное сообщение."
|
||||
/>
|
||||
<OptionCard
|
||||
icon="hardware-chip"
|
||||
title="Своя нода"
|
||||
text="Максимальный контроль. Исходники открыты — подними на своём сервере за 5 минут."
|
||||
/>
|
||||
|
||||
<Text style={{
|
||||
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
|
||||
textTransform: 'uppercase', letterSpacing: 1.2, marginTop: 20, marginBottom: 8,
|
||||
}}>
|
||||
Node URL
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1, flexDirection: 'row', alignItems: 'center',
|
||||
backgroundColor: '#0a0a0a', borderWidth: 1, borderColor: '#1f1f1f',
|
||||
borderRadius: 12, paddingHorizontal: 12, gap: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ width: 7, height: 7, borderRadius: 3.5, backgroundColor: statusColor }} />
|
||||
<TextInput
|
||||
value={nodeInput}
|
||||
onChangeText={t => { setNodeInput(t); setNodeOk(null); }}
|
||||
onEndEditing={() => applyNode(nodeInput)}
|
||||
onSubmitEditing={() => applyNode(nodeInput)}
|
||||
placeholder="http://192.168.1.10:8080"
|
||||
placeholderTextColor="#5a5a5a"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
style={{ flex: 1, color: '#ffffff', fontSize: 14, paddingVertical: 12 }}
|
||||
/>
|
||||
{checking
|
||||
? <ActivityIndicator size="small" color="#8b8b8b" />
|
||||
: nodeOk === true
|
||||
? <Ionicons name="checkmark" size={16} color="#3ba55d" />
|
||||
: nodeOk === false
|
||||
? <Ionicons name="close" size={16} color="#f4212e" />
|
||||
: null}
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={openScanner}
|
||||
style={({ pressed }) => ({
|
||||
width: 48, alignItems: 'center', justifyContent: 'center',
|
||||
backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
borderRadius: 12,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="qr-code-outline" size={22} color="#ffffff" />
|
||||
</Pressable>
|
||||
</View>
|
||||
{nodeOk === false && (
|
||||
<Text style={{ color: '#f4212e', fontSize: 12, marginTop: 6 }}>
|
||||
Cannot reach node — check URL and that the node is running
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* CTA — прижата к правому нижнему краю. */}
|
||||
<View style={{
|
||||
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
|
||||
paddingHorizontal: 24, paddingBottom: 8,
|
||||
}}>
|
||||
<CTASecondary
|
||||
label="Исходники"
|
||||
icon="logo-github"
|
||||
onPress={() => Linking.openURL(GITEA_URL).catch(() => {})}
|
||||
/>
|
||||
<CTAPrimary label="Продолжить" onPress={() => goToPage(2)} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ───────── Slide 3: Your keys ───────── */}
|
||||
<View style={{ width: SCREEN_W, height: PAGE_H }}>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 28,
|
||||
paddingTop: insets.top + 60,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={{ alignItems: 'center', marginBottom: 36 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 88, height: 88, borderRadius: 24,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="key" size={44} color="#1d9bf0" />
|
||||
</View>
|
||||
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5, textAlign: 'center' }}>
|
||||
Твой аккаунт
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, textAlign: 'center', maxWidth: 280 }}>
|
||||
Создай новую пару ключей или импортируй существующую.
|
||||
Ключи хранятся только на этом устройстве.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* CTA — прижата к правому нижнему краю. */}
|
||||
<View style={{
|
||||
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
|
||||
paddingHorizontal: 24, paddingBottom: 8,
|
||||
}}>
|
||||
<CTASecondary
|
||||
label="Импорт"
|
||||
onPress={() => router.push('/(auth)/import' as never)}
|
||||
/>
|
||||
<CTAPrimary
|
||||
label="Создать аккаунт"
|
||||
onPress={() => router.push('/(auth)/create' as never)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer: dots-only pager indicator. CTA-кнопки теперь inline
|
||||
на каждом слайде, чтобы выглядели как полноценные кнопки, а не
|
||||
мелкий "Далее" в углу. */}
|
||||
<View style={{
|
||||
paddingHorizontal: 28,
|
||||
paddingBottom: Math.max(insets.bottom, 20) + 8,
|
||||
paddingTop: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
gap: 6,
|
||||
}}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<Pressable
|
||||
key={i}
|
||||
onPress={() => goToPage(i)}
|
||||
hitSlop={8}
|
||||
style={{
|
||||
width: page === i ? 22 : 7,
|
||||
height: 7,
|
||||
borderRadius: 3.5,
|
||||
backgroundColor: page === i ? '#1d9bf0' : '#2a2a2a',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── helper components ─────────
|
||||
|
||||
/**
|
||||
* Primary CTA button — синий pill. Натуральная ширина (hugs content),
|
||||
* `numberOfLines={1}` на лейбле чтобы текст не переносился. Фон
|
||||
* применяется через inner View, а не напрямую на Pressable — это
|
||||
* обходит редкие RN-баги, когда backgroundColor на Pressable не
|
||||
* рендерится пока кнопка не нажата.
|
||||
*/
|
||||
function CTAPrimary({ label, onPress }: { label: string; onPress: () => void }) {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.85 : 1 })}>
|
||||
<View
|
||||
style={{
|
||||
height: 46,
|
||||
paddingHorizontal: 22,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#1d9bf0',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
/** Secondary CTA — тёмный pill с border'ом, optional icon слева. */
|
||||
function CTASecondary({
|
||||
label, icon, onPress,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: React.ComponentProps<typeof Ionicons>['name'];
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}>
|
||||
<View
|
||||
style={{
|
||||
height: 46,
|
||||
paddingHorizontal: 18,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{icon && <Ionicons name={icon} size={15} color="#ffffff" />}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureRow({
|
||||
icon, title, text,
|
||||
}: { icon: React.ComponentProps<typeof Ionicons>['name']; title: string; text: string }) {
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', marginBottom: 20, gap: 14 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 40, height: 40, borderRadius: 12,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name={icon} size={20} color="#1d9bf0" />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700', marginBottom: 3 }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19 }}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionCard({
|
||||
icon, title, text, actionLabel, onAction,
|
||||
}: {
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
title: string;
|
||||
text: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
borderRadius: 14, padding: 14, marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
||||
<Ionicons name={icon} size={18} color="#1d9bf0" />
|
||||
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19 }}>
|
||||
{text}
|
||||
</Text>
|
||||
{actionLabel && onAction && (
|
||||
<Pressable onPress={onAction} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, marginTop: 8 })}>
|
||||
<Text style={{ color: '#1d9bf0', fontSize: 13, fontWeight: '600' }}>
|
||||
{actionLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
12
client-app/babel.config.js
Normal file
12
client-app/babel.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||
'nativewind/babel',
|
||||
],
|
||||
plugins: [
|
||||
'react-native-reanimated/plugin', // must be last
|
||||
],
|
||||
};
|
||||
};
|
||||
35
client-app/components/AnimatedSlot.tsx
Normal file
35
client-app/components/AnimatedSlot.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* AnimatedSlot — renders the (app) group as a native <Stack>.
|
||||
*
|
||||
* Why Stack instead of Slot: Slot is stack-less. When a child route
|
||||
* (e.g. profile/[address]) pushes another child, Slot swaps content
|
||||
* with no history, so router.back() falls all the way through to the
|
||||
* URL root instead of returning to the caller (e.g. /chats/xyz →
|
||||
* /profile/abc → back should go to /chats/xyz, not to /chats).
|
||||
*
|
||||
* Tab switching stays flat because NavBar uses router.replace, which
|
||||
* maps to navigation.replace on the Stack → no history accumulation.
|
||||
*
|
||||
* animation: 'none' on tab roots keeps tab-swap instant (matches the
|
||||
* prior Slot look). Sub-routes (profile/*, compose, feed/*, chats/[id])
|
||||
* inherit slide_from_right from their own nested _layout.tsx Stacks,
|
||||
* which is where the push animation happens.
|
||||
*
|
||||
* This file is named AnimatedSlot for git-history continuity — the
|
||||
* Animated.Value + translateX slide was removed earlier (got stuck at
|
||||
* ±width when interrupted by re-render cascades).
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export function AnimatedSlot() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: '#000000' },
|
||||
animation: 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
client-app/components/Avatar.tsx
Normal file
76
client-app/components/Avatar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Avatar — круглая заглушка с инициалом, опционально online-пип.
|
||||
* Нет зависимостей от асинхронных источников (картинок) — для messenger-тайла
|
||||
* важнее мгновенный рендер, чем фотография. Если в будущем будут фото,
|
||||
* расширяем здесь.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
export interface AvatarProps {
|
||||
/** Имя / @username — берём первый символ для placeholder. */
|
||||
name?: string;
|
||||
/** Адрес (hex pubkey) — fallback для тех у кого нет имени. */
|
||||
address?: string;
|
||||
/** Общий размер в px. По умолчанию 48 (tile size). */
|
||||
size?: number;
|
||||
/** Цвет пипа справа-снизу. undefined = без пипа. */
|
||||
dotColor?: string;
|
||||
/** Класс для обёртки (position: relative кадр). */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Простое хэширование имени → один из 6 оттенков серого для разнообразия. */
|
||||
function pickBg(seed: string): string {
|
||||
const shades = ['#1a1a1a', '#222222', '#2a2a2a', '#151515', '#1c1c1c', '#1f1f1f'];
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) & 0xffff;
|
||||
return shades[h % shades.length];
|
||||
}
|
||||
|
||||
export function Avatar({ name, address, size = 48, dotColor, className }: AvatarProps) {
|
||||
const seed = (name ?? address ?? '?').replace(/^@/, '');
|
||||
const initial = seed.charAt(0).toUpperCase() || '?';
|
||||
const bg = pickBg(seed);
|
||||
|
||||
return (
|
||||
<View className={className} style={{ width: size, height: size, position: 'relative' }}>
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: bg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: '#d0d0d0',
|
||||
fontSize: size * 0.4,
|
||||
fontWeight: '600',
|
||||
includeFontPadding: false,
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</Text>
|
||||
</View>
|
||||
{dotColor && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: size * 0.28,
|
||||
height: size * 0.28,
|
||||
borderRadius: size * 0.14,
|
||||
backgroundColor: dotColor,
|
||||
borderWidth: 2,
|
||||
borderColor: '#000',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
174
client-app/components/ChatTile.tsx
Normal file
174
client-app/components/ChatTile.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* ChatTile — одна строка в списке чатов на главной (Messages screen).
|
||||
*
|
||||
* Layout:
|
||||
* [avatar 44] [name (+verified) (+kind-icon)] [time]
|
||||
* [last-msg preview] [unread pill]
|
||||
*
|
||||
* Kind-icon — мегафон для channel, 👥 для group, ничего для direct.
|
||||
* Verified checkmark — если у контакта есть @username.
|
||||
* Online-dot на аватарке — только для direct-чатов с x25519 ключом.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import type { Contact, Message } from '@/lib/types';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { formatWhen } from '@/lib/dates';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
function previewText(s: string, max = 50): string {
|
||||
return s.length <= max ? s : s.slice(0, max).trimEnd() + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Текстовое превью последнего сообщения. Если у сообщения нет текста
|
||||
* (только вложение) — возвращаем маркер с иконкой названием типа:
|
||||
* "🖼 Photo" / "🎬 Video" / "🎙 Voice" / "📎 File"
|
||||
* Если текст есть — он используется; если есть и то и другое, префикс
|
||||
* добавляется перед текстом.
|
||||
*/
|
||||
function lastPreview(m: Message): string {
|
||||
const emojiByKind = {
|
||||
image: '🖼', video: '🎬', voice: '🎙', file: '📎',
|
||||
} as const;
|
||||
const labelByKind = {
|
||||
image: 'Photo', video: 'Video', voice: 'Voice message', file: 'File',
|
||||
} as const;
|
||||
const text = m.text.trim();
|
||||
if (m.attachment) {
|
||||
const prefix = `${emojiByKind[m.attachment.kind]} ${labelByKind[m.attachment.kind]}`;
|
||||
return text ? `${prefix} ${previewText(text, 40)}` : prefix;
|
||||
}
|
||||
return previewText(text);
|
||||
}
|
||||
|
||||
function shortAddr(a: string, n = 5): string {
|
||||
if (!a) return '—';
|
||||
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||
}
|
||||
|
||||
function displayName(c: Contact): string {
|
||||
return c.username ? `@${c.username}` : c.alias ?? shortAddr(c.address);
|
||||
}
|
||||
|
||||
export interface ChatTileProps {
|
||||
contact: Contact;
|
||||
lastMessage: Message | null;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
|
||||
const name = displayName(c);
|
||||
const last = lastMessage;
|
||||
|
||||
// Визуальный маркер типа чата.
|
||||
const kindIcon: React.ComponentProps<typeof Ionicons>['name'] | null =
|
||||
c.kind === 'group' ? 'people' : null;
|
||||
|
||||
// Unread берётся из runtime-store'а (инкрементится в useGlobalInbox,
|
||||
// обнуляется при открытии чата). Fallback на c.unread для legacy seed.
|
||||
const storeUnread = useStore(s => s.unreadByContact[c.address] ?? 0);
|
||||
const unreadCount = storeUnread || (c.unread ?? 0);
|
||||
const unread = unreadCount > 0 ? unreadCount : null;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? '#0a0a0a' : 'transparent',
|
||||
})}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
name={name}
|
||||
address={c.address}
|
||||
size={44}
|
||||
dotColor={c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined}
|
||||
/>
|
||||
|
||||
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
|
||||
{/* Первая строка: [kind-icon] name [verified] ··· time */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{kindIcon && (
|
||||
<Ionicons
|
||||
name={kindIcon}
|
||||
size={12}
|
||||
color="#8b8b8b"
|
||||
style={{ marginRight: 5 }}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ color: '#ffffff', fontWeight: '700', fontSize: 15, flex: 1 }}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
{c.username && (
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={14}
|
||||
color="#1d9bf0"
|
||||
style={{ marginLeft: 4, marginRight: 2 }}
|
||||
/>
|
||||
)}
|
||||
{last && (
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginLeft: 6 }}>
|
||||
{formatWhen(last.timestamp)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Вторая строка: [✓✓ mine-seen] preview ··· [unread] */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
|
||||
{last?.mine && (
|
||||
<Ionicons
|
||||
name="checkmark-done-outline"
|
||||
size={13}
|
||||
color="#8b8b8b"
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}
|
||||
>
|
||||
{last
|
||||
? lastPreview(last)
|
||||
: c.x25519Pub
|
||||
? 'Tap to start encrypted chat'
|
||||
: 'Waiting for identity…'}
|
||||
</Text>
|
||||
|
||||
{unread !== null && (
|
||||
<View
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
paddingHorizontal: 5,
|
||||
borderRadius: 9,
|
||||
backgroundColor: '#1d9bf0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '700' }}>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
329
client-app/components/Composer.tsx
Normal file
329
client-app/components/Composer.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Composer — плавающий блок ввода сообщения, прибит к низу.
|
||||
*
|
||||
* Композиция:
|
||||
* 1. Опциональный баннер (edit / reply) сверху.
|
||||
* 2. Опциональная pending-attachment preview.
|
||||
* 3. Либо:
|
||||
* - обычный input-bubble с `[+] [textarea] [↑/🎤/⭕]`
|
||||
* - inline VoiceRecorder когда идёт запись голосового
|
||||
*
|
||||
* Send-action зависит от состояния:
|
||||
* - есть текст/attachment → ↑ (send)
|
||||
* - пусто → показываем две иконки: 🎤 (start voice) + ⭕ (open video circle)
|
||||
*
|
||||
* API:
|
||||
* mode, onCancelMode
|
||||
* text, onChangeText
|
||||
* onSend, sending
|
||||
* onAttach — tap на + (AttachmentMenu)
|
||||
* attachment, onClearAttach
|
||||
* onFinishVoice — готовая voice-attachment (из VoiceRecorder)
|
||||
* onStartVideoCircle — tap на ⭕, родитель открывает VideoCircleRecorder
|
||||
* placeholder
|
||||
*/
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { View, Text, TextInput, Pressable, ActivityIndicator, Image } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import type { Attachment } from '@/lib/types';
|
||||
import { VoiceRecorder } from '@/components/chat/VoiceRecorder';
|
||||
|
||||
export type ComposerMode =
|
||||
| { kind: 'new' }
|
||||
| { kind: 'edit'; text: string }
|
||||
| { kind: 'reply'; msgId: string; author: string; preview: string };
|
||||
|
||||
export interface ComposerProps {
|
||||
mode: ComposerMode;
|
||||
onCancelMode?: () => void;
|
||||
|
||||
text: string;
|
||||
onChangeText: (t: string) => void;
|
||||
|
||||
onSend: () => void;
|
||||
sending?: boolean;
|
||||
|
||||
onAttach?: () => void;
|
||||
|
||||
attachment?: Attachment | null;
|
||||
onClearAttach?: () => void;
|
||||
|
||||
/** Voice recording завершена и отправляем сразу (мгновенный flow). */
|
||||
onFinishVoice?: (att: Attachment) => void;
|
||||
/** Tap на "⭕" — родитель открывает VideoCircleRecorder. */
|
||||
onStartVideoCircle?: () => void;
|
||||
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const INPUT_MIN_HEIGHT = 24;
|
||||
const INPUT_MAX_HEIGHT = 72;
|
||||
|
||||
export function Composer(props: ComposerProps) {
|
||||
const {
|
||||
mode, onCancelMode, text, onChangeText, onSend, sending, onAttach,
|
||||
attachment, onClearAttach,
|
||||
onFinishVoice, onStartVideoCircle,
|
||||
placeholder,
|
||||
} = props;
|
||||
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [recordingVoice, setRecordingVoice] = useState(false);
|
||||
|
||||
const hasContent = !!text.trim() || !!attachment;
|
||||
const canSend = hasContent && !sending;
|
||||
const inEdit = mode.kind === 'edit';
|
||||
const inReply = mode.kind === 'reply';
|
||||
|
||||
const focusInput = () => inputRef.current?.focus();
|
||||
|
||||
return (
|
||||
<View style={{ paddingHorizontal: 8, paddingTop: 6, paddingBottom: 4, gap: 6 }}>
|
||||
{/* ── Banner: edit / reply ── */}
|
||||
{(inEdit || inReply) && !recordingVoice && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
backgroundColor: '#111111',
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={inEdit ? 'create-outline' : 'arrow-undo-outline'}
|
||||
size={16}
|
||||
color="#ffffff"
|
||||
/>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
{inEdit && (
|
||||
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}>
|
||||
Edit message
|
||||
</Text>
|
||||
)}
|
||||
{inReply && (
|
||||
<>
|
||||
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '700' }} numberOfLines={1}>
|
||||
Reply to {(mode as { author: string }).author}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12 }} numberOfLines={1}>
|
||||
{(mode as { preview: string }).preview}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={onCancelMode}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
|
||||
>
|
||||
<Ionicons name="close" size={20} color="#8b8b8b" />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Pending attachment preview ── */}
|
||||
{attachment && !recordingVoice && (
|
||||
<AttachmentChip attachment={attachment} onClear={onClearAttach} />
|
||||
)}
|
||||
|
||||
{/* ── Voice recording (inline) ИЛИ обычный input ── */}
|
||||
{recordingVoice ? (
|
||||
<VoiceRecorder
|
||||
onFinish={(att) => {
|
||||
setRecordingVoice(false);
|
||||
onFinishVoice?.(att);
|
||||
}}
|
||||
onCancel={() => setRecordingVoice(false)}
|
||||
/>
|
||||
) : (
|
||||
<Pressable onPress={focusInput}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#111111',
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
paddingLeft: 4,
|
||||
paddingRight: 8,
|
||||
paddingVertical: 6,
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{/* + attach — всегда, кроме edit */}
|
||||
{onAttach && !inEdit && (
|
||||
<Pressable
|
||||
onPress={(e) => { e.stopPropagation?.(); onAttach(); }}
|
||||
hitSlop={6}
|
||||
style={({ pressed }) => ({
|
||||
width: 32, height: 32, borderRadius: 16,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="add" size={22} color="#ffffff" />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder ?? 'Message'}
|
||||
placeholderTextColor="#5a5a5a"
|
||||
multiline
|
||||
maxLength={2000}
|
||||
style={{
|
||||
flex: 1,
|
||||
color: '#ffffff',
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
minHeight: INPUT_MIN_HEIGHT,
|
||||
maxHeight: INPUT_MAX_HEIGHT,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6,
|
||||
paddingLeft: onAttach && !inEdit ? 6 : 10,
|
||||
paddingRight: 6,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Правая часть: send ИЛИ [mic + video-circle] */}
|
||||
{canSend ? (
|
||||
<Pressable
|
||||
onPress={(e) => { e.stopPropagation?.(); onSend(); }}
|
||||
style={({ pressed }) => ({
|
||||
width: 32, height: 32, borderRadius: 16,
|
||||
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Ionicons name="arrow-up" size={18} color="#ffffff" />
|
||||
)}
|
||||
</Pressable>
|
||||
) : !inEdit && (onFinishVoice || onStartVideoCircle) ? (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
{onStartVideoCircle && (
|
||||
<Pressable
|
||||
onPress={(e) => { e.stopPropagation?.(); onStartVideoCircle(); }}
|
||||
hitSlop={6}
|
||||
style={({ pressed }) => ({
|
||||
width: 32, height: 32, borderRadius: 16,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="videocam-outline" size={20} color="#ffffff" />
|
||||
</Pressable>
|
||||
)}
|
||||
{onFinishVoice && (
|
||||
<Pressable
|
||||
onPress={(e) => { e.stopPropagation?.(); setRecordingVoice(true); }}
|
||||
hitSlop={6}
|
||||
style={({ pressed }) => ({
|
||||
width: 32, height: 32, borderRadius: 16,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="mic-outline" size={20} color="#ffffff" />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Attachment chip — preview текущего pending attachment'а ────────
|
||||
|
||||
function AttachmentChip({
|
||||
attachment, onClear,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
onClear?: () => void;
|
||||
}) {
|
||||
const icon: React.ComponentProps<typeof Ionicons>['name'] =
|
||||
attachment.kind === 'image' ? 'image-outline' :
|
||||
attachment.kind === 'video' ? 'videocam-outline' :
|
||||
attachment.kind === 'voice' ? 'mic-outline' :
|
||||
'document-outline';
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
backgroundColor: '#111111',
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
{attachment.kind === 'image' || attachment.kind === 'video' ? (
|
||||
<Image
|
||||
source={{ uri: attachment.uri }}
|
||||
style={{
|
||||
width: 40, height: 40,
|
||||
borderRadius: attachment.circle ? 20 : 8,
|
||||
backgroundColor: '#0a0a0a',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: 40, height: 40, borderRadius: 8,
|
||||
backgroundColor: '#1a1a1a',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name={icon} size={20} color="#ffffff" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '600' }} numberOfLines={1}>
|
||||
{attachment.name ?? attachmentLabel(attachment)}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 11 }} numberOfLines={1}>
|
||||
{attachment.kind.toUpperCase()}
|
||||
{attachment.circle ? ' · circle' : ''}
|
||||
{attachment.size ? ` · ${(attachment.size / 1024).toFixed(0)} KB` : ''}
|
||||
{attachment.duration ? ` · ${attachment.duration}s` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={onClear}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, padding: 4 })}
|
||||
>
|
||||
<Ionicons name="close" size={18} color="#8b8b8b" />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function attachmentLabel(a: Attachment): string {
|
||||
switch (a.kind) {
|
||||
case 'image': return 'Photo';
|
||||
case 'video': return a.circle ? 'Video message' : 'Video';
|
||||
case 'voice': return 'Voice message';
|
||||
case 'file': return 'File';
|
||||
}
|
||||
}
|
||||
76
client-app/components/Header.tsx
Normal file
76
client-app/components/Header.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Header — единая шапка экрана: [left slot] [title centered] [right slot].
|
||||
*
|
||||
* Правила выравнивания:
|
||||
* - left/right принимают натуральную ширину контента (обычно 1-2
|
||||
* IconButton'а 36px, или pressable-avatar 32px).
|
||||
* - title (ReactNode, принимает как string, так и compound — аватар +
|
||||
* имя вместе) всегда центрирован через flex:1 + alignItems:center.
|
||||
* Абсолютно не позиционируется, т.к. при слишком широком title'е
|
||||
* лучше ужать его, чем наложить на кнопки.
|
||||
*
|
||||
* `title` может быть строкой (тогда рендерится как Text 17px semibold)
|
||||
* либо произвольным node'ом — используется в chat detail для
|
||||
* [avatar][name + typing-subtitle] compound-блока.
|
||||
*
|
||||
* `divider` (default true) — тонкая 1px линия снизу; в tab-страницах
|
||||
* обычно выключена (TabHeader всегда ставит divider=false).
|
||||
*/
|
||||
import React, { ReactNode } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
export interface HeaderProps {
|
||||
title?: ReactNode;
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
/** Показывать нижнюю тонкую линию-разделитель. По умолчанию true. */
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
export function Header({ title, left, right, divider = true }: HeaderProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: divider ? 1 : 0,
|
||||
borderBottomColor: '#0f0f0f',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minHeight: 44,
|
||||
}}
|
||||
>
|
||||
{/* Left slot — натуральная ширина, минимум 44 чтобы title
|
||||
визуально центрировался для одно-icon-left + одно-icon-right. */}
|
||||
<View style={{ minWidth: 44, alignItems: 'flex-start' }}>{left}</View>
|
||||
|
||||
{/* Title centered */}
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
{typeof title === 'string' ? (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.2,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
) : title ?? null}
|
||||
</View>
|
||||
|
||||
{/* Right slot — row, натуральная ширина, минимум 44. gap=4
|
||||
чтобы несколько IconButton'ов не слипались в selection-mode. */}
|
||||
<View style={{ minWidth: 44, flexDirection: 'row', justifyContent: 'flex-end', gap: 4 }}>
|
||||
{right}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
61
client-app/components/IconButton.tsx
Normal file
61
client-app/components/IconButton.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* IconButton — круглая touch-target кнопка под Ionicon.
|
||||
*
|
||||
* Три варианта:
|
||||
* - 'ghost' — прозрачная, используется в хедере (шестерёнка, back).
|
||||
* - 'solid' — акцентный заливной круг, например composer FAB.
|
||||
* - 'tile' — квадратная заливка 36×36 для небольших action-chip'ов.
|
||||
*
|
||||
* Размер управляется props.size (диаметр). Touch-target никогда меньше 40px
|
||||
* (accessibility), поэтому для size<40 внутренний иконопад растёт.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Pressable, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
|
||||
export interface IconButtonProps {
|
||||
icon: IoniconName;
|
||||
onPress?: () => void;
|
||||
variant?: 'ghost' | 'solid' | 'tile';
|
||||
size?: number; // visual diameter; hit slop ensures accessibility
|
||||
color?: string; // override icon color
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
icon, onPress, variant = 'ghost', size = 40, color, disabled, className,
|
||||
}: IconButtonProps) {
|
||||
const iconSize = Math.round(size * 0.5);
|
||||
const bg =
|
||||
variant === 'solid' ? '#1d9bf0' :
|
||||
variant === 'tile' ? '#1a1a1a' :
|
||||
'transparent';
|
||||
const tint =
|
||||
color ??
|
||||
(variant === 'solid' ? '#ffffff' :
|
||||
disabled ? '#3a3a3a' :
|
||||
'#e7e7e7');
|
||||
|
||||
const radius = variant === 'tile' ? 10 : size / 2;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={disabled ? undefined : onPress}
|
||||
hitSlop={8}
|
||||
className={className}
|
||||
style={({ pressed }) => ({
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: radius,
|
||||
backgroundColor: pressed && !disabled ? (variant === 'solid' ? '#1a8cd8' : '#1a1a1a') : bg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<Ionicons name={icon} size={iconSize} color={tint} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
150
client-app/components/NavBar.tsx
Normal file
150
client-app/components/NavBar.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* NavBar — нижний бар на 5 иконок без подписей.
|
||||
*
|
||||
* Активный таб:
|
||||
* - иконка заполненная (Ionicons variant без `-outline`)
|
||||
* - вокруг иконки subtle highlight-блок (чуть светлее bg), радиус 14
|
||||
* - текст/бейдж остаются как у inactive
|
||||
*
|
||||
* Inactive:
|
||||
* - outline-иконка, цвет #6b6b6b
|
||||
* - soon-таб дополнительно dimmed и показывает чип SOON
|
||||
*
|
||||
* Роутинг через expo-router `router.replace` — без стекa, каждый tab это
|
||||
* полная страница без "back" концепции.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Pressable, Text } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter, usePathname } from 'expo-router';
|
||||
|
||||
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
|
||||
interface Item {
|
||||
key: string;
|
||||
href: string;
|
||||
icon: IoniconName;
|
||||
iconActive: IoniconName;
|
||||
badge?: number;
|
||||
soon?: boolean;
|
||||
}
|
||||
|
||||
export interface NavBarProps {
|
||||
bottomInset?: number;
|
||||
requestCount?: number;
|
||||
notifCount?: number;
|
||||
}
|
||||
|
||||
export function NavBar({ bottomInset = 0, requestCount = 0, notifCount = 0 }: NavBarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const items: Item[] = [
|
||||
{ key: 'home', href: '/(app)/chats', icon: 'home-outline', iconActive: 'home', badge: requestCount },
|
||||
{ key: 'add', href: '/(app)/new-contact', icon: 'search-outline', iconActive: 'search' },
|
||||
{ key: 'feed', href: '/(app)/feed', icon: 'newspaper-outline', iconActive: 'newspaper' },
|
||||
{ key: 'notif', href: '/(app)/requests', icon: 'notifications-outline', iconActive: 'notifications', badge: notifCount },
|
||||
{ key: 'wallet', href: '/(app)/wallet', icon: 'wallet-outline', iconActive: 'wallet' },
|
||||
];
|
||||
|
||||
// NavBar active-matching: путь может начинаться с "/chats" ИЛИ с href
|
||||
// напрямую. Вариант `/chats/xyz` тоже считается active для home.
|
||||
const isActive = (href: string) => {
|
||||
// Нормализуем /(app)/chats → /chats
|
||||
const norm = href.replace(/^\/\(app\)/, '');
|
||||
return pathname === norm || pathname.startsWith(norm + '/');
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
backgroundColor: '#000000',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#0f0f0f',
|
||||
paddingTop: 8,
|
||||
paddingBottom: Math.max(bottomInset, 8),
|
||||
}}
|
||||
>
|
||||
{items.map((it) => {
|
||||
const active = isActive(it.href);
|
||||
return (
|
||||
<Pressable
|
||||
key={it.key}
|
||||
onPress={() => {
|
||||
if (it.soon) return;
|
||||
router.replace(it.href as never);
|
||||
}}
|
||||
hitSlop={6}
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 4,
|
||||
opacity: pressed ? 0.65 : 1,
|
||||
})}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
// highlight-блок вокруг active-иконки
|
||||
width: 52,
|
||||
height: 36,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={active ? it.iconActive : it.icon}
|
||||
size={26}
|
||||
color={active ? '#ffffff' : it.soon ? '#3a3a3a' : '#6b6b6b'}
|
||||
/>
|
||||
{it.badge && it.badge > 0 ? (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 8,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#1d9bf0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#000',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontSize: 9, fontWeight: '700' }}>
|
||||
{it.badge > 99 ? '99+' : it.badge}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{it.soon && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: 2,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#1a1a1a',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 7, fontWeight: '700', letterSpacing: 0.3 }}>
|
||||
SOON
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
88
client-app/components/SearchBar.tsx
Normal file
88
client-app/components/SearchBar.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* SearchBar — серый блок, в состоянии idle текст с иконкой 🔍 отцентрированы.
|
||||
*
|
||||
* Когда пользователь тапает/фокусирует — поле становится input-friendly, но
|
||||
* визуально рестайл не нужен: при наличии текста placeholder скрыт и
|
||||
* пользовательский ввод выравнивается влево автоматически (multiline off).
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { View, TextInput, Text } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
export interface SearchBarProps {
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
onSubmitEditing?: () => void;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing,
|
||||
}: SearchBarProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
// Placeholder центрируется пока нет фокуса И нет значения.
|
||||
// Как только юзер фокусируется или начинает печатать — иконка+текст
|
||||
// прыгают к левому краю, чтобы не мешать вводу.
|
||||
const centered = !focused && !value;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 9,
|
||||
minHeight: 36,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{centered ? (
|
||||
// ── Idle state — только текст+icon, отцентрированы.
|
||||
// Невидимый TextInput поверх ловит tap, чтобы не дергать focus вручную.
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 6 }} />
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 14 }}>{placeholder}</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
autoFocus={autoFocus}
|
||||
onFocus={() => setFocused(true)}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
returnKeyType="search"
|
||||
style={{
|
||||
position: 'absolute', left: 0, right: 0, top: 0, bottom: 0,
|
||||
color: 'transparent',
|
||||
// Скрываем cursor в idle-режиме; при focus компонент перерисуется.
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 8 }} />
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#8b8b8b"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
returnKeyType="search"
|
||||
style={{
|
||||
flex: 1,
|
||||
color: '#ffffff',
|
||||
fontSize: 14,
|
||||
padding: 0,
|
||||
includeFontPadding: false,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
59
client-app/components/TabHeader.tsx
Normal file
59
client-app/components/TabHeader.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* TabHeader — общая шапка для всех tab-страниц (home/feed/notifications/wallet).
|
||||
*
|
||||
* Структура строго как в референсе Messages-экрана:
|
||||
* [avatar 32 → /settings] [title] [right slot]
|
||||
*
|
||||
* Без нижнего разделителя (divider=false) — тот же уровень, что и фон экрана.
|
||||
*
|
||||
* Right-slot по умолчанию — шестерёнка → /settings. Но экраны могут передать
|
||||
* свой (например, refresh в wallet). Левый avatar — всегда клик-навигация в
|
||||
* settings, как в референсе.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Pressable } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
|
||||
export interface TabHeaderProps {
|
||||
title: string;
|
||||
/** Right-slot. Если не передан — выставляется IconButton с settings-outline. */
|
||||
right?: React.ReactNode;
|
||||
/** Dot-color на profile-avatar'е (например, WS live/polling indicator). */
|
||||
profileDotColor?: string;
|
||||
}
|
||||
|
||||
export function TabHeader({ title, right, profileDotColor }: TabHeaderProps) {
|
||||
const router = useRouter();
|
||||
const username = useStore(s => s.username);
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
return (
|
||||
<Header
|
||||
title={title}
|
||||
divider={false}
|
||||
left={
|
||||
<Pressable onPress={() => router.push('/(app)/settings' as never)} hitSlop={8}>
|
||||
<Avatar
|
||||
name={username ?? '?'}
|
||||
address={keyFile?.pub_key}
|
||||
size={32}
|
||||
dotColor={profileDotColor}
|
||||
/>
|
||||
</Pressable>
|
||||
}
|
||||
right={
|
||||
right ?? (
|
||||
<IconButton
|
||||
icon="settings-outline"
|
||||
size={36}
|
||||
onPress={() => router.push('/(app)/settings' as never)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
188
client-app/components/chat/AttachmentMenu.tsx
Normal file
188
client-app/components/chat/AttachmentMenu.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* AttachmentMenu — bottom-sheet с вариантами прикрепления.
|
||||
*
|
||||
* Выводится при нажатии на `+` в composer'е. Опции:
|
||||
* - 📷 Photo / video из галереи (expo-image-picker)
|
||||
* - 📸 Take photo (камера)
|
||||
* - 📎 File (expo-document-picker)
|
||||
* - 🎙️ Voice message — stub (запись через expo-av потребует
|
||||
* permissions runtime + recording UI; сейчас добавляет мок-
|
||||
* голосовое с duration 4s)
|
||||
*
|
||||
* Всё визуально — тёмный overlay + sheet снизу. Закрытие по tap'у на
|
||||
* overlay или на Cancel.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, Alert, Modal } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
|
||||
import type { Attachment } from '@/lib/types';
|
||||
|
||||
export interface AttachmentMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Вызывается когда attachment готов для отправки. */
|
||||
onPick: (att: Attachment) => void;
|
||||
}
|
||||
|
||||
export function AttachmentMenu({ visible, onClose, onPick }: AttachmentMenuProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const pickImageOrVideo = async () => {
|
||||
try {
|
||||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert('Permission needed', 'Grant photos access to attach media.');
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
quality: 0.85,
|
||||
allowsEditing: false,
|
||||
});
|
||||
if (result.canceled) return;
|
||||
const asset = result.assets[0];
|
||||
onPick({
|
||||
kind: asset.type === 'video' ? 'video' : 'image',
|
||||
uri: asset.uri,
|
||||
mime: asset.mimeType,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
duration: asset.duration ? Math.round(asset.duration / 1000) : undefined,
|
||||
});
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Pick failed', e?.message ?? 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
const takePhoto = async () => {
|
||||
try {
|
||||
const perm = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert('Permission needed', 'Grant camera access to take a photo.');
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({ quality: 0.85 });
|
||||
if (result.canceled) return;
|
||||
const asset = result.assets[0];
|
||||
onPick({
|
||||
kind: asset.type === 'video' ? 'video' : 'image',
|
||||
uri: asset.uri,
|
||||
mime: asset.mimeType,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
});
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Camera failed', e?.message ?? 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
const pickFile = async () => {
|
||||
try {
|
||||
const res = await DocumentPicker.getDocumentAsync({
|
||||
type: '*/*',
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
if (res.canceled) return;
|
||||
const asset = res.assets[0];
|
||||
onPick({
|
||||
kind: 'file',
|
||||
uri: asset.uri,
|
||||
name: asset.name,
|
||||
mime: asset.mimeType ?? undefined,
|
||||
size: asset.size,
|
||||
});
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
Alert.alert('File pick failed', e?.message ?? 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
// Voice recorder больше не stub — см. inline-кнопку 🎤 в composer'е,
|
||||
// которая разворачивает VoiceRecorder (expo-av Audio.Recording). Опция
|
||||
// Voice в этом меню убрана, т.к. дублировала бы UX.
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.55)' }}
|
||||
>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Pressable
|
||||
onPress={() => {}}
|
||||
style={{
|
||||
backgroundColor: '#111111',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingTop: 8,
|
||||
paddingBottom: Math.max(insets.bottom, 12) + 10,
|
||||
paddingHorizontal: 10,
|
||||
borderTopWidth: 1, borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<View
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
width: 40, height: 4, borderRadius: 2,
|
||||
backgroundColor: '#2a2a2a',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: '#ffffff', fontSize: 16, fontWeight: '700',
|
||||
marginLeft: 8, marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Attach
|
||||
</Text>
|
||||
|
||||
<Row icon="images-outline" label="Photo / video" onPress={pickImageOrVideo} />
|
||||
<Row icon="camera-outline" label="Take photo" onPress={takePhoto} />
|
||||
<Row icon="document-outline" label="File" onPress={pickFile} />
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
icon, label, onPress,
|
||||
}: {
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: pressed ? '#1a1a1a' : 'transparent',
|
||||
})}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
backgroundColor: '#1a1a1a',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name={icon} size={20} color="#ffffff" />
|
||||
</View>
|
||||
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '600' }}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
178
client-app/components/chat/AttachmentPreview.tsx
Normal file
178
client-app/components/chat/AttachmentPreview.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* AttachmentPreview — рендер `Message.attachment` внутри bubble'а.
|
||||
*
|
||||
* Четыре формы:
|
||||
* - image → Image с object-fit cover, aspect-ratio из width/height
|
||||
* - video → то же + play-overlay в центре, duration внизу-справа
|
||||
* - voice → row [play-icon] [waveform stub] [duration]
|
||||
* - file → row [file-icon] [name + size]
|
||||
*
|
||||
* Вложения размещаются ВНУТРИ того же bubble'а что и текст, чуть ниже
|
||||
* footer'а нет и ширина bubble'а снимает maxWidth-ограничение ради
|
||||
* изображений (отдельный media-first-bubble case).
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text, Image } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import type { Attachment } from '@/lib/types';
|
||||
import { VoicePlayer } from '@/components/chat/VoicePlayer';
|
||||
import { VideoCirclePlayer } from '@/components/chat/VideoCirclePlayer';
|
||||
|
||||
export interface AttachmentPreviewProps {
|
||||
attachment: Attachment;
|
||||
/** Используется для тонирования footer-элементов. */
|
||||
own?: boolean;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function AttachmentPreview({ attachment, own }: AttachmentPreviewProps) {
|
||||
switch (attachment.kind) {
|
||||
case 'image':
|
||||
return <ImageAttachment att={attachment} />;
|
||||
case 'video':
|
||||
// circle=true — круглое видео-сообщение (Telegram-стиль).
|
||||
return attachment.circle
|
||||
? <VideoCirclePlayer uri={attachment.uri} duration={attachment.duration} />
|
||||
: <VideoAttachment att={attachment} />;
|
||||
case 'voice':
|
||||
return <VoicePlayer uri={attachment.uri} duration={attachment.duration} own={own} />;
|
||||
case 'file':
|
||||
return <FileAttachment att={attachment} own={own} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Image ──────────────────────────────────────────────────────────
|
||||
|
||||
function ImageAttachment({ att }: { att: Attachment }) {
|
||||
// Aspect-ratio из реальных width/height; fallback 4:3.
|
||||
const aspect = att.width && att.height ? att.width / att.height : 4 / 3;
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: att.uri }}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: aspect,
|
||||
borderRadius: 12,
|
||||
marginBottom: 4,
|
||||
backgroundColor: '#0a0a0a',
|
||||
}}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Video ──────────────────────────────────────────────────────────
|
||||
|
||||
function VideoAttachment({ att }: { att: Attachment }) {
|
||||
const aspect = att.width && att.height ? att.width / att.height : 16 / 9;
|
||||
return (
|
||||
<View style={{ position: 'relative', marginBottom: 4 }}>
|
||||
<Image
|
||||
source={{ uri: att.uri }}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: aspect,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#0a0a0a',
|
||||
}}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
{/* Play overlay по центру */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%', left: '50%',
|
||||
transform: [{ translateX: -22 }, { translateY: -22 }],
|
||||
width: 44, height: 44, borderRadius: 22,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="play" size={22} color="#ffffff" style={{ marginLeft: 2 }} />
|
||||
</View>
|
||||
{att.duration !== undefined && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8, bottom: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
paddingHorizontal: 6, paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '600' }}>
|
||||
{formatDuration(att.duration)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Voice ──────────────────────────────────────────────────────────
|
||||
// Реальный плеер — см. components/chat/VoicePlayer.tsx (expo-av Sound).
|
||||
|
||||
// ─── File ───────────────────────────────────────────────────────────
|
||||
|
||||
function FileAttachment({ att, own }: { att: Attachment; own?: boolean }) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
gap: 10,
|
||||
paddingVertical: 4,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 10,
|
||||
backgroundColor: own ? 'rgba(255,255,255,0.18)' : '#1a1a1a',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="document-text"
|
||||
size={18}
|
||||
color={own ? '#ffffff' : '#ffffff'}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{att.name ?? 'file'}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: own ? 'rgba(255,255,255,0.75)' : '#8b8b8b',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{att.size !== undefined ? formatSize(att.size) : ''}
|
||||
{att.size !== undefined && att.mime ? ' · ' : ''}
|
||||
{att.mime ?? ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
36
client-app/components/chat/DaySeparator.tsx
Normal file
36
client-app/components/chat/DaySeparator.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* DaySeparator — центральный лейбл "Сегодня" / "Вчера" / "17 июня 2025"
|
||||
* между группами сообщений.
|
||||
*
|
||||
* Стиль: тонкий шрифт серого цвета, маленький размер. В референсе этот
|
||||
* лейбл не должен перетягивать на себя внимание — он визуальный якорь,
|
||||
* не заголовок.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text, Platform } from 'react-native';
|
||||
|
||||
export interface DaySeparatorProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function DaySeparator({ label }: DaySeparatorProps) {
|
||||
return (
|
||||
<View style={{ alignItems: 'center', marginTop: 14, marginBottom: 6 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: 12,
|
||||
// Тонкий шрифт — на iOS "200" рисует ultra-light, на Android —
|
||||
// sans-serif-thin. В Expo font-weight 300 почти идентичен на
|
||||
// обеих платформах и доступен без дополнительных шрифтов.
|
||||
fontWeight: '300',
|
||||
// Android font-weight 100-300 требует явной семьи, иначе
|
||||
// округляется до 400. Для thin визуала передаём serif-thin.
|
||||
...(Platform.OS === 'android' ? { fontFamily: 'sans-serif-thin' } : null),
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
385
client-app/components/chat/MessageBubble.tsx
Normal file
385
client-app/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* MessageBubble — рендер одного сообщения с gesture interactions.
|
||||
*
|
||||
* Гестуры — разведены по двум примитивам во избежание конфликта со
|
||||
* скроллом FlatList'а:
|
||||
*
|
||||
* 1. Swipe-left (reply): PanResponder на Animated.View обёртке
|
||||
* bubble'а. `onMoveShouldSetPanResponder` клеймит responder ТОЛЬКО
|
||||
* когда пользователь сдвинул палец > 6px влево и горизонталь
|
||||
* преобладает над вертикалью. Для вертикального скролла
|
||||
* `onMoveShouldSet` возвращает false — FlatList получает gesture.
|
||||
* Touchdown ничего не клеймит (onStartShouldSetPanResponder
|
||||
* отсутствует).
|
||||
*
|
||||
* 2. Long-press / tap: через View.onTouchStart/End. Primitive touch
|
||||
* events bubble'ятся независимо от responder'а. Long-press запускаем
|
||||
* timer'ом на 550ms, cancel при `onTouchMove` с достаточной
|
||||
* амплитудой. Tap — короткое касание без move в selection mode.
|
||||
*
|
||||
* 3. `selectionMode=true` — PanResponder disabled (в selection режиме
|
||||
* свайпы не работают).
|
||||
*
|
||||
* 4. ReplyQuote — отдельный Pressable над bubble-текстом; tap прыгает
|
||||
* к оригиналу через onJumpToReply.
|
||||
*
|
||||
* 5. highlight prop — bubble-row мерцает accent-blue фоном, использует
|
||||
* Animated.Value; управляется из ChatScreen после scrollToIndex.
|
||||
*/
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, Pressable, ViewStyle, Animated, PanResponder,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import type { Message } from '@/lib/types';
|
||||
import { relTime } from '@/lib/dates';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { AttachmentPreview } from '@/components/chat/AttachmentPreview';
|
||||
import { ReplyQuote } from '@/components/chat/ReplyQuote';
|
||||
import { PostRefCard } from '@/components/chat/PostRefCard';
|
||||
|
||||
export const PEER_AVATAR_SLOT = 34;
|
||||
const SWIPE_THRESHOLD = 60;
|
||||
const LONG_PRESS_MS = 550;
|
||||
const TAP_MAX_MOVEMENT = 8;
|
||||
const TAP_MAX_ELAPSED = 300;
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
msg: Message;
|
||||
peerName: string;
|
||||
peerAddress?: string;
|
||||
withSenderMeta?: boolean;
|
||||
showName: boolean;
|
||||
showAvatar: boolean;
|
||||
|
||||
onReply?: (m: Message) => void;
|
||||
onLongPress?: (m: Message) => void;
|
||||
onTap?: (m: Message) => void;
|
||||
onOpenProfile?: () => void;
|
||||
onJumpToReply?: (originalId: string) => void;
|
||||
|
||||
selectionMode?: boolean;
|
||||
selected?: boolean;
|
||||
/** Mgnt-управляемый highlight: row мерцает accent-фоном ~1-2 секунды. */
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
// ─── Bubble styles ──────────────────────────────────────────────────
|
||||
|
||||
const bubbleBase: ViewStyle = {
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 6,
|
||||
};
|
||||
|
||||
const peerBubble: ViewStyle = {
|
||||
...bubbleBase,
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderBottomLeftRadius: 6,
|
||||
};
|
||||
|
||||
const ownBubble: ViewStyle = {
|
||||
...bubbleBase,
|
||||
backgroundColor: '#1d9bf0',
|
||||
borderBottomRightRadius: 6,
|
||||
};
|
||||
|
||||
const bubbleText = { color: '#ffffff', fontSize: 15, lineHeight: 20 } as const;
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────
|
||||
|
||||
export function MessageBubble(props: MessageBubbleProps) {
|
||||
if (props.msg.mine) return <RowShell {...props} variant="own" />;
|
||||
if (!props.withSenderMeta) return <RowShell {...props} variant="peer-compact" />;
|
||||
return <RowShell {...props} variant="group-peer" />;
|
||||
}
|
||||
|
||||
type Variant = 'own' | 'peer-compact' | 'group-peer';
|
||||
|
||||
function RowShell({
|
||||
msg, peerName, peerAddress, showName, showAvatar,
|
||||
onReply, onLongPress, onTap, onOpenProfile, onJumpToReply,
|
||||
selectionMode, selected, highlighted, variant,
|
||||
}: MessageBubbleProps & { variant: Variant }) {
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const startTs = useRef(0);
|
||||
const moved = useRef(false);
|
||||
const lpTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearLp = () => {
|
||||
if (lpTimer.current) { clearTimeout(lpTimer.current); lpTimer.current = null; }
|
||||
};
|
||||
|
||||
// Touch start — запускаем long-press timer (НЕ клеймим responder).
|
||||
const onTouchStart = () => {
|
||||
startTs.current = Date.now();
|
||||
moved.current = false;
|
||||
clearLp();
|
||||
if (onLongPress) {
|
||||
lpTimer.current = setTimeout(() => {
|
||||
if (!moved.current) onLongPress(msg);
|
||||
lpTimer.current = null;
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchMove = (e: { nativeEvent: { pageX: number; pageY: number } }) => {
|
||||
// Если пользователь двигает палец — отменяем long-press timer.
|
||||
// Малые движения (< TAP_MAX_MOVEMENT) игнорируем — устраняют
|
||||
// fale-cancel от дрожания пальца.
|
||||
// Здесь нет точного dx/dy от gesture-системы, используем primitive
|
||||
// touch coords отсчитываемые по абсолютным координатам. Проще —
|
||||
// всегда отменяем на first move (PanResponder ниже отнимет
|
||||
// responder если leftward).
|
||||
moved.current = true;
|
||||
clearLp();
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
const elapsed = Date.now() - startTs.current;
|
||||
clearLp();
|
||||
// Короткий tap без движения → в selection mode toggle.
|
||||
if (!moved.current && elapsed < TAP_MAX_ELAPSED && selectionMode) {
|
||||
onTap?.(msg);
|
||||
}
|
||||
};
|
||||
|
||||
// Swipe-to-reply: PanResponder клеймит ТОЛЬКО leftward-dominant move.
|
||||
// Для vertical scroll / rightward swipe / start-touch возвращает false,
|
||||
// FlatList / AnimatedSlot получают gesture.
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onMoveShouldSetPanResponder: (_e, g) => {
|
||||
if (selectionMode) return false;
|
||||
// Leftward > 6px и горизонталь преобладает.
|
||||
return g.dx < -6 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5;
|
||||
},
|
||||
onPanResponderGrant: () => {
|
||||
// Как только мы заклеймили gesture, отменяем long-press
|
||||
// (пользователь явно свайпает, не удерживает).
|
||||
clearLp();
|
||||
moved.current = true;
|
||||
},
|
||||
onPanResponderMove: (_e, g) => {
|
||||
translateX.setValue(Math.min(0, g.dx));
|
||||
},
|
||||
onPanResponderRelease: (_e, g) => {
|
||||
if (g.dx <= -SWIPE_THRESHOLD) onReply?.(msg);
|
||||
Animated.spring(translateX, {
|
||||
toValue: 0, friction: 6, tension: 80, useNativeDriver: true,
|
||||
}).start();
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
Animated.spring(translateX, {
|
||||
toValue: 0, friction: 6, tension: 80, useNativeDriver: true,
|
||||
}).start();
|
||||
},
|
||||
}),
|
||||
).current;
|
||||
|
||||
// Highlight fade: при переключении highlighted=true крутим короткую
|
||||
// анимацию "flash + fade out" через Animated.Value (0→1→0 за ~1.8s).
|
||||
const highlightAnim = useRef(new Animated.Value(0)).current;
|
||||
useEffect(() => {
|
||||
if (!highlighted) return;
|
||||
highlightAnim.setValue(0);
|
||||
Animated.sequence([
|
||||
Animated.timing(highlightAnim, { toValue: 1, duration: 150, useNativeDriver: false }),
|
||||
Animated.delay(1400),
|
||||
Animated.timing(highlightAnim, { toValue: 0, duration: 450, useNativeDriver: false }),
|
||||
]).start();
|
||||
}, [highlighted, highlightAnim]);
|
||||
|
||||
const highlightBg = highlightAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['rgba(29,155,240,0)', 'rgba(29,155,240,0.22)'],
|
||||
});
|
||||
|
||||
const isMine = variant === 'own';
|
||||
const hasAttachment = !!msg.attachment;
|
||||
const hasPostRef = !!msg.postRef;
|
||||
const hasReply = !!msg.replyTo;
|
||||
const attachmentOnly = hasAttachment && !msg.text.trim();
|
||||
const bubbleStyle = attachmentOnly
|
||||
? { ...(isMine ? ownBubble : peerBubble), padding: 4 }
|
||||
: (isMine ? ownBubble : peerBubble);
|
||||
|
||||
const bubbleNode = (
|
||||
<Animated.View
|
||||
{...panResponder.panHandlers}
|
||||
style={{
|
||||
transform: [{ translateX }],
|
||||
maxWidth: hasAttachment ? '80%' : '85%',
|
||||
minWidth: hasAttachment || hasReply ? 220 : undefined,
|
||||
}}
|
||||
>
|
||||
<View style={bubbleStyle}>
|
||||
{msg.replyTo && (
|
||||
<ReplyQuote
|
||||
author={msg.replyTo.author}
|
||||
preview={msg.replyTo.text}
|
||||
own={isMine}
|
||||
onJump={() => onJumpToReply?.(msg.replyTo!.id)}
|
||||
/>
|
||||
)}
|
||||
{msg.attachment && (
|
||||
<AttachmentPreview attachment={msg.attachment} own={isMine} />
|
||||
)}
|
||||
{msg.postRef && (
|
||||
<PostRefCard
|
||||
postID={msg.postRef.postID}
|
||||
author={msg.postRef.author}
|
||||
excerpt={msg.postRef.excerpt}
|
||||
hasImage={!!msg.postRef.hasImage}
|
||||
own={isMine}
|
||||
/>
|
||||
)}
|
||||
{msg.text.trim() ? (
|
||||
<Text style={bubbleText}>{msg.text}</Text>
|
||||
) : null}
|
||||
<BubbleFooter
|
||||
edited={!!msg.edited}
|
||||
time={relTime(msg.timestamp)}
|
||||
own={isMine}
|
||||
read={!!msg.read}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
const contentRow =
|
||||
variant === 'own' ? (
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'flex-end' }}>
|
||||
{bubbleNode}
|
||||
</View>
|
||||
) : variant === 'peer-compact' ? (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
|
||||
{bubbleNode}
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{showName && (
|
||||
<Pressable
|
||||
onPress={onOpenProfile}
|
||||
hitSlop={4}
|
||||
style={{ marginLeft: PEER_AVATAR_SLOT, marginBottom: 3 }}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12 }} numberOfLines={1}>
|
||||
{peerName}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
|
||||
<View style={{ width: PEER_AVATAR_SLOT, alignItems: 'flex-start' }}>
|
||||
{showAvatar ? (
|
||||
<Pressable onPress={onOpenProfile} hitSlop={4}>
|
||||
<Avatar name={peerName} address={peerAddress} size={26} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{bubbleNode}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchCancel={() => { clearLp(); moved.current = true; }}
|
||||
style={{
|
||||
paddingHorizontal: 8,
|
||||
marginBottom: 6,
|
||||
// Selection & highlight накладываются: highlight flash побеждает
|
||||
// когда анимация > 0, иначе статичный selection-tint.
|
||||
backgroundColor: selected ? 'rgba(29,155,240,0.12)' : highlightBg,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{contentRow}
|
||||
{selectionMode && (
|
||||
<CheckDot
|
||||
selected={!!selected}
|
||||
onPress={() => onTap?.(msg)}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Clickable check-dot ────────────────────────────────────────────
|
||||
|
||||
function CheckDot({ selected, onPress }: { selected: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={12}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
top: 0, bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: selected ? '#1d9bf0' : 'rgba(0,0,0,0.55)',
|
||||
borderWidth: 2,
|
||||
borderColor: selected ? '#1d9bf0' : '#6b6b6b',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{selected && <Ionicons name="checkmark" size={12} color="#ffffff" />}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Footer ─────────────────────────────────────────────────────────
|
||||
|
||||
interface FooterProps {
|
||||
edited: boolean;
|
||||
time: string;
|
||||
own?: boolean;
|
||||
read?: boolean;
|
||||
}
|
||||
|
||||
function BubbleFooter({ edited, time, own, read }: FooterProps) {
|
||||
const textColor = own ? 'rgba(255,255,255,0.78)' : '#8b8b8b';
|
||||
const dotColor = own ? 'rgba(255,255,255,0.55)' : '#5a5a5a';
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 2,
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{edited && (
|
||||
<>
|
||||
<Text style={{ color: textColor, fontSize: 11 }}>Edited</Text>
|
||||
<Text style={{ color: dotColor, fontSize: 11 }}>·</Text>
|
||||
</>
|
||||
)}
|
||||
<Text style={{ color: textColor, fontSize: 11 }}>{time}</Text>
|
||||
{own && (
|
||||
<Ionicons
|
||||
name={read ? 'checkmark-circle' : 'checkmark-circle-outline'}
|
||||
size={13}
|
||||
color={read ? '#ffffff' : 'rgba(255,255,255,0.78)'}
|
||||
style={{ marginLeft: 2 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
143
client-app/components/chat/PostRefCard.tsx
Normal file
143
client-app/components/chat/PostRefCard.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* PostRefCard — renders a shared feed post inside a chat bubble.
|
||||
*
|
||||
* Visually distinct from plain messages so the user sees at-a-glance
|
||||
* that this came from the feed, not a direct-typed text. Matches
|
||||
* VK's "shared wall post" embed pattern:
|
||||
*
|
||||
* [newspaper icon] ПОСТ
|
||||
* @author · 2 строки excerpt'а
|
||||
* [📷 Фото in this post]
|
||||
*
|
||||
* Tap → /(app)/feed/{postID}. The full post (with image + stats +
|
||||
* like button) is displayed in the standard post-detail screen.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
|
||||
export interface PostRefCardProps {
|
||||
postID: string;
|
||||
author: string;
|
||||
excerpt: string;
|
||||
hasImage?: boolean;
|
||||
/** True when the card appears inside the sender's own bubble (our own
|
||||
* share). Adjusts colour contrast so it reads on the blue bubble
|
||||
* background. */
|
||||
own: boolean;
|
||||
}
|
||||
|
||||
function shortAddr(a: string, n = 6): string {
|
||||
if (!a) return '—';
|
||||
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||
}
|
||||
|
||||
export function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefCardProps) {
|
||||
const contacts = useStore(s => s.contacts);
|
||||
|
||||
// Resolve author name the same way the feed does.
|
||||
const contact = contacts.find(c => c.address === author);
|
||||
const displayName = contact?.username
|
||||
? `@${contact.username}`
|
||||
: contact?.alias ?? shortAddr(author);
|
||||
|
||||
const onOpen = () => {
|
||||
router.push(`/(app)/feed/${postID}` as never);
|
||||
};
|
||||
|
||||
// Tinted palette based on bubble side — inside an "own" (blue) bubble
|
||||
// the card uses a deeper blue so it reads as a distinct nested block,
|
||||
// otherwise we use the standard card colours.
|
||||
const bg = own ? 'rgba(0, 0, 0, 0.22)' : '#0a0a0a';
|
||||
const border = own ? 'rgba(255, 255, 255, 0.15)' : '#1f1f1f';
|
||||
const labelColor = own ? 'rgba(255, 255, 255, 0.75)' : '#1d9bf0';
|
||||
const bodyColor = own ? '#ffffff' : '#ffffff';
|
||||
const subColor = own ? 'rgba(255, 255, 255, 0.65)' : '#8b8b8b';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onOpen}
|
||||
style={({ pressed }) => ({
|
||||
marginBottom: 6,
|
||||
borderRadius: 14,
|
||||
backgroundColor: pressed ? 'rgba(0,0,0,0.35)' : bg,
|
||||
borderWidth: 1,
|
||||
borderColor: border,
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{/* Top ribbon: "ПОСТ" label — makes the shared nature unmistakable. */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 4,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="newspaper-outline" size={11} color={labelColor} />
|
||||
<Text
|
||||
style={{
|
||||
color: labelColor,
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1.2,
|
||||
}}
|
||||
>
|
||||
ПОСТ
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Author + excerpt */}
|
||||
<View style={{ flexDirection: 'row', paddingHorizontal: 10, paddingBottom: 10 }}>
|
||||
<Avatar name={displayName} address={author} size={28} />
|
||||
<View style={{ flex: 1, marginLeft: 8, minWidth: 0, overflow: 'hidden' }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
color: bodyColor,
|
||||
fontWeight: '700',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{excerpt.length > 0 && (
|
||||
<Text
|
||||
numberOfLines={3}
|
||||
style={{
|
||||
color: subColor,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{excerpt}
|
||||
</Text>
|
||||
)}
|
||||
{hasImage && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="image-outline" size={11} color={subColor} />
|
||||
<Text style={{ color: subColor, fontSize: 11 }}>
|
||||
с фото
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
70
client-app/components/chat/ReplyQuote.tsx
Normal file
70
client-app/components/chat/ReplyQuote.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* ReplyQuote — блок "цитаты" внутри bubble'а сообщения-ответа.
|
||||
*
|
||||
* Визуал: slim-row с синим бордером слева (accent-bar), author в синем,
|
||||
* preview text — серым, в одну строку.
|
||||
*
|
||||
* Tap на quoted-блок → onJump → ChatScreen скроллит к оригиналу и
|
||||
* подсвечивает его на пару секунд. Если оригинал не найден в текущем
|
||||
* списке (удалён / ушёл за пределы пагинации) — onJump может просто
|
||||
* no-op'нуть.
|
||||
*
|
||||
* Цвета зависят от того в чьём bubble'е мы находимся:
|
||||
* - own (синий bubble) → quote border = белый, текст белый/85%
|
||||
* - peer (серый bubble) → quote border = accent blue, текст white
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
|
||||
export interface ReplyQuoteProps {
|
||||
author: string;
|
||||
preview: string;
|
||||
own?: boolean;
|
||||
onJump?: () => void;
|
||||
}
|
||||
|
||||
export function ReplyQuote({ author, preview, own, onJump }: ReplyQuoteProps) {
|
||||
const barColor = own ? 'rgba(255,255,255,0.85)' : '#1d9bf0';
|
||||
const authorColor = own ? '#ffffff' : '#1d9bf0';
|
||||
const previewColor = own ? 'rgba(255,255,255,0.85)' : '#c0c0c0';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onJump}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row',
|
||||
backgroundColor: own ? 'rgba(255,255,255,0.10)' : 'rgba(29,155,240,0.10)',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 5,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
{/* Accent bar слева */}
|
||||
<View
|
||||
style={{
|
||||
width: 3,
|
||||
backgroundColor: barColor,
|
||||
}}
|
||||
/>
|
||||
<View style={{ flex: 1, paddingHorizontal: 8, paddingVertical: 6 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: authorColor,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{author}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ color: previewColor, fontSize: 13 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{preview || 'attachment'}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
158
client-app/components/chat/VideoCirclePlayer.tsx
Normal file
158
client-app/components/chat/VideoCirclePlayer.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* VideoCirclePlayer — telegram-style круглое видео-сообщение.
|
||||
*
|
||||
* Мигрировано с expo-av `<Video>` на expo-video `<VideoView>` +
|
||||
* useVideoPlayer hook (expo-av deprecated в SDK 54).
|
||||
*
|
||||
* UI:
|
||||
* - Круглая thumbnail-рамка (Image превью первого кадра) с play-overlay
|
||||
* - Tap → полноэкранный Modal с VideoView в круглой рамке, auto-play + loop
|
||||
* - Duration badge снизу
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, Pressable, Modal, Image } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useVideoPlayer, VideoView } from 'expo-video';
|
||||
|
||||
export interface VideoCirclePlayerProps {
|
||||
uri: string;
|
||||
duration?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
function formatClock(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function VideoCirclePlayer({ uri, duration, size = 220 }: VideoCirclePlayerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={() => setOpen(true)}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: size / 2,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#0a0a0a',
|
||||
marginBottom: 4,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Статический thumbnail через Image (первый кадр если платформа
|
||||
поддерживает, иначе чёрный фон). Реальное видео играет только
|
||||
в Modal ради производительности FlatList'а. */}
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 52, height: 52, borderRadius: 26,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="play" size={22} color="#ffffff" style={{ marginLeft: 2 }} />
|
||||
</View>
|
||||
{duration !== undefined && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: size / 2 - 26, bottom: 16,
|
||||
paddingHorizontal: 6, paddingVertical: 2,
|
||||
borderRadius: 6,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '600' }}>
|
||||
{formatClock(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{open && (
|
||||
<VideoModal uri={uri} onClose={() => setOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal рендерится только когда open=true — это значит useVideoPlayer
|
||||
// не создаёт лишних плееров пока пользователь не открыл overlay.
|
||||
|
||||
function VideoModal({ uri, onClose }: { uri: string; onClose: () => void }) {
|
||||
// useVideoPlayer может throw'нуть на некоторых платформах при
|
||||
// невалидных source'ах. try/catch вокруг render'а защищает парента
|
||||
// от полного crash'а.
|
||||
let player: ReturnType<typeof useVideoPlayer> | null = null;
|
||||
try {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
player = useVideoPlayer({ uri }, (p) => {
|
||||
p.loop = true;
|
||||
p.muted = false;
|
||||
p.play();
|
||||
});
|
||||
} catch {
|
||||
player = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.92)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: '90%',
|
||||
aspectRatio: 1,
|
||||
maxWidth: 420, maxHeight: 420,
|
||||
borderRadius: 9999,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
{player ? (
|
||||
<VideoView
|
||||
player={player}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
contentFit="cover"
|
||||
nativeControls={false}
|
||||
/>
|
||||
) : (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Ionicons name="alert-circle-outline" size={36} color="#8b8b8b" />
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 8 }}>
|
||||
Playback not available
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 48, right: 16,
|
||||
width: 40, height: 40, borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.14)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={22} color="#ffffff" />
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
217
client-app/components/chat/VideoCircleRecorder.tsx
Normal file
217
client-app/components/chat/VideoCircleRecorder.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* VideoCircleRecorder — full-screen Modal для записи круглого видео-
|
||||
* сообщения (Telegram-style).
|
||||
*
|
||||
* UX:
|
||||
* 1. Открывается Modal с CameraView (по умолчанию front-camera).
|
||||
* 2. Превью — круглое (аналогично VideoCirclePlayer).
|
||||
* 3. Большая красная кнопка внизу: tap-to-start, tap-to-stop.
|
||||
* 4. Максимум 15 секунд — авто-стоп.
|
||||
* 5. По stop'у возвращаем attachment { kind:'video', circle:true, uri, duration }.
|
||||
* 6. Свайп вниз / close-icon → cancel (без отправки).
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, Pressable, Modal, Alert } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { CameraView, useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
||||
|
||||
import type { Attachment } from '@/lib/types';
|
||||
|
||||
export interface VideoCircleRecorderProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onFinish: (att: Attachment) => void;
|
||||
}
|
||||
|
||||
const MAX_DURATION_SEC = 15;
|
||||
|
||||
function formatClock(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function VideoCircleRecorder({ visible, onClose, onFinish }: VideoCircleRecorderProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const camRef = useRef<CameraView>(null);
|
||||
|
||||
const [camPerm, requestCam] = useCameraPermissions();
|
||||
const [micPerm, requestMic] = useMicrophonePermissions();
|
||||
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const startedAt = useRef(0);
|
||||
const facing: 'front' | 'back' = 'front';
|
||||
|
||||
// Timer + auto-stop at MAX_DURATION_SEC
|
||||
useEffect(() => {
|
||||
if (!recording) return;
|
||||
const t = setInterval(() => {
|
||||
const s = Math.floor((Date.now() - startedAt.current) / 1000);
|
||||
setElapsed(s);
|
||||
if (s >= MAX_DURATION_SEC) stopAndSend();
|
||||
}, 250);
|
||||
return () => clearInterval(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recording]);
|
||||
|
||||
// Permissions on mount of visible
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setRecording(false);
|
||||
setElapsed(0);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
if (!camPerm?.granted) await requestCam();
|
||||
if (!micPerm?.granted) await requestMic();
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible]);
|
||||
|
||||
const start = async () => {
|
||||
if (!camRef.current || recording) return;
|
||||
try {
|
||||
startedAt.current = Date.now();
|
||||
setElapsed(0);
|
||||
setRecording(true);
|
||||
// recordAsync блокируется до stopRecording или maxDuration
|
||||
const result = await camRef.current.recordAsync({ maxDuration: MAX_DURATION_SEC });
|
||||
setRecording(false);
|
||||
if (!result?.uri) return;
|
||||
const seconds = Math.max(1, Math.floor((Date.now() - startedAt.current) / 1000));
|
||||
onFinish({
|
||||
kind: 'video',
|
||||
circle: true,
|
||||
uri: result.uri,
|
||||
duration: seconds,
|
||||
mime: 'video/mp4',
|
||||
});
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setRecording(false);
|
||||
Alert.alert('Recording failed', e?.message ?? 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
const stopAndSend = () => {
|
||||
if (!recording) return;
|
||||
camRef.current?.stopRecording();
|
||||
// recordAsync promise выше resolve'нется с uri → onFinish
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (recording) {
|
||||
camRef.current?.stopRecording();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const permOK = camPerm?.granted && micPerm?.granted;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={cancel}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: '#000000',
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: Math.max(insets.bottom, 12),
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', padding: 12 }}>
|
||||
<Pressable
|
||||
onPress={cancel}
|
||||
hitSlop={10}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 18,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={20} color="#ffffff" />
|
||||
</Pressable>
|
||||
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', flex: 1, textAlign: 'center' }}>
|
||||
Video message
|
||||
</Text>
|
||||
<View style={{ width: 36 }} />
|
||||
</View>
|
||||
|
||||
{/* Camera */}
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||
{permOK ? (
|
||||
<View
|
||||
style={{
|
||||
width: '85%',
|
||||
aspectRatio: 1,
|
||||
maxWidth: 360, maxHeight: 360,
|
||||
borderRadius: 9999,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: recording ? 3 : 0,
|
||||
borderColor: '#f4212e',
|
||||
}}
|
||||
>
|
||||
<CameraView
|
||||
ref={camRef}
|
||||
style={{ flex: 1 }}
|
||||
facing={facing}
|
||||
mode="video"
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ alignItems: 'center', paddingHorizontal: 24 }}>
|
||||
<Ionicons name="videocam-off-outline" size={42} color="#8b8b8b" />
|
||||
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 12 }}>
|
||||
Permissions needed
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 4, textAlign: 'center' }}>
|
||||
Camera + microphone access are required to record a video message.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Timer */}
|
||||
{recording && (
|
||||
<Text
|
||||
style={{
|
||||
color: '#f4212e',
|
||||
fontSize: 14, fontWeight: '700',
|
||||
marginTop: 14,
|
||||
}}
|
||||
>
|
||||
● {formatClock(elapsed)} / {formatClock(MAX_DURATION_SEC)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Record / Stop button */}
|
||||
<View style={{ alignItems: 'center', paddingBottom: 16 }}>
|
||||
<Pressable
|
||||
onPress={recording ? stopAndSend : start}
|
||||
disabled={!permOK}
|
||||
style={({ pressed }) => ({
|
||||
width: 72, height: 72, borderRadius: 36,
|
||||
backgroundColor: !permOK ? '#1a1a1a' : recording ? '#f4212e' : '#1d9bf0',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
borderWidth: 4,
|
||||
borderColor: 'rgba(255,255,255,0.2)',
|
||||
})}
|
||||
>
|
||||
<Ionicons
|
||||
name={recording ? 'stop' : 'videocam'}
|
||||
size={30}
|
||||
color="#ffffff"
|
||||
/>
|
||||
</Pressable>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 10 }}>
|
||||
{recording ? 'Tap to stop & send' : permOK ? 'Tap to record' : 'Grant permissions'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
166
client-app/components/chat/VoicePlayer.tsx
Normal file
166
client-app/components/chat/VoicePlayer.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* VoicePlayer — play/pause voice message через expo-audio.
|
||||
*
|
||||
* Раздел на две подкомпоненты:
|
||||
* - RealVoicePlayer: useAudioPlayer с настоящим URI
|
||||
* - StubVoicePlayer: отрисовка waveform без player'а (seed-URI)
|
||||
*
|
||||
* Разделение важно: useAudioPlayer не должен получать null/stub-строки —
|
||||
* при падении внутри expo-audio это крашит render всего bubble'а и
|
||||
* (в FlatList) визуально "пропадает" интерфейс чата.
|
||||
*
|
||||
* UI:
|
||||
* [▶/⏸] ▮▮▮▮▮▮▮▮▮▯▯▯▯▯▯▯▯ 0:03 / 0:17
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
||||
|
||||
export interface VoicePlayerProps {
|
||||
uri: string;
|
||||
duration?: number;
|
||||
own?: boolean;
|
||||
}
|
||||
|
||||
const BAR_COUNT = 22;
|
||||
|
||||
function formatClock(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function isStubUri(u: string): boolean {
|
||||
return u.startsWith('voice-stub://') || u.startsWith('voice-demo://');
|
||||
}
|
||||
|
||||
function useBars(uri: string) {
|
||||
return useMemo(() => {
|
||||
const seed = uri.length;
|
||||
return Array.from({ length: BAR_COUNT }, (_, i) => {
|
||||
const h = ((seed * (i + 1) * 7919) % 11) + 4;
|
||||
return h;
|
||||
});
|
||||
}, [uri]);
|
||||
}
|
||||
|
||||
// ─── Top-level router ──────────────────────────────────────────────
|
||||
|
||||
export function VoicePlayer(props: VoicePlayerProps) {
|
||||
// Stub-URI (seed) не передаётся в useAudioPlayer — hook может крашить
|
||||
// на невалидных source'ах. Рендерим статический waveform.
|
||||
if (isStubUri(props.uri)) return <StubVoicePlayer {...props} />;
|
||||
return <RealVoicePlayer {...props} />;
|
||||
}
|
||||
|
||||
// ─── Stub (seed / preview) ─────────────────────────────────────────
|
||||
|
||||
function StubVoicePlayer({ uri, duration, own }: VoicePlayerProps) {
|
||||
const bars = useBars(uri);
|
||||
const accent = own ? 'rgba(255,255,255,0.92)' : '#1d9bf0';
|
||||
const subtle = own ? 'rgba(255,255,255,0.35)' : '#3a3a3a';
|
||||
const textColor = own ? 'rgba(255,255,255,0.85)' : '#8b8b8b';
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 16,
|
||||
backgroundColor: own ? 'rgba(255,255,255,0.18)' : '#1a1a1a',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="play" size={14} color="#ffffff" style={{ marginLeft: 1 }} />
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2, flex: 1 }}>
|
||||
{bars.map((h, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
width: 2, height: h, borderRadius: 1,
|
||||
backgroundColor: i < 6 ? accent : subtle,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={{ color: textColor, fontSize: 12 }}>
|
||||
{formatClock(duration ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Real expo-audio player ────────────────────────────────────────
|
||||
|
||||
function RealVoicePlayer({ uri, duration, own }: VoicePlayerProps) {
|
||||
const player = useAudioPlayer({ uri });
|
||||
const status = useAudioPlayerStatus(player);
|
||||
const bars = useBars(uri);
|
||||
|
||||
const accent = own ? 'rgba(255,255,255,0.92)' : '#1d9bf0';
|
||||
const subtle = own ? 'rgba(255,255,255,0.35)' : '#3a3a3a';
|
||||
const textColor = own ? 'rgba(255,255,255,0.85)' : '#8b8b8b';
|
||||
|
||||
const playing = !!status.playing;
|
||||
const loading = !!status.isBuffering && !status.isLoaded;
|
||||
const curSec = status.currentTime ?? 0;
|
||||
const totalSec = (status.duration && status.duration > 0) ? status.duration : (duration ?? 0);
|
||||
|
||||
const playedRatio = totalSec > 0 ? Math.min(1, curSec / totalSec) : 0;
|
||||
const playedBars = Math.round(playedRatio * BAR_COUNT);
|
||||
|
||||
const toggle = () => {
|
||||
try {
|
||||
if (status.playing) {
|
||||
player.pause();
|
||||
} else {
|
||||
if (status.duration && curSec >= status.duration - 0.05) {
|
||||
player.seekTo(0);
|
||||
}
|
||||
player.play();
|
||||
}
|
||||
} catch {
|
||||
/* dbl-tap during load */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
|
||||
<Pressable
|
||||
onPress={toggle}
|
||||
hitSlop={8}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 16,
|
||||
backgroundColor: own ? 'rgba(255,255,255,0.18)' : '#1a1a1a',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={playing ? 'pause' : (loading ? 'hourglass-outline' : 'play')}
|
||||
size={14}
|
||||
color="#ffffff"
|
||||
style={{ marginLeft: playing || loading ? 0 : 1 }}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2, flex: 1 }}>
|
||||
{bars.map((h, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
width: 2, height: h, borderRadius: 1,
|
||||
backgroundColor: i < playedBars ? accent : subtle,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={{ color: textColor, fontSize: 12 }}>
|
||||
{playing || curSec > 0
|
||||
? `${formatClock(Math.floor(curSec))} / ${formatClock(Math.floor(totalSec))}`
|
||||
: formatClock(Math.floor(totalSec))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
183
client-app/components/chat/VoiceRecorder.tsx
Normal file
183
client-app/components/chat/VoiceRecorder.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* VoiceRecorder — inline UI для записи голосового сообщения через
|
||||
* expo-audio (заменил deprecated expo-av).
|
||||
*
|
||||
* UX:
|
||||
* - При монтировании проверяет permission + запускает запись
|
||||
* - [🗑] ● timer Recording… [↑]
|
||||
* - 🗑 = cancel (discard), ↑ = stop + send
|
||||
*
|
||||
* Состояние recorder'а живёт в useAudioRecorder hook'е. Prepare + start
|
||||
* вызывается из useEffect. Stop — при release, finalized URI через
|
||||
* `recorder.uri`.
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, Pressable, Alert, Animated } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import {
|
||||
useAudioRecorder, AudioModule, RecordingPresets, setAudioModeAsync,
|
||||
} from 'expo-audio';
|
||||
|
||||
import type { Attachment } from '@/lib/types';
|
||||
|
||||
export interface VoiceRecorderProps {
|
||||
onFinish: (att: Attachment) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function VoiceRecorder({ onFinish, onCancel }: VoiceRecorderProps) {
|
||||
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||
const startedAt = useRef(0);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// Pulsing red dot
|
||||
const pulse = useRef(new Animated.Value(1)).current;
|
||||
useEffect(() => {
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 0.4, duration: 500, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 1, duration: 500, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [pulse]);
|
||||
|
||||
// Start recording at mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const perm = await AudioModule.requestRecordingPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
setError('Microphone permission denied');
|
||||
return;
|
||||
}
|
||||
await setAudioModeAsync({
|
||||
allowsRecording: true,
|
||||
playsInSilentMode: true,
|
||||
});
|
||||
await recorder.prepareToRecordAsync();
|
||||
if (cancelled) return;
|
||||
recorder.record();
|
||||
startedAt.current = Date.now();
|
||||
setReady(true);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? 'Failed to start recording');
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Timer tick
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const t = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - startedAt.current) / 1000));
|
||||
}, 250);
|
||||
return () => clearInterval(t);
|
||||
}, [ready]);
|
||||
|
||||
const stop = async (send: boolean) => {
|
||||
try {
|
||||
if (recorder.isRecording) {
|
||||
await recorder.stop();
|
||||
}
|
||||
const uri = recorder.uri;
|
||||
const seconds = Math.max(1, Math.floor((Date.now() - startedAt.current) / 1000));
|
||||
if (!send || !uri || seconds < 1) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
onFinish({
|
||||
kind: 'voice',
|
||||
uri,
|
||||
duration: seconds,
|
||||
mime: 'audio/m4a',
|
||||
});
|
||||
} catch (e: any) {
|
||||
Alert.alert('Recording failed', e?.message ?? 'Unknown error');
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const mm = Math.floor(elapsed / 60);
|
||||
const ss = elapsed % 60;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#111111',
|
||||
borderRadius: 22,
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
paddingHorizontal: 14, paddingVertical: 8,
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="alert-circle" size={18} color="#f4212e" />
|
||||
<Text style={{ color: '#f4212e', fontSize: 13, flex: 1 }}>{error}</Text>
|
||||
<Pressable onPress={onCancel} hitSlop={8}>
|
||||
<Ionicons name="close" size={20} color="#8b8b8b" />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#111111',
|
||||
borderRadius: 22,
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
paddingHorizontal: 10, paddingVertical: 6,
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => stop(false)}
|
||||
hitSlop={8}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 16,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#f4212e" />
|
||||
</Pressable>
|
||||
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, flex: 1 }}>
|
||||
<Animated.View
|
||||
style={{
|
||||
width: 10, height: 10, borderRadius: 5,
|
||||
backgroundColor: '#f4212e',
|
||||
opacity: pulse,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '600' }}>
|
||||
{mm}:{String(ss).padStart(2, '0')}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12 }}>
|
||||
Recording…
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => stop(true)}
|
||||
style={({ pressed }) => ({
|
||||
width: 32, height: 32, borderRadius: 16,
|
||||
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<Ionicons name="arrow-up" size={18} color="#ffffff" />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
79
client-app/components/chat/rows.ts
Normal file
79
client-app/components/chat/rows.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Группировка сообщений в rows для FlatList чат-экрана.
|
||||
*
|
||||
* Чистая функция — никаких React-зависимостей, легко тестируется unit'ом.
|
||||
*
|
||||
* Правила:
|
||||
* 1. Между разными календарными днями вставляется {kind:'sep', label}.
|
||||
* 2. Внутри одного дня peer-сообщения группируются в "лесенку" с учётом:
|
||||
* - смены отправителя
|
||||
* - перерыва > 1 часа между соседними сообщениями
|
||||
* В пределах одной группы:
|
||||
* showName = true только у первого
|
||||
* showAvatar = true только у последнего
|
||||
* 3. mine-сообщения всегда idle: showName=false, showAvatar=false
|
||||
* (в референсе X-style никогда не рисуется имя/аватар над своим bubble).
|
||||
*
|
||||
* showName/showAvatar всё равно вычисляются — даже если потом render-слой
|
||||
* их проигнорирует (DM / channel — без sender-meta). Логика кнопки renders
|
||||
* сама решает показывать ли их, см. MessageBubble → withSenderMeta.
|
||||
*/
|
||||
import type { Message } from '@/lib/types';
|
||||
import { dateBucket } from '@/lib/dates';
|
||||
|
||||
export type Row =
|
||||
| { kind: 'sep'; id: string; label: string }
|
||||
| {
|
||||
kind: 'msg';
|
||||
msg: Message;
|
||||
showName: boolean;
|
||||
showAvatar: boolean;
|
||||
};
|
||||
|
||||
// Максимальная пауза внутри "лесенки" — после неё новый run.
|
||||
const RUN_GAP_SECONDS = 60 * 60; // 1 час
|
||||
|
||||
export function buildRows(msgs: Message[]): Row[] {
|
||||
const out: Row[] = [];
|
||||
let lastBucket = '';
|
||||
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
const m = msgs[i];
|
||||
const b = dateBucket(m.timestamp);
|
||||
|
||||
if (b !== lastBucket) {
|
||||
out.push({ kind: 'sep', id: `sep_${b}_${m.id}`, label: b });
|
||||
lastBucket = b;
|
||||
}
|
||||
|
||||
const prev = msgs[i - 1];
|
||||
const next = msgs[i + 1];
|
||||
|
||||
// "Прервать run" флаги:
|
||||
// - разный день
|
||||
// - разный отправитель
|
||||
// - своё vs чужое
|
||||
// - пауза > 1 часа
|
||||
const breakBefore =
|
||||
!prev ||
|
||||
dateBucket(prev.timestamp) !== b ||
|
||||
prev.from !== m.from ||
|
||||
prev.mine !== m.mine ||
|
||||
(m.timestamp - prev.timestamp) > RUN_GAP_SECONDS;
|
||||
|
||||
const breakAfter =
|
||||
!next ||
|
||||
dateBucket(next.timestamp) !== b ||
|
||||
next.from !== m.from ||
|
||||
next.mine !== m.mine ||
|
||||
(next.timestamp - m.timestamp) > RUN_GAP_SECONDS;
|
||||
|
||||
// Для mine — никогда не показываем имя/аватар.
|
||||
const showName = m.mine ? false : breakBefore;
|
||||
const showAvatar = m.mine ? false : breakAfter;
|
||||
|
||||
out.push({ kind: 'msg', msg: m, showName, showAvatar });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
475
client-app/components/feed/PostCard.tsx
Normal file
475
client-app/components/feed/PostCard.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* PostCard — Twitter-style feed row.
|
||||
*
|
||||
* Layout (top-to-bottom, left-to-right):
|
||||
*
|
||||
* [avatar 44] [@author · time · ⋯ menu]
|
||||
* [post text body with #tags + @mentions highlighted]
|
||||
* [optional attachment preview]
|
||||
* [💬 0 🔁 link ❤️ likes 👁 views]
|
||||
*
|
||||
* Interaction model:
|
||||
* - Tap anywhere except controls → navigate to post detail
|
||||
* - Tap author/avatar → profile
|
||||
* - Double-tap the post body → like (with a short heart-bounce animation)
|
||||
* - Long-press → context menu (copy, share link, delete-if-mine)
|
||||
*
|
||||
* Performance notes:
|
||||
* - Memoised. Feed lists re-render often (after every like, view bump,
|
||||
* new post), but each card only needs to update when ITS own stats
|
||||
* change. We use shallow prop comparison + stable key on post_id.
|
||||
* - Stats are passed in by parent (fetched once per refresh), not
|
||||
* fetched here — avoids N /stats requests per timeline render.
|
||||
*/
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View, Text, Pressable, Alert, Animated, Image,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getNodeUrl } from '@/lib/api';
|
||||
import type { FeedPostItem } from '@/lib/feed';
|
||||
import {
|
||||
formatRelativeTime, formatCount, likePost, unlikePost, deletePost,
|
||||
} from '@/lib/feed';
|
||||
import { ShareSheet } from '@/components/feed/ShareSheet';
|
||||
|
||||
export interface PostCardProps {
|
||||
post: FeedPostItem;
|
||||
/** true = current user has liked this post (used for filled heart). */
|
||||
likedByMe?: boolean;
|
||||
/** Called after a successful like/unlike so parent can refresh stats. */
|
||||
onStatsChanged?: (postID: string) => void;
|
||||
/** Called after delete so parent can drop the card from the list. */
|
||||
onDeleted?: (postID: string) => void;
|
||||
/** Compact (no attachment, less padding) — used in nested thread context. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }: PostCardProps) {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const contacts = useStore(s => s.contacts);
|
||||
|
||||
// Optimistic local state — immediate response to tap, reconciled after tx.
|
||||
const [localLiked, setLocalLiked] = useState<boolean>(!!likedByMe);
|
||||
const [localLikeCount, setLocalLikeCount] = useState<number>(post.likes);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalLiked(!!likedByMe);
|
||||
setLocalLikeCount(post.likes);
|
||||
}, [likedByMe, post.likes]);
|
||||
|
||||
// Heart bounce animation when liked (Twitter-style).
|
||||
const heartScale = useMemo(() => new Animated.Value(1), []);
|
||||
const animateHeart = useCallback(() => {
|
||||
heartScale.setValue(0.6);
|
||||
Animated.spring(heartScale, {
|
||||
toValue: 1,
|
||||
friction: 3,
|
||||
tension: 120,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [heartScale]);
|
||||
|
||||
const mine = !!keyFile && keyFile.pub_key === post.author;
|
||||
|
||||
// Find a display-friendly name for the author. If it's a known contact
|
||||
// with @username, use that; otherwise short-addr.
|
||||
const displayName = useMemo(() => {
|
||||
const c = contacts.find(x => x.address === post.author);
|
||||
if (c?.username) return `@${c.username}`;
|
||||
if (c?.alias) return c.alias;
|
||||
if (mine) return 'You';
|
||||
return shortAddr(post.author);
|
||||
}, [contacts, post.author, mine]);
|
||||
|
||||
const onToggleLike = useCallback(async () => {
|
||||
if (!keyFile || busy) return;
|
||||
setBusy(true);
|
||||
const wasLiked = localLiked;
|
||||
// Optimistic update.
|
||||
setLocalLiked(!wasLiked);
|
||||
setLocalLikeCount(c => c + (wasLiked ? -1 : 1));
|
||||
animateHeart();
|
||||
try {
|
||||
if (wasLiked) {
|
||||
await unlikePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||||
} else {
|
||||
await likePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||||
}
|
||||
// Refresh stats from server so counts reconcile (on-chain is delayed
|
||||
// by block time; server returns current cached count).
|
||||
setTimeout(() => onStatsChanged?.(post.post_id), 1500);
|
||||
} catch (e: any) {
|
||||
// Roll back optimistic update.
|
||||
setLocalLiked(wasLiked);
|
||||
setLocalLikeCount(c => c + (wasLiked ? 1 : -1));
|
||||
Alert.alert('Не удалось', String(e?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [keyFile, busy, localLiked, post.post_id, animateHeart, onStatsChanged]);
|
||||
|
||||
const onOpenDetail = useCallback(() => {
|
||||
router.push(`/(app)/feed/${post.post_id}` as never);
|
||||
}, [post.post_id]);
|
||||
|
||||
const onOpenAuthor = useCallback(() => {
|
||||
router.push(`/(app)/profile/${post.author}` as never);
|
||||
}, [post.author]);
|
||||
|
||||
const onLongPress = useCallback(() => {
|
||||
if (!keyFile) return;
|
||||
const options: Array<{ label: string; destructive?: boolean; onPress: () => void }> = [];
|
||||
if (mine) {
|
||||
options.push({
|
||||
label: 'Удалить пост',
|
||||
destructive: true,
|
||||
onPress: () => {
|
||||
Alert.alert('Удалить пост?', 'Это действие нельзя отменить.', [
|
||||
{ text: 'Отмена', style: 'cancel' },
|
||||
{
|
||||
text: 'Удалить',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deletePost({
|
||||
from: keyFile.pub_key,
|
||||
privKey: keyFile.priv_key,
|
||||
postID: post.post_id,
|
||||
});
|
||||
onDeleted?.(post.post_id);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Ошибка', String(e?.message ?? e));
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (options.length === 0) return;
|
||||
const buttons: Array<{ text: string; style?: 'default' | 'cancel' | 'destructive'; onPress?: () => void }> = [
|
||||
...options.map(o => ({
|
||||
text: o.label,
|
||||
style: (o.destructive ? 'destructive' : 'default') as 'default' | 'destructive',
|
||||
onPress: o.onPress,
|
||||
})),
|
||||
{ text: 'Отмена', style: 'cancel' as const },
|
||||
];
|
||||
Alert.alert('Действия', '', buttons);
|
||||
}, [keyFile, mine, post.post_id, onDeleted]);
|
||||
|
||||
// Attachment preview URL — native Image can stream straight from the
|
||||
// hosting relay's /feed/post/{id}/attachment endpoint. `getNodeUrl()`
|
||||
// returns the node the client is connected to; for cross-relay posts
|
||||
// that's actually the hosting relay once /api/relays resolution lands
|
||||
// (Phase D). For now we assume same-node.
|
||||
const attachmentURL = post.has_attachment
|
||||
? `${getNodeUrl()}/feed/post/${post.post_id}/attachment`
|
||||
: null;
|
||||
|
||||
// Body content truncation:
|
||||
// - In the timeline (compact=undefined/false) cap at 5 lines. If the
|
||||
// text is longer the rest is hidden behind "…" — tapping the card
|
||||
// opens the detail view where the full body is shown.
|
||||
// - In post detail (compact=true) show everything.
|
||||
const bodyLines = compact ? undefined : 5;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Outer container is a plain View so layout styles (padding, row
|
||||
direction) are static and always applied. Pressable's dynamic
|
||||
style-function has been observed to drop properties between
|
||||
renders on some RN versions — we hit that with the FAB, so
|
||||
we're not relying on it here either. Tap handling lives on the
|
||||
content-column Pressable (covers ~90% of the card area) plus a
|
||||
separate Pressable around the avatar. */}
|
||||
{/* Card = vertical stack: [HEADER row with avatar+name+time] /
|
||||
[FULL-WIDTH content column with body/image/actions]. Putting
|
||||
content under the header (rather than in a column next to the
|
||||
avatar) means body text and attachments occupy the full card
|
||||
width — no risk of the text running next to the avatar and
|
||||
clipping off the right edge. */}
|
||||
<View
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingVertical: compact ? 10 : 12,
|
||||
}}
|
||||
>
|
||||
{/* ── HEADER ROW: [avatar] [name · time] [menu] ──────────────── */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Pressable onPress={onOpenAuthor} hitSlop={4} style={{ width: 44 }}>
|
||||
<Avatar name={displayName} address={post.author} size={44} />
|
||||
</Pressable>
|
||||
|
||||
{/* Name + time take all remaining horizontal space in the
|
||||
header, with the name truncating (numberOfLines:1 +
|
||||
flexShrink:1) and the "· <time>" tail pinned to stay on
|
||||
the same line (flexShrink:0). */}
|
||||
<Pressable
|
||||
onPress={onOpenAuthor}
|
||||
hitSlop={{ top: 4, bottom: 2 }}
|
||||
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', marginLeft: 10, minWidth: 0 }}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
letterSpacing: -0.2,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
color: '#6a6a6a',
|
||||
fontSize: 13,
|
||||
marginLeft: 6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
· {formatRelativeTime(post.created_at)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{mine && (
|
||||
<Pressable onPress={onLongPress} hitSlop={8} style={{ marginLeft: 8 }}>
|
||||
<Ionicons name="ellipsis-horizontal" size={16} color="#6a6a6a" />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ── CONTENT (body, attachment, actions) — full card width ──── */}
|
||||
<Pressable
|
||||
onPress={onOpenDetail}
|
||||
onLongPress={onLongPress}
|
||||
style={({ pressed }) => ({
|
||||
marginTop: 14,
|
||||
overflow: 'hidden',
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
|
||||
{/* Body text with hashtag highlighting. Full card width now
|
||||
(we moved it out of the avatar-sibling column) — no special
|
||||
width tricks needed, normal wrapping just works. */}
|
||||
{post.content.length > 0 && (
|
||||
<Text
|
||||
numberOfLines={bodyLines}
|
||||
ellipsizeMode="tail"
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
}}
|
||||
>
|
||||
{renderInline(post.content)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Attachment preview.
|
||||
Timeline (compact=false): aspect-ratio capped at 4:5 so a
|
||||
portrait photo doesn't occupy the whole screen — extra height
|
||||
is cropped via resizeMode="cover", full image shows in detail.
|
||||
Detail (compact=true): contain with no aspect cap → original
|
||||
proportions preserved. */}
|
||||
{attachmentURL && (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 10,
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: attachmentURL }}
|
||||
style={compact ? {
|
||||
width: '100%',
|
||||
aspectRatio: 1, // will be overridden by onLoad if known
|
||||
maxHeight: undefined,
|
||||
} : {
|
||||
width: '100%',
|
||||
aspectRatio: 4 / 5, // portrait-friendly but bounded
|
||||
}}
|
||||
resizeMode={compact ? 'contain' : 'cover'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Action row — 3 buttons (Twitter-style). Comments button
|
||||
intentionally omitted: v2.0.0 doesn't implement replies and
|
||||
a dead button with "0" adds noise. Heart + views + share
|
||||
distribute across the row; share pins to the right edge. */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 12,
|
||||
paddingHorizontal: 12,
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1, alignItems: 'flex-start' }}>
|
||||
<Pressable
|
||||
onPress={onToggleLike}
|
||||
disabled={busy}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
|
||||
<Ionicons
|
||||
name={localLiked ? 'heart' : 'heart-outline'}
|
||||
size={16}
|
||||
color={localLiked ? '#e0245e' : '#6a6a6a'}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Text
|
||||
style={{
|
||||
color: localLiked ? '#e0245e' : '#6a6a6a',
|
||||
fontSize: 12,
|
||||
fontWeight: localLiked ? '600' : '400',
|
||||
lineHeight: 16,
|
||||
includeFontPadding: false,
|
||||
textAlignVertical: 'center',
|
||||
}}
|
||||
>
|
||||
{formatCount(localLikeCount)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={{ flex: 1, alignItems: 'flex-start' }}>
|
||||
<ActionButton
|
||||
icon="eye-outline"
|
||||
label={formatCount(post.views)}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<ActionButton
|
||||
icon="share-outline"
|
||||
onPress={() => setShareOpen(true)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ShareSheet
|
||||
visible={shareOpen}
|
||||
post={post}
|
||||
onClose={() => setShareOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const PostCard = React.memo(PostCardInner);
|
||||
|
||||
/**
|
||||
* PostSeparator — visible divider line between post cards. Exported so
|
||||
* every feed surface (timeline, author, hashtag, post detail) can pass
|
||||
* it as ItemSeparatorComponent and get identical spacing / colour.
|
||||
*
|
||||
* Layout: 12px blank space → 1px grey line → 12px blank space. The
|
||||
* blank space on each side makes the line "float" between posts rather
|
||||
* than hugging the edge of the card — gives clear visual separation
|
||||
* without needing big card padding everywhere.
|
||||
*
|
||||
* Colour #2a2a2a is the minimum grey that reads on OLED black under
|
||||
* mobile-bright-mode gamma; darker and the seam vanishes.
|
||||
*/
|
||||
export function PostSeparator() {
|
||||
return (
|
||||
<View style={{ paddingVertical: 12 }}>
|
||||
<View style={{ height: 1, backgroundColor: '#2a2a2a' }} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inline helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** ActionButton — small icon + optional label. */
|
||||
function ActionButton({ icon, label, onPress }: {
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
label?: string;
|
||||
onPress?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
disabled={!onPress}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name={icon} size={16} color="#6a6a6a" />
|
||||
{label && (
|
||||
<Text
|
||||
style={{
|
||||
color: '#6a6a6a',
|
||||
fontSize: 12,
|
||||
// Match icon height (16) as lineHeight so baseline-anchored
|
||||
// text aligns with the icon's vertical centre. Without this,
|
||||
// RN renders Text one pixel lower than the icon mid-point on
|
||||
// most Android fonts, which looks sloppy next to the heart /
|
||||
// eye glyphs.
|
||||
lineHeight: 16,
|
||||
includeFontPadding: false,
|
||||
textAlignVertical: 'center',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render post body with hashtag highlighting. Splits by the hashtag regex,
|
||||
* wraps matches in blue-coloured Text spans that are tappable → hashtag
|
||||
* feed. For future: @mentions highlighting + URL auto-linking.
|
||||
*/
|
||||
function renderInline(text: string): React.ReactNode {
|
||||
const parts = text.split(/(#[A-Za-z0-9_\u0400-\u04FF]{1,40})/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('#')) {
|
||||
const tag = part.slice(1);
|
||||
return (
|
||||
<Text
|
||||
key={i}
|
||||
style={{ color: '#1d9bf0' }}
|
||||
onPress={() => router.push(`/(app)/feed/tag/${encodeURIComponent(tag)}` as never)}
|
||||
>
|
||||
{part}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text key={i}>{part}</Text>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function shortAddr(a: string, n = 6): string {
|
||||
if (!a) return '—';
|
||||
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||
}
|
||||
|
||||
307
client-app/components/feed/ShareSheet.tsx
Normal file
307
client-app/components/feed/ShareSheet.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* ShareSheet — bottom-sheet picker that forwards a feed post into one
|
||||
* (or several) chats. Opens when the user taps the share icon on a
|
||||
* PostCard.
|
||||
*
|
||||
* Design notes
|
||||
* ------------
|
||||
* - Single modal component, managed by the parent via `visible` +
|
||||
* `onClose`. Parent passes the `post` it wants to share.
|
||||
* - Multi-select: the user can tick several contacts at once and hit
|
||||
* "Отправить". Fits the common "share with a couple of friends"
|
||||
* flow better than one-at-a-time.
|
||||
* - Only contacts with an x25519 key show up — those are the ones we
|
||||
* can actually encrypt for. An info note explains absent contacts.
|
||||
* - Search: typing filters the list by username / alias / address
|
||||
* prefix. Useful once the user has more than a screenful of
|
||||
* contacts.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
View, Text, Pressable, Modal, FlatList, TextInput, ActivityIndicator, Alert,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { useStore } from '@/lib/store';
|
||||
import type { Contact } from '@/lib/types';
|
||||
import type { FeedPostItem } from '@/lib/feed';
|
||||
import { forwardPostToContacts } from '@/lib/forwardPost';
|
||||
|
||||
export interface ShareSheetProps {
|
||||
visible: boolean;
|
||||
post: FeedPostItem | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const available = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const withKeys = contacts.filter(c => !!c.x25519Pub);
|
||||
if (!q) return withKeys;
|
||||
return withKeys.filter(c =>
|
||||
(c.username ?? '').toLowerCase().includes(q) ||
|
||||
(c.alias ?? '').toLowerCase().includes(q) ||
|
||||
c.address.toLowerCase().startsWith(q),
|
||||
);
|
||||
}, [contacts, query]);
|
||||
|
||||
const toggle = (address: string) => {
|
||||
setPicked(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(address)) next.delete(address);
|
||||
else next.add(address);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const doSend = async () => {
|
||||
if (!post || !keyFile) return;
|
||||
const targets = contacts.filter(c => picked.has(c.address));
|
||||
if (targets.length === 0) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const { ok, failed } = await forwardPostToContacts({
|
||||
post, contacts: targets, keyFile,
|
||||
});
|
||||
if (failed > 0) {
|
||||
Alert.alert('Готово', `Отправлено в ${ok} из ${ok + failed} чат${plural(ok + failed)}.`);
|
||||
}
|
||||
// Close + reset regardless — done is done.
|
||||
setPicked(new Set());
|
||||
setQuery('');
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Не удалось', String(e?.message ?? e));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeAndReset = () => {
|
||||
setPicked(new Set());
|
||||
setQuery('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={closeAndReset}
|
||||
>
|
||||
{/* Dim backdrop — tap to dismiss */}
|
||||
<Pressable
|
||||
onPress={closeAndReset}
|
||||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.72)', justifyContent: 'flex-end' }}
|
||||
>
|
||||
{/* Sheet body — stopPropagation so inner taps don't dismiss */}
|
||||
<Pressable
|
||||
onPress={(e) => e.stopPropagation?.()}
|
||||
style={{
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderTopLeftRadius: 22,
|
||||
borderTopRightRadius: 22,
|
||||
paddingTop: 10,
|
||||
paddingBottom: Math.max(insets.bottom, 10) + 10,
|
||||
maxHeight: '78%',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<View
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
width: 44, height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#2a2a2a',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Title row */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
paddingHorizontal: 16, marginBottom: 10,
|
||||
}}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}>
|
||||
Поделиться постом
|
||||
</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Pressable onPress={closeAndReset} hitSlop={8}>
|
||||
<Ionicons name="close" size={22} color="#8b8b8b" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Search */}
|
||||
<View style={{ paddingHorizontal: 16, marginBottom: 10 }}>
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
backgroundColor: '#111111',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 10,
|
||||
gap: 6,
|
||||
}}>
|
||||
<Ionicons name="search" size={14} color="#6a6a6a" />
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Поиск по контактам"
|
||||
placeholderTextColor="#5a5a5a"
|
||||
style={{
|
||||
flex: 1,
|
||||
color: '#ffffff',
|
||||
fontSize: 14,
|
||||
paddingVertical: 10,
|
||||
}}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<Pressable onPress={() => setQuery('')} hitSlop={6}>
|
||||
<Ionicons name="close-circle" size={16} color="#6a6a6a" />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Contact list */}
|
||||
<FlatList
|
||||
data={available}
|
||||
keyExtractor={c => c.address}
|
||||
renderItem={({ item }) => (
|
||||
<ContactRow
|
||||
contact={item}
|
||||
checked={picked.has(item.address)}
|
||||
onToggle={() => toggle(item.address)}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={{
|
||||
paddingVertical: 40,
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<Ionicons name="people-outline" size={28} color="#5a5a5a" />
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 10 }}>
|
||||
{query.length > 0
|
||||
? 'Нет контактов по такому запросу'
|
||||
: 'Контакты с ключами шифрования отсутствуют'}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Send button */}
|
||||
<View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
|
||||
<Pressable
|
||||
onPress={doSend}
|
||||
disabled={picked.size === 0 || sending}
|
||||
style={({ pressed }) => ({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 13,
|
||||
borderRadius: 999,
|
||||
backgroundColor:
|
||||
picked.size === 0 ? '#1f1f1f'
|
||||
: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
})}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Text style={{
|
||||
color: picked.size === 0 ? '#5a5a5a' : '#ffffff',
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
{picked.size === 0
|
||||
? 'Выберите контакты'
|
||||
: `Отправить (${picked.size})`}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ContactRow({ contact, checked, onToggle }: {
|
||||
contact: Contact;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const name = contact.username
|
||||
? `@${contact.username}`
|
||||
: contact.alias ?? shortAddr(contact.address);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onToggle}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: pressed ? '#111111' : 'transparent',
|
||||
})}
|
||||
>
|
||||
<Avatar name={name} address={contact.address} size={38} />
|
||||
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}
|
||||
>
|
||||
{shortAddr(contact.address, 8)}
|
||||
</Text>
|
||||
</View>
|
||||
{/* Checkbox indicator */}
|
||||
<View
|
||||
style={{
|
||||
width: 22, height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 1.5,
|
||||
borderColor: checked ? '#1d9bf0' : '#2a2a2a',
|
||||
backgroundColor: checked ? '#1d9bf0' : 'transparent',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{checked && <Ionicons name="checkmark" size={14} color="#ffffff" />}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function shortAddr(a: string, n = 6): string {
|
||||
if (!a) return '—';
|
||||
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||
}
|
||||
|
||||
function plural(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
const mod10 = n % 10;
|
||||
if (mod100 >= 11 && mod100 <= 19) return 'ов';
|
||||
if (mod10 === 1) return '';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'а';
|
||||
return 'ов';
|
||||
}
|
||||
22
client-app/eas.json
Normal file
22
client-app/eas.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 18.7.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"android": { "buildType": "apk" }
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
3
client-app/global.css
Normal file
3
client-app/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
94
client-app/hooks/useBalance.ts
Normal file
94
client-app/hooks/useBalance.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Balance hook — uses the WebSocket gateway to receive instant updates when
|
||||
* a tx involving the current address is committed, with HTTP polling as a
|
||||
* graceful fallback for old nodes that don't expose /api/ws.
|
||||
*
|
||||
* Flow:
|
||||
* 1. On mount: immediate HTTP fetch so the UI has a non-zero balance ASAP
|
||||
* 2. Subscribe to `addr:<my_pubkey>` on the WS hub
|
||||
* 3. On every `tx` event, re-fetch balance (cheap — one Badger read server-side)
|
||||
* 4. If WS disconnects for >15s, fall back to 10-second polling until it reconnects
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { getBalance } from '@/lib/api';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
const FALLBACK_POLL_INTERVAL = 10_000; // HTTP poll when WS is down
|
||||
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
|
||||
|
||||
export function useBalance() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const setBalance = useStore(s => s.setBalance);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!keyFile) return;
|
||||
try {
|
||||
const bal = await getBalance(keyFile.pub_key);
|
||||
setBalance(bal);
|
||||
} catch {
|
||||
// transient — next call will retry
|
||||
}
|
||||
}, [keyFile, setBalance]);
|
||||
|
||||
// --- fallback polling management ---
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const disconnectSinceRef = useRef<number | null>(null);
|
||||
const disconnectTORef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) return;
|
||||
console.log('[useBalance] WS down for grace period — starting HTTP poll');
|
||||
refresh();
|
||||
pollTimerRef.current = setInterval(refresh, FALLBACK_POLL_INTERVAL);
|
||||
}, [refresh]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
if (disconnectTORef.current) {
|
||||
clearTimeout(disconnectTORef.current);
|
||||
disconnectTORef.current = null;
|
||||
}
|
||||
disconnectSinceRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
const ws = getWSClient();
|
||||
|
||||
// Immediate HTTP fetch so the UI is not empty while the WS hello arrives.
|
||||
refresh();
|
||||
|
||||
// Refresh balance whenever a tx for our address is committed.
|
||||
const offTx = ws.subscribe('addr:' + keyFile.pub_key, (frame) => {
|
||||
if (frame.event === 'tx') {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Manage fallback polling based on WS connection state.
|
||||
const offConn = ws.onConnectionChange((ok) => {
|
||||
if (ok) {
|
||||
stopPolling();
|
||||
refresh(); // catch up anything we missed while disconnected
|
||||
} else if (disconnectTORef.current === null) {
|
||||
disconnectSinceRef.current = Date.now();
|
||||
disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING);
|
||||
}
|
||||
});
|
||||
|
||||
ws.connect();
|
||||
|
||||
return () => {
|
||||
offTx();
|
||||
offConn();
|
||||
stopPolling();
|
||||
};
|
||||
}, [keyFile, refresh, startPolling, stopPolling]);
|
||||
|
||||
return { refresh };
|
||||
}
|
||||
52
client-app/hooks/useConnectionStatus.ts
Normal file
52
client-app/hooks/useConnectionStatus.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* useConnectionStatus — объединённое состояние подключения клиента к сети.
|
||||
*
|
||||
* Определяет один из трёх стейтов:
|
||||
* - 'offline' — нет интернета по данным NetInfo
|
||||
* - 'connecting' — интернет есть, но WebSocket к ноде не подключён
|
||||
* - 'online' — WebSocket к ноде активен
|
||||
*
|
||||
* Используется в headers (например Messages → "Connecting...",
|
||||
* "Waiting for internet") и на profile-avatar'ах как индикатор
|
||||
* живости.
|
||||
*
|
||||
* NetInfo использует connected + internetReachable для детекта
|
||||
* настоящего Internet (не просто Wi-Fi SSID без доступа); fallback
|
||||
* на `connected`-only когда internetReachable неопределён (некоторые
|
||||
* корпоративные сети или Android в первые секунды).
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
|
||||
export type ConnectionStatus = 'online' | 'connecting' | 'offline';
|
||||
|
||||
export function useConnectionStatus(): ConnectionStatus {
|
||||
const [wsLive, setWsLive] = useState(false);
|
||||
const [hasNet, setHasNet] = useState(true);
|
||||
|
||||
// WS live-state: subscribe к его onConnectionChange.
|
||||
useEffect(() => {
|
||||
const ws = getWSClient();
|
||||
setWsLive(ws.isConnected());
|
||||
return ws.onConnectionChange(setWsLive);
|
||||
}, []);
|
||||
|
||||
// Internet reachability: через NetInfo.
|
||||
useEffect(() => {
|
||||
const unsub = NetInfo.addEventListener((state) => {
|
||||
// internetReachable = null значит "ещё не проверили" — считаем
|
||||
// что есть, чтобы не ложно отображать "offline" на старте.
|
||||
const reachable =
|
||||
state.isInternetReachable === false ? false :
|
||||
state.isConnected === false ? false :
|
||||
true;
|
||||
setHasNet(reachable);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
if (!hasNet) return 'offline';
|
||||
if (wsLive) return 'online';
|
||||
return 'connecting';
|
||||
}
|
||||
80
client-app/hooks/useContacts.ts
Normal file
80
client-app/hooks/useContacts.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Contacts + inbound request tracking.
|
||||
*
|
||||
* - Loads cached contacts from local storage on boot.
|
||||
* - Subscribes to the address WS topic so a new CONTACT_REQUEST pulls the
|
||||
* relay contact list immediately (sub-second UX).
|
||||
* - Keeps a 30 s polling fallback for nodes without WS or while disconnected.
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { fetchContactRequests } from '@/lib/api';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { loadContacts } from '@/lib/storage';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
const FALLBACK_POLL_INTERVAL = 30_000;
|
||||
|
||||
export function useContacts() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const setContacts = useStore(s => s.setContacts);
|
||||
const setRequests = useStore(s => s.setRequests);
|
||||
const contacts = useStore(s => s.contacts);
|
||||
|
||||
// Load cached contacts from local storage once
|
||||
useEffect(() => {
|
||||
loadContacts().then(setContacts);
|
||||
}, [setContacts]);
|
||||
|
||||
const pollRequests = useCallback(async () => {
|
||||
if (!keyFile) return;
|
||||
try {
|
||||
const raw = await fetchContactRequests(keyFile.pub_key);
|
||||
|
||||
// Filter out already-accepted contacts
|
||||
const contactAddresses = new Set(contacts.map(c => c.address));
|
||||
|
||||
const requests = raw
|
||||
.filter(r => r.status === 'pending' && !contactAddresses.has(r.requester_pub))
|
||||
.map(r => ({
|
||||
from: r.requester_pub,
|
||||
// x25519Pub will be fetched from identity when user taps Accept
|
||||
x25519Pub: '',
|
||||
intro: r.intro ?? '',
|
||||
timestamp: r.created_at,
|
||||
txHash: r.tx_id,
|
||||
}));
|
||||
|
||||
setRequests(requests);
|
||||
} catch {
|
||||
// Ignore transient network errors
|
||||
}
|
||||
}, [keyFile, contacts, setRequests]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
const ws = getWSClient();
|
||||
|
||||
// Initial load + low-frequency fallback poll (covers missed WS events,
|
||||
// works even when the node has no WS endpoint).
|
||||
pollRequests();
|
||||
const interval = setInterval(pollRequests, FALLBACK_POLL_INTERVAL);
|
||||
|
||||
// Immediate refresh when a CONTACT_REQUEST / ACCEPT_CONTACT tx addressed
|
||||
// to us lands on-chain. WS fan-out already filters to our address topic.
|
||||
const off = ws.subscribe('addr:' + keyFile.pub_key, (frame) => {
|
||||
if (frame.event === 'tx') {
|
||||
const d = frame.data as { tx_type?: string } | undefined;
|
||||
if (d?.tx_type === 'CONTACT_REQUEST' || d?.tx_type === 'ACCEPT_CONTACT') {
|
||||
pollRequests();
|
||||
}
|
||||
}
|
||||
});
|
||||
ws.connect();
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
off();
|
||||
};
|
||||
}, [keyFile, pollRequests]);
|
||||
}
|
||||
124
client-app/hooks/useGlobalInbox.ts
Normal file
124
client-app/hooks/useGlobalInbox.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* useGlobalInbox — app-wide inbox listener.
|
||||
*
|
||||
* Подписан на WS-топик `inbox:<my_x25519>` при любом экране внутри
|
||||
* (app)-группы. Когда приходит push с envelope, мы:
|
||||
* 1. Декриптуем — если это наш контакт, добавляем в store.
|
||||
* 2. Инкрементим unreadByContact[address].
|
||||
* 3. Показываем local notification (от кого + счётчик).
|
||||
*
|
||||
* НЕ дублирует chat-detail'овский `useMessages` — тот делает initial
|
||||
* HTTP-pull при открытии чата и слушает тот же топик (двойная подписка
|
||||
* с фильтром по sender_pub). Оба держат в store консистентное состояние
|
||||
* через `appendMessage` (который идемпотентен по id).
|
||||
*
|
||||
* Фильтрация "app backgrounded" не нужна: Expo notifications'handler
|
||||
* показывает banner и в foreground, но при активном чате с этим
|
||||
* контактом нотификация dismiss'ится автоматически через
|
||||
* clearContactNotifications (вызывается при mount'е chats/[id]).
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import { usePathname } from 'expo-router';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { decryptMessage } from '@/lib/crypto';
|
||||
import { tryParsePostRef } from '@/lib/forwardPost';
|
||||
import { fetchInbox } from '@/lib/api';
|
||||
import { appendMessage } from '@/lib/storage';
|
||||
import { randomId } from '@/lib/utils';
|
||||
|
||||
import { notifyIncoming } from './useNotifications';
|
||||
|
||||
export function useGlobalInbox() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const appendMsg = useStore(s => s.appendMessage);
|
||||
const incrementUnread = useStore(s => s.incrementUnread);
|
||||
const pathname = usePathname();
|
||||
const contactsRef = useRef(contacts);
|
||||
const pathnameRef = useRef(pathname);
|
||||
|
||||
useEffect(() => { contactsRef.current = contacts; }, [contacts]);
|
||||
useEffect(() => { pathnameRef.current = pathname; }, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyFile?.x25519_pub) return;
|
||||
|
||||
const ws = getWSClient();
|
||||
|
||||
const handleEnvelopePull = async () => {
|
||||
try {
|
||||
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||||
for (const env of envelopes) {
|
||||
// Найти контакт по sender_pub — если не знакомый, игнорим
|
||||
// (для MVP; в future можно показывать "unknown sender").
|
||||
const c = contactsRef.current.find(
|
||||
x => x.x25519Pub === env.sender_pub,
|
||||
);
|
||||
if (!c) continue;
|
||||
|
||||
let text = '';
|
||||
try {
|
||||
text = decryptMessage(
|
||||
env.ciphertext,
|
||||
env.nonce,
|
||||
env.sender_pub,
|
||||
keyFile.x25519_priv,
|
||||
) ?? '';
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!text) continue;
|
||||
|
||||
// Стабильный id от сервера (sha256(nonce||ct)[:16]); fallback
|
||||
// на nonce-префикс если вдруг env.id пустой.
|
||||
const msgId = env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`;
|
||||
const postRef = tryParsePostRef(text);
|
||||
const msg = {
|
||||
id: msgId,
|
||||
from: env.sender_pub,
|
||||
text: postRef ? '' : text,
|
||||
timestamp: env.timestamp,
|
||||
mine: false,
|
||||
...(postRef && {
|
||||
postRef: {
|
||||
postID: postRef.post_id,
|
||||
author: postRef.author,
|
||||
excerpt: postRef.excerpt,
|
||||
hasImage: postRef.has_image,
|
||||
},
|
||||
}),
|
||||
};
|
||||
appendMsg(c.address, msg);
|
||||
await appendMessage(c.address, msg);
|
||||
|
||||
// Если пользователь прямо сейчас в этом чате — unread не инкрементим,
|
||||
// notification не показываем.
|
||||
const inThisChat =
|
||||
pathnameRef.current === `/chats/${c.address}` ||
|
||||
pathnameRef.current.startsWith(`/chats/${c.address}/`);
|
||||
if (inThisChat && AppState.currentState === 'active') continue;
|
||||
|
||||
incrementUnread(c.address);
|
||||
const unread = useStore.getState().unreadByContact[c.address] ?? 1;
|
||||
notifyIncoming({
|
||||
contactAddress: c.address,
|
||||
senderName: c.username ? `@${c.username}` : (c.alias ?? 'New message'),
|
||||
unreadCount: unread,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* silent — ошибки pull'а обрабатывает useMessages */
|
||||
}
|
||||
};
|
||||
|
||||
const off = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
|
||||
if (frame.event !== 'inbox') return;
|
||||
handleEnvelopePull();
|
||||
});
|
||||
|
||||
return off;
|
||||
}, [keyFile, appendMsg, incrementUnread]);
|
||||
}
|
||||
159
client-app/hooks/useMessages.ts
Normal file
159
client-app/hooks/useMessages.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Subscribe to the relay inbox via WebSocket and decrypt incoming envelopes
|
||||
* for the active chat. Falls back to 30-second polling whenever the WS is
|
||||
* not connected — preserves correctness on older nodes or flaky networks.
|
||||
*
|
||||
* Flow:
|
||||
* 1. On mount: one HTTP fetch so we have whatever is already in the inbox
|
||||
* 2. Subscribe to topic `inbox:<my_x25519>` — the node pushes a summary
|
||||
* for each fresh envelope as soon as mailbox.Store() succeeds
|
||||
* 3. On each push, pull the full envelope list (cheap — bounded by
|
||||
* MailboxPerRecipientCap) and decrypt anything we haven't seen yet
|
||||
* 4. If WS disconnects for > 15 seconds, start a 30 s HTTP poll until it
|
||||
* reconnects
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { fetchInbox } from '@/lib/api';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { decryptMessage } from '@/lib/crypto';
|
||||
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { tryParsePostRef } from '@/lib/forwardPost';
|
||||
|
||||
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
|
||||
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
|
||||
|
||||
export function useMessages(contactX25519: string) {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const appendMsg = useStore(s => s.appendMessage);
|
||||
|
||||
// Подгружаем кэш сообщений из AsyncStorage при открытии чата.
|
||||
// Релей держит envelope'ы всего 7 дней, поэтому без чтения кэша
|
||||
// история старше недели пропадает при каждом рестарте приложения.
|
||||
// appendMsg в store идемпотентен по id, поэтому безопасно гонять его
|
||||
// для каждого кэшированного сообщения.
|
||||
useEffect(() => {
|
||||
if (!contactX25519) return;
|
||||
let cancelled = false;
|
||||
loadMessages(contactX25519).then(cached => {
|
||||
if (cancelled) return;
|
||||
for (const m of cached) appendMsg(contactX25519, m);
|
||||
}).catch(() => { /* cache miss / JSON error — not fatal */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [contactX25519, appendMsg]);
|
||||
|
||||
const pullAndDecrypt = useCallback(async () => {
|
||||
if (!keyFile || !contactX25519) return;
|
||||
try {
|
||||
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||||
for (const env of envelopes) {
|
||||
// Only process messages from this contact
|
||||
if (env.sender_pub !== contactX25519) continue;
|
||||
|
||||
const text = decryptMessage(
|
||||
env.ciphertext,
|
||||
env.nonce,
|
||||
env.sender_pub,
|
||||
keyFile.x25519_priv,
|
||||
);
|
||||
if (!text) continue;
|
||||
|
||||
// Detect forwarded feed posts — plaintext is a tiny JSON
|
||||
// envelope (see lib/forwardPost.ts). Regular text messages
|
||||
// stay as-is.
|
||||
const postRef = tryParsePostRef(text);
|
||||
|
||||
const msg = {
|
||||
id: env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`,
|
||||
from: env.sender_pub,
|
||||
text: postRef ? '' : text,
|
||||
timestamp: env.timestamp,
|
||||
mine: false,
|
||||
...(postRef && {
|
||||
postRef: {
|
||||
postID: postRef.post_id,
|
||||
author: postRef.author,
|
||||
excerpt: postRef.excerpt,
|
||||
hasImage: postRef.has_image,
|
||||
},
|
||||
}),
|
||||
};
|
||||
appendMsg(contactX25519, msg);
|
||||
await appendMessage(contactX25519, msg);
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Шумные ошибки (404 = нет mailbox'а, Network request failed =
|
||||
// нода недоступна) — ожидаемы в dev-среде и при offline-режиме,
|
||||
// не спамим console. Остальное — логируем.
|
||||
const msg = String(e?.message ?? e ?? '');
|
||||
if (/→\s*404\b/.test(msg)) return;
|
||||
if (/ 404\b/.test(msg)) return;
|
||||
if (/Network request failed/i.test(msg)) return;
|
||||
if (/Failed to fetch/i.test(msg)) return;
|
||||
console.warn('[useMessages] pull error:', e);
|
||||
}
|
||||
}, [keyFile, contactX25519, appendMsg]);
|
||||
|
||||
// ── Fallback polling state ────────────────────────────────────────────
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const disconnectTORef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) return;
|
||||
console.log('[useMessages] WS down — starting HTTP poll fallback');
|
||||
pullAndDecrypt();
|
||||
pollTimerRef.current = setInterval(pullAndDecrypt, FALLBACK_POLL_INTERVAL);
|
||||
}, [pullAndDecrypt]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
if (disconnectTORef.current) {
|
||||
clearTimeout(disconnectTORef.current);
|
||||
disconnectTORef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyFile || !contactX25519) return;
|
||||
|
||||
const ws = getWSClient();
|
||||
|
||||
// Initial fetch — populate whatever landed before we mounted.
|
||||
pullAndDecrypt();
|
||||
|
||||
// Subscribe to our x25519 inbox — the node emits on mailbox.Store.
|
||||
// Topic filter: only envelopes for ME; we then filter by sender inside
|
||||
// the handler so we only render messages in THIS chat.
|
||||
const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
|
||||
if (frame.event !== 'inbox') return;
|
||||
const d = frame.data as { sender_pub?: string } | undefined;
|
||||
// Optimisation: if the envelope is from a different peer, skip the
|
||||
// whole refetch — we'd just drop it in the sender filter below anyway.
|
||||
if (d?.sender_pub && d.sender_pub !== contactX25519) return;
|
||||
pullAndDecrypt();
|
||||
});
|
||||
|
||||
// Manage fallback polling based on WS connection state.
|
||||
const offConn = ws.onConnectionChange((ok) => {
|
||||
if (ok) {
|
||||
stopPolling();
|
||||
// Catch up anything we missed while disconnected.
|
||||
pullAndDecrypt();
|
||||
} else if (disconnectTORef.current === null) {
|
||||
disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING);
|
||||
}
|
||||
});
|
||||
|
||||
ws.connect();
|
||||
|
||||
return () => {
|
||||
offInbox();
|
||||
offConn();
|
||||
stopPolling();
|
||||
};
|
||||
}, [keyFile, contactX25519, pullAndDecrypt, startPolling, stopPolling]);
|
||||
}
|
||||
144
client-app/hooks/useNotifications.ts
Normal file
144
client-app/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* useNotifications — bootstrap expo-notifications (permission + handler)
|
||||
* и routing при tap'е на notification → открыть конкретный чат.
|
||||
*
|
||||
* ВАЖНО: expo-notifications в Expo Go (SDK 53+) валит error при САМОМ
|
||||
* import'е модуля ("Android Push notifications ... removed from Expo Go").
|
||||
* Поэтому мы НЕ делаем static `import * as Notifications from ...` —
|
||||
* вместо этого lazy `require()` внутри функций, только если мы вне Expo Go.
|
||||
* На dev-build / production APK всё работает штатно.
|
||||
*
|
||||
* Privacy: notification содержит ТОЛЬКО имя отправителя и счётчик
|
||||
* непрочитанных. Тело сообщения НЕ показывается — для E2E-мессенджера
|
||||
* это критично (push нотификации проходят через OS / APNs и могут
|
||||
* логироваться).
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import Constants, { ExecutionEnvironment } from 'expo-constants';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
// В Expo Go push-нотификации отключены с SDK 53. Любое обращение к
|
||||
// expo-notifications (включая import) пишет error в консоль. Детектим
|
||||
// среду один раз на module-load.
|
||||
const IS_EXPO_GO =
|
||||
Constants.executionEnvironment === ExecutionEnvironment.StoreClient;
|
||||
|
||||
/**
|
||||
* Lazy-load expo-notifications. Возвращает модуль или null в Expo Go.
|
||||
* Кешируем результат, чтобы не делать require повторно.
|
||||
*/
|
||||
let _cached: any | null | undefined = undefined;
|
||||
function getNotifications(): any | null {
|
||||
if (_cached !== undefined) return _cached;
|
||||
if (IS_EXPO_GO) { _cached = null; return null; }
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
_cached = require('expo-notifications');
|
||||
} catch {
|
||||
_cached = null;
|
||||
}
|
||||
return _cached;
|
||||
}
|
||||
|
||||
// Handler ставим лениво при первом обращении (а не на module-top'е),
|
||||
// т.к. require сам по себе подтянет модуль — в Expo Go его не дергаем.
|
||||
let _handlerInstalled = false;
|
||||
function installHandler() {
|
||||
if (_handlerInstalled) return;
|
||||
const N = getNotifications();
|
||||
if (!N) return;
|
||||
N.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: true,
|
||||
}),
|
||||
});
|
||||
_handlerInstalled = true;
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
useEffect(() => {
|
||||
const N = getNotifications();
|
||||
if (!N) return; // Expo Go — no-op
|
||||
|
||||
installHandler();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const existing = await N.getPermissionsAsync();
|
||||
if (existing.status !== 'granted' && existing.canAskAgain !== false) {
|
||||
await N.requestPermissionsAsync();
|
||||
}
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
// Channel — обязателен для Android 8+ чтобы уведомления показывались.
|
||||
await N.setNotificationChannelAsync('messages', {
|
||||
name: 'Messages',
|
||||
importance: N.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 200, 100, 200],
|
||||
lightColor: '#1d9bf0',
|
||||
sound: undefined,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// fail-safe — notifications не критичны
|
||||
}
|
||||
})();
|
||||
|
||||
// Tap-to-open listener.
|
||||
const sub = N.addNotificationResponseReceivedListener((resp: any) => {
|
||||
const data = resp?.notification?.request?.content?.data as
|
||||
{ contactAddress?: string } | undefined;
|
||||
if (data?.contactAddress) {
|
||||
router.push(`/(app)/chats/${data.contactAddress}` as never);
|
||||
}
|
||||
});
|
||||
|
||||
return () => { try { sub.remove(); } catch { /* ignore */ } };
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать локальное уведомление о новом сообщении. Вызывается из
|
||||
* global-inbox-listener'а когда приходит envelope от peer'а.
|
||||
*
|
||||
* content не содержит текста — только "New message" как generic label
|
||||
* (см. privacy-note в doc'е выше).
|
||||
*/
|
||||
export async function notifyIncoming(params: {
|
||||
contactAddress: string;
|
||||
senderName: string;
|
||||
unreadCount: number;
|
||||
}) {
|
||||
const N = getNotifications();
|
||||
if (!N) return; // Expo Go — no-op
|
||||
const { contactAddress, senderName, unreadCount } = params;
|
||||
try {
|
||||
await N.scheduleNotificationAsync({
|
||||
identifier: `inbox:${contactAddress}`, // replaces previous for same contact
|
||||
content: {
|
||||
title: senderName,
|
||||
body: unreadCount === 1
|
||||
? 'New message'
|
||||
: `${unreadCount} new messages`,
|
||||
data: { contactAddress },
|
||||
},
|
||||
trigger: null, // immediate
|
||||
});
|
||||
} catch {
|
||||
// Fail silently — если OS не дала permission, notification не
|
||||
// покажется. Не ломаем send-flow.
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss notification для контакта (вызывается когда чат открыт). */
|
||||
export async function clearContactNotifications(contactAddress: string) {
|
||||
const N = getNotifications();
|
||||
if (!N) return;
|
||||
try {
|
||||
await N.dismissNotificationAsync(`inbox:${contactAddress}`);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
61
client-app/hooks/useWellKnownContracts.ts
Normal file
61
client-app/hooks/useWellKnownContracts.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Auto-discover canonical system contracts from the node so the user doesn't
|
||||
* have to paste contract IDs into settings by hand.
|
||||
*
|
||||
* Flow:
|
||||
* 1. On app boot (and whenever nodeUrl changes), call GET /api/well-known-contracts
|
||||
* 2. If the node advertises a `username_registry` and the user has not
|
||||
* manually set `settings.contractId`, auto-populate it and persist.
|
||||
* 3. A user-supplied contractId is never overwritten — so power users can
|
||||
* still pin a non-canonical deployment from settings.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { fetchWellKnownContracts } from '@/lib/api';
|
||||
import { saveSettings } from '@/lib/storage';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
export function useWellKnownContracts() {
|
||||
const nodeUrl = useStore(s => s.settings.nodeUrl);
|
||||
const contractId = useStore(s => s.settings.contractId);
|
||||
const settings = useStore(s => s.settings);
|
||||
const setSettings = useStore(s => s.setSettings);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function run() {
|
||||
if (!nodeUrl) return;
|
||||
const res = await fetchWellKnownContracts();
|
||||
if (cancelled || !res) return;
|
||||
|
||||
const registry = res.contracts['username_registry'];
|
||||
if (!registry) return;
|
||||
|
||||
// Always keep the stored contractId in sync with what the node reports
|
||||
// as canonical. If the user resets their chain or we migrate from a
|
||||
// WASM contract to the native one, the stale contract_id cached in
|
||||
// local storage would otherwise keep the client trying to call a
|
||||
// contract that no longer exists on this chain.
|
||||
//
|
||||
// To still support intentional overrides: the UI's "advanced" section
|
||||
// allows pasting a specific ID — and since that also writes to
|
||||
// settings.contractId, the loop converges back to whatever the node
|
||||
// says after a short delay. Operators who want a hard override should
|
||||
// either run a patched node or pin the value with a wrapper config
|
||||
// outside the app.
|
||||
if (registry.contract_id !== contractId) {
|
||||
const next = { ...settings, contractId: registry.contract_id };
|
||||
setSettings({ contractId: registry.contract_id });
|
||||
await saveSettings(next);
|
||||
console.log('[well-known] synced username_registry =', registry.contract_id,
|
||||
'(was:', contractId || '<empty>', ')');
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
return () => { cancelled = true; };
|
||||
// Re-run when the node URL changes (user switched networks) or when
|
||||
// contractId is cleared.
|
||||
}, [nodeUrl, contractId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
781
client-app/lib/api.ts
Normal file
781
client-app/lib/api.ts
Normal file
@@ -0,0 +1,781 @@
|
||||
/**
|
||||
* DChain REST API client.
|
||||
* All requests go to the configured node URL (e.g. http://192.168.1.10:8081).
|
||||
*/
|
||||
|
||||
import type { Envelope, TxRecord, NetStats, Contact } from './types';
|
||||
import { base64ToBytes, bytesToBase64, bytesToHex, hexToBytes } from './crypto';
|
||||
|
||||
// ─── Base ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let _nodeUrl = 'http://localhost:8081';
|
||||
|
||||
/**
|
||||
* Listeners invoked AFTER _nodeUrl changes. The WS client registers here so
|
||||
* that switching nodes in Settings tears down the old socket and re-dials
|
||||
* the new one (without this, a user who pointed their app at node A would
|
||||
* keep receiving A's events forever after flipping to B).
|
||||
*/
|
||||
const nodeUrlListeners = new Set<(url: string) => void>();
|
||||
|
||||
export function setNodeUrl(url: string) {
|
||||
const normalised = url.replace(/\/$/, '');
|
||||
if (_nodeUrl === normalised) return;
|
||||
_nodeUrl = normalised;
|
||||
for (const fn of nodeUrlListeners) {
|
||||
try { fn(_nodeUrl); } catch { /* ignore — listeners are best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeUrl(): string {
|
||||
return _nodeUrl;
|
||||
}
|
||||
|
||||
/** Register a callback for node-URL changes. Returns an unsubscribe fn. */
|
||||
export function onNodeUrlChange(fn: (url: string) => void): () => void {
|
||||
nodeUrlListeners.add(fn);
|
||||
return () => { nodeUrlListeners.delete(fn); };
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${_nodeUrl}${path}`);
|
||||
if (!res.ok) throw new Error(`GET ${path} → ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced error reporter for POST failures. The node's `jsonErr` writes
|
||||
* `{"error": "..."}` as the response body; we parse that out so the UI layer
|
||||
* can show a meaningful message instead of a raw status code.
|
||||
*
|
||||
* Rate-limit and timestamp-skew rejections produce specific strings the UI
|
||||
* can translate to user-friendly Russian via matcher functions below.
|
||||
*/
|
||||
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${_nodeUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
// Try to extract {"error":"..."} payload for a cleaner message.
|
||||
let detail = text;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed?.error) detail = parsed.error;
|
||||
} catch { /* keep raw text */ }
|
||||
// Include HTTP status so `humanizeTxError` can branch on 429/400/etc.
|
||||
throw new Error(`${res.status}: ${detail}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a submission error from `post()` / `submitTx()` into a user-facing
|
||||
* Russian message with actionable hints. Preserves the raw detail at the end
|
||||
* so advanced users can still copy the original for support.
|
||||
*/
|
||||
export function humanizeTxError(e: unknown): string {
|
||||
const raw = e instanceof Error ? e.message : String(e);
|
||||
if (raw.startsWith('429')) {
|
||||
return 'Слишком много запросов к ноде. Подождите пару секунд и попробуйте снова.';
|
||||
}
|
||||
if (raw.startsWith('400') && raw.includes('timestamp')) {
|
||||
return 'Часы устройства не синхронизированы с нодой. Проверьте время на телефоне (±1 час).';
|
||||
}
|
||||
if (raw.startsWith('400') && raw.includes('signature')) {
|
||||
return 'Подпись транзакции невалидна. Попробуйте ещё раз; если не помогает — вероятна несовместимость версий клиента и ноды.';
|
||||
}
|
||||
if (raw.startsWith('400')) {
|
||||
return `Нода отклонила транзакцию: ${raw.replace(/^400:\s*/, '')}`;
|
||||
}
|
||||
if (raw.startsWith('5')) {
|
||||
return `Ошибка ноды (${raw}). Попробуйте позже.`;
|
||||
}
|
||||
// Network-level
|
||||
if (raw.toLowerCase().includes('network request failed')) {
|
||||
return 'Нет связи с нодой. Проверьте URL в настройках и доступность сервера.';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ─── Chain API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getNetStats(): Promise<NetStats> {
|
||||
return get<NetStats>('/api/netstats');
|
||||
}
|
||||
|
||||
interface AddrResponse {
|
||||
balance_ut: number;
|
||||
balance: string;
|
||||
transactions: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
from: string;
|
||||
to?: string;
|
||||
amount_ut: number;
|
||||
fee_ut: number;
|
||||
time: string; // ISO-8601 e.g. "2025-01-01T12:00:00Z"
|
||||
block_index: number;
|
||||
}>;
|
||||
tx_count: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export async function getBalance(pubkey: string): Promise<number> {
|
||||
const data = await get<AddrResponse>(`/api/address/${pubkey}`);
|
||||
return data.balance_ut ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction JSON.
|
||||
* Key facts:
|
||||
* - `payload` is base64-encoded JSON bytes (Go []byte → base64 in JSON)
|
||||
* - `signature` is base64-encoded Ed25519 sig (Go []byte → base64 in JSON)
|
||||
* - `timestamp` is RFC3339 string (Go time.Time → string in JSON)
|
||||
* - There is NO nonce field; dedup is by `id`
|
||||
*/
|
||||
export interface RawTx {
|
||||
id: string; // "tx-<nanoseconds>" or sha256-based
|
||||
type: string; // "TRANSFER", "CONTACT_REQUEST", etc.
|
||||
from: string; // hex Ed25519 pub key
|
||||
to: string; // hex Ed25519 pub key (empty string if N/A)
|
||||
amount: number; // µT (uint64)
|
||||
fee: number; // µT (uint64)
|
||||
memo?: string; // optional
|
||||
payload: string; // base64(json.Marshal(TypeSpecificPayload))
|
||||
signature: string; // base64(ed25519.Sign(canonical_bytes, priv))
|
||||
timestamp: string; // RFC3339 e.g. "2025-01-01T12:00:00Z"
|
||||
}
|
||||
|
||||
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
|
||||
console.log('[submitTx] →', {
|
||||
id: tx.id,
|
||||
type: tx.type,
|
||||
from: tx.from.slice(0, 12) + '…',
|
||||
to: tx.to ? tx.to.slice(0, 12) + '…' : '',
|
||||
amount: tx.amount,
|
||||
fee: tx.fee,
|
||||
timestamp: tx.timestamp,
|
||||
transport: 'auto',
|
||||
});
|
||||
|
||||
// Try the WebSocket path first: no HTTP round-trip, and we get a proper
|
||||
// submit_ack correlated back to our tx id. Falls through to HTTP if WS is
|
||||
// unavailable (old node, disconnected, timeout, etc.) so legacy setups
|
||||
// keep working.
|
||||
try {
|
||||
// Lazy import avoids a circular dep with lib/ws.ts (which itself
|
||||
// imports getNodeUrl from this module).
|
||||
const { getWSClient } = await import('./ws');
|
||||
const ws = getWSClient();
|
||||
if (ws.isConnected()) {
|
||||
try {
|
||||
const res = await ws.submitTx(tx);
|
||||
console.log('[submitTx] ← accepted via WS', res);
|
||||
return { id: res.id || tx.id, status: 'accepted' };
|
||||
} catch (e) {
|
||||
console.warn('[submitTx] WS path failed, falling back to HTTP:', e);
|
||||
}
|
||||
}
|
||||
} catch { /* circular import edge case — ignore and use HTTP */ }
|
||||
|
||||
try {
|
||||
const res = await post<{ id: string; status: string }>('/api/tx', tx);
|
||||
console.log('[submitTx] ← accepted via HTTP', res);
|
||||
return res;
|
||||
} catch (e) {
|
||||
console.warn('[submitTx] ← rejected', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord[]> {
|
||||
const data = await get<AddrResponse>(`/api/address/${pubkey}?limit=${limit}`);
|
||||
return (data.transactions ?? []).map(tx => ({
|
||||
hash: tx.id,
|
||||
type: tx.type,
|
||||
from: tx.from,
|
||||
to: tx.to,
|
||||
amount: tx.amount_ut,
|
||||
fee: tx.fee_ut,
|
||||
// Convert ISO-8601 string → unix seconds
|
||||
timestamp: tx.time ? Math.floor(new Date(tx.time).getTime() / 1000) : 0,
|
||||
status: 'confirmed' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Relay API ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Endpoints are mounted at the ROOT of the node HTTP server (not under /api):
|
||||
// POST /relay/broadcast — publish pre-sealed envelope (proper E2E)
|
||||
// GET /relay/inbox — fetch envelopes addressed to <pub>
|
||||
//
|
||||
// Why /relay/broadcast, not /relay/send?
|
||||
// /relay/send takes plaintext (msg_b64) и SEAL'ит его ключом релей-ноды —
|
||||
// это ломает end-to-end шифрование (получатель не сможет расшифровать
|
||||
// своим ключом). Для E2E всегда используем /relay/broadcast с уже
|
||||
// запечатанным на клиенте envelope'ом.
|
||||
|
||||
/**
|
||||
* Shape of envelope item returned by GET /relay/inbox (server item type).
|
||||
* Go `[]byte` поля сериализуются как base64 в JSON — поэтому `nonce` и
|
||||
* `ciphertext` приходят base64, а не hex. Мы декодируем их в hex для
|
||||
* совместимости с crypto.ts (decryptMessage принимает hex).
|
||||
*/
|
||||
interface InboxItemWire {
|
||||
id: string;
|
||||
sender_pub: string;
|
||||
recipient_pub: string;
|
||||
fee_ut?: number;
|
||||
sent_at: number;
|
||||
sent_at_human?: string;
|
||||
nonce: string; // base64
|
||||
ciphertext: string; // base64
|
||||
}
|
||||
|
||||
interface InboxResponseWire {
|
||||
pub: string;
|
||||
count: number;
|
||||
has_more: boolean;
|
||||
items: InboxItemWire[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиент собирает envelope через encryptMessage и шлёт на /relay/broadcast.
|
||||
* Серверный формат: `{envelope: <relay.Envelope JSON>}`. Nonce/ciphertext
|
||||
* там — base64 (Go []byte), а у нас в crypto.ts — hex, так что на wire
|
||||
* конвертим hex→bytes→base64.
|
||||
*/
|
||||
export async function sendEnvelope(params: {
|
||||
senderPub: string; // X25519 hex
|
||||
recipientPub: string; // X25519 hex
|
||||
nonce: string; // hex
|
||||
ciphertext: string; // hex
|
||||
senderEd25519Pub?: string; // optional — для будущих fee-релеев
|
||||
}): Promise<{ id: string; status: string }> {
|
||||
const sentAt = Math.floor(Date.now() / 1000);
|
||||
const nonceB64 = bytesToBase64(hexToBytes(params.nonce));
|
||||
const ctB64 = bytesToBase64(hexToBytes(params.ciphertext));
|
||||
|
||||
// envelope.id — 16 байт, hex. Сервер только проверяет что поле не
|
||||
// пустое и использует его как ключ mailbox'а. Первые 16 байт nonce
|
||||
// уже криптографически-случайны (nacl.randomBytes), так что берём их.
|
||||
const id = bytesToHex(hexToBytes(params.nonce).slice(0, 16));
|
||||
|
||||
return post<{ id: string; status: string }>('/relay/broadcast', {
|
||||
envelope: {
|
||||
id,
|
||||
sender_pub: params.senderPub,
|
||||
recipient_pub: params.recipientPub,
|
||||
sender_ed25519_pub: params.senderEd25519Pub ?? '',
|
||||
fee_ut: 0,
|
||||
fee_sig: null,
|
||||
nonce: nonceB64,
|
||||
ciphertext: ctB64,
|
||||
sent_at: sentAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch envelopes адресованные нам из relay-почтовика.
|
||||
* Server: `GET /relay/inbox?pub=<x25519hex>` → `{pub, count, has_more, items}`.
|
||||
* Нормализуем item'ы к clientскому Envelope type: sent_at → timestamp,
|
||||
* base64 nonce/ciphertext → hex.
|
||||
*/
|
||||
export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
|
||||
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519PubHex}`);
|
||||
const items = Array.isArray(resp?.items) ? resp.items : [];
|
||||
return items.map((it): Envelope => ({
|
||||
id: it.id,
|
||||
sender_pub: it.sender_pub,
|
||||
recipient_pub: it.recipient_pub,
|
||||
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
||||
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
||||
timestamp: it.sent_at ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
// ─── Contact requests (on-chain) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps blockchain.ContactInfo returned by GET /api/relay/contacts?pub=...
|
||||
* The response shape is { pub, count, contacts: ContactInfo[] }.
|
||||
*/
|
||||
export interface ContactRequestRaw {
|
||||
requester_pub: string; // Ed25519 pubkey of requester
|
||||
requester_addr: string; // DChain address (DC…)
|
||||
status: string; // "pending" | "accepted" | "blocked"
|
||||
intro: string; // plaintext intro message (may be empty)
|
||||
fee_ut: number; // anti-spam fee paid in µT
|
||||
tx_id: string; // transaction ID
|
||||
created_at: number; // unix seconds
|
||||
}
|
||||
|
||||
export async function fetchContactRequests(edPubHex: string): Promise<ContactRequestRaw[]> {
|
||||
const data = await get<{ contacts: ContactRequestRaw[] }>(`/api/relay/contacts?pub=${edPubHex}`);
|
||||
return data.contacts ?? [];
|
||||
}
|
||||
|
||||
// ─── Identity API ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface IdentityInfo {
|
||||
pub_key: string;
|
||||
address: string;
|
||||
x25519_pub: string; // hex Curve25519 key; empty string if not published
|
||||
nickname: string;
|
||||
registered: boolean;
|
||||
}
|
||||
|
||||
/** Fetch identity info for any pubkey or DC address. Returns null on 404. */
|
||||
export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> {
|
||||
try {
|
||||
return await get<IdentityInfo>(`/api/identity/${pubkeyOrAddr}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Contract API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Response shape from GET /api/contracts/{id}/state/{key}.
|
||||
* The node handler (node/api_contract.go:handleContractState) returns either:
|
||||
* { value_b64: null, value_hex: null, ... } when the key is missing
|
||||
* or
|
||||
* { value_b64: "...", value_hex: "...", value_u64?: 0 } when the key exists.
|
||||
*/
|
||||
interface ContractStateResponse {
|
||||
contract_id: string;
|
||||
key: string;
|
||||
value_b64: string | null;
|
||||
value_hex: string | null;
|
||||
value_u64?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a hex string (lowercase/uppercase) back to the original string value
|
||||
* it represents. The username registry contract stores values as plain ASCII
|
||||
* bytes (pubkey hex strings / username strings), so `value_hex` on the wire
|
||||
* is the hex-encoding of UTF-8 bytes. We hex-decode to bytes, then interpret
|
||||
* those bytes as UTF-8.
|
||||
*/
|
||||
function hexToUtf8(hex: string): string {
|
||||
if (hex.length % 2 !== 0) return '';
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
// TextDecoder is available in Hermes / RN's JS runtime.
|
||||
try {
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
} catch {
|
||||
// Fallback for environments without TextDecoder.
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
/** username → address (hex pubkey). Returns null if unregistered. */
|
||||
export async function resolveUsername(contractId: string, username: string): Promise<string | null> {
|
||||
try {
|
||||
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/name:${username}`);
|
||||
if (!data.value_hex) return null;
|
||||
const decoded = hexToUtf8(data.value_hex).trim();
|
||||
return decoded || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** address (hex pubkey) → username. Returns null if this address hasn't registered a name. */
|
||||
export async function reverseResolve(contractId: string, address: string): Promise<string | null> {
|
||||
try {
|
||||
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/addr:${address}`);
|
||||
if (!data.value_hex) return null;
|
||||
const decoded = hexToUtf8(data.value_hex).trim();
|
||||
return decoded || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Well-known contracts ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Per-entry shape returned by GET /api/well-known-contracts.
|
||||
* Matches node/api_well_known.go:WellKnownContract.
|
||||
*/
|
||||
export interface WellKnownContract {
|
||||
contract_id: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
deployed_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET /api/well-known-contracts.
|
||||
* `contracts` is keyed by ABI name (e.g. "username_registry").
|
||||
*/
|
||||
export interface WellKnownResponse {
|
||||
count: number;
|
||||
contracts: Record<string, WellKnownContract>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the node's view of canonical system contracts so the client doesn't
|
||||
* have to force the user to paste contract IDs into settings.
|
||||
*
|
||||
* The node returns the earliest-deployed contract per ABI name; this means
|
||||
* every peer in the same chain reports the same mapping.
|
||||
*
|
||||
* Returns `null` on failure (old node, network hiccup, endpoint missing).
|
||||
*/
|
||||
export async function fetchWellKnownContracts(): Promise<WellKnownResponse | null> {
|
||||
try {
|
||||
return await get<WellKnownResponse>('/api/well-known-contracts');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Node version / update-check ─────────────────────────────────────────────
|
||||
//
|
||||
// The three calls below let the client:
|
||||
// 1. fetchNodeVersion() — see what tag/commit/features the connected node
|
||||
// exposes. Used on first boot + on every chain-switch so we can warn if
|
||||
// a required feature is missing.
|
||||
// 2. checkNodeVersion(required) — thin wrapper that returns {supported,
|
||||
// missing} by diffing a client-expected feature list against the node's.
|
||||
// 3. fetchUpdateCheck() — ask the node whether its operator has a newer
|
||||
// release available from their configured release source (Gitea). For
|
||||
// messenger UX this is purely informational ("the node you're on is N
|
||||
// versions behind"), never used to update the node automatically.
|
||||
|
||||
/** The shape returned by GET /api/well-known-version. */
|
||||
export interface NodeVersionInfo {
|
||||
node_version: string;
|
||||
protocol_version: number;
|
||||
features: string[];
|
||||
chain_id?: string;
|
||||
build?: {
|
||||
tag: string;
|
||||
commit: string;
|
||||
date: string;
|
||||
dirty: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Client-expected protocol version. Bumped only when wire-protocol breaks. */
|
||||
export const CLIENT_PROTOCOL_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Minimum feature set this client build relies on. A node missing any of
|
||||
* these is considered "unsupported" — caller should surface an upgrade
|
||||
* prompt to the user instead of silently failing on the first feature call.
|
||||
*/
|
||||
export const CLIENT_REQUIRED_FEATURES = [
|
||||
'chain_id',
|
||||
'feed_v2', // social feed (v2.0.0) — PostCard, timeline, forYou
|
||||
'identity_registry',
|
||||
'media_scrub', // server-side EXIF strip — we rely on this for privacy
|
||||
'onboarding_api',
|
||||
'relay_broadcast', // /relay/broadcast for E2E envelopes (not /relay/send)
|
||||
'relay_mailbox',
|
||||
'ws_submit_tx',
|
||||
];
|
||||
|
||||
/** GET /api/well-known-version. Returns null on failure (old node, network hiccup). */
|
||||
export async function fetchNodeVersion(): Promise<NodeVersionInfo | null> {
|
||||
try {
|
||||
return await get<NodeVersionInfo>('/api/well-known-version');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the connected node supports this client's required features
|
||||
* and protocol version. Returns a decision blob the UI can render directly.
|
||||
*
|
||||
* { supported: true } → everything fine
|
||||
* { supported: false, reason: "...", ... } → show update prompt
|
||||
* { supported: null, reason: "unreachable" } → couldn't reach the endpoint,
|
||||
* likely old node — assume OK
|
||||
* but warn quietly.
|
||||
*/
|
||||
export async function checkNodeVersion(
|
||||
required: string[] = CLIENT_REQUIRED_FEATURES,
|
||||
): Promise<{
|
||||
supported: boolean | null;
|
||||
reason?: string;
|
||||
missing?: string[];
|
||||
info?: NodeVersionInfo;
|
||||
}> {
|
||||
const info = await fetchNodeVersion();
|
||||
if (!info) {
|
||||
return { supported: null, reason: 'unreachable' };
|
||||
}
|
||||
if (info.protocol_version !== CLIENT_PROTOCOL_VERSION) {
|
||||
return {
|
||||
supported: false,
|
||||
reason: `protocol v${info.protocol_version} but client expects v${CLIENT_PROTOCOL_VERSION}`,
|
||||
info,
|
||||
};
|
||||
}
|
||||
const have = new Set(info.features || []);
|
||||
const missing = required.filter((f) => !have.has(f));
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
supported: false,
|
||||
reason: `node missing features: ${missing.join(', ')}`,
|
||||
missing,
|
||||
info,
|
||||
};
|
||||
}
|
||||
return { supported: true, info };
|
||||
}
|
||||
|
||||
/** The shape returned by GET /api/update-check. */
|
||||
export interface UpdateCheckResponse {
|
||||
current: { tag: string; commit: string; date: string; dirty: string };
|
||||
latest?: { tag: string; commit?: string; url?: string; published_at?: string };
|
||||
update_available: boolean;
|
||||
checked_at: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/update-check. Returns null when:
|
||||
* - the node operator hasn't configured DCHAIN_UPDATE_SOURCE_URL (503),
|
||||
* - upstream Gitea call failed (502),
|
||||
* - request errored out.
|
||||
* All three are non-fatal for the client; the UI just doesn't render the
|
||||
* "update available" banner.
|
||||
*/
|
||||
export async function fetchUpdateCheck(): Promise<UpdateCheckResponse | null> {
|
||||
try {
|
||||
return await get<UpdateCheckResponse>('/api/update-check');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Transaction builder helpers ─────────────────────────────────────────────
|
||||
|
||||
import { signBase64 } from './crypto';
|
||||
|
||||
/** Minimum blockchain tx fee paid to the block validator (matches blockchain.MinFee = 1000 µT). */
|
||||
const MIN_TX_FEE = 1000;
|
||||
|
||||
const _encoder = new TextEncoder();
|
||||
|
||||
/** RFC3339 timestamp with second precision — matches Go time.Time JSON output. */
|
||||
function rfc3339Now(): string {
|
||||
const d = new Date();
|
||||
d.setMilliseconds(0);
|
||||
// toISOString() gives "2025-01-01T12:00:00.000Z" → replace ".000Z" with "Z"
|
||||
return d.toISOString().replace('.000Z', 'Z');
|
||||
}
|
||||
|
||||
/** Unique transaction ID (nanoseconds-like using Date.now + random). */
|
||||
function newTxID(): string {
|
||||
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical bytes for signing — must match identity.txSignBytes in Go exactly.
|
||||
*
|
||||
* Go struct field order: id, type, from, to, amount, fee, payload, timestamp.
|
||||
* JS JSON.stringify preserves insertion order, so we rely on that here.
|
||||
*/
|
||||
function txCanonicalBytes(tx: {
|
||||
id: string; type: string; from: string; to: string;
|
||||
amount: number; fee: number; payload: string; timestamp: string;
|
||||
}): Uint8Array {
|
||||
const s = JSON.stringify({
|
||||
id: tx.id,
|
||||
type: tx.type,
|
||||
from: tx.from,
|
||||
to: tx.to,
|
||||
amount: tx.amount,
|
||||
fee: tx.fee,
|
||||
payload: tx.payload,
|
||||
timestamp: tx.timestamp,
|
||||
});
|
||||
return _encoder.encode(s);
|
||||
}
|
||||
|
||||
/** Encode a JS string (UTF-8) to base64. */
|
||||
function strToBase64(s: string): string {
|
||||
return bytesToBase64(_encoder.encode(s));
|
||||
}
|
||||
|
||||
export function buildTransferTx(params: {
|
||||
from: string;
|
||||
to: string;
|
||||
amount: number;
|
||||
fee: number;
|
||||
privKey: string;
|
||||
memo?: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payloadObj = params.memo ? { memo: params.memo } : {};
|
||||
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'TRANSFER', from: params.from, to: params.to,
|
||||
amount: params.amount, fee: params.fee, payload, timestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
id, type: 'TRANSFER', from: params.from, to: params.to,
|
||||
amount: params.amount, fee: params.fee,
|
||||
memo: params.memo,
|
||||
payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTACT_REQUEST transaction.
|
||||
*
|
||||
* blockchain.Transaction fields:
|
||||
* Amount = contactFee — anti-spam fee, paid directly to recipient (>= 5000 µT)
|
||||
* Fee = MIN_TX_FEE — blockchain tx fee to the block validator (1000 µT)
|
||||
* Payload = ContactRequestPayload { intro? } as base64 JSON bytes
|
||||
*/
|
||||
export function buildContactRequestTx(params: {
|
||||
from: string; // sender Ed25519 pubkey
|
||||
to: string; // recipient Ed25519 pubkey
|
||||
contactFee: number; // anti-spam amount paid to recipient (>= 5000 µT)
|
||||
intro?: string; // optional plaintext intro message (≤ 280 chars)
|
||||
privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
// Payload matches ContactRequestPayload{Intro: "..."} in Go
|
||||
const payloadObj = params.intro ? { intro: params.intro } : {};
|
||||
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
|
||||
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
|
||||
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ACCEPT_CONTACT transaction.
|
||||
* AcceptContactPayload is an empty struct in Go — no fields needed.
|
||||
*/
|
||||
export function buildAcceptContactTx(params: {
|
||||
from: string; // acceptor Ed25519 pubkey (us — the recipient of the request)
|
||||
to: string; // requester Ed25519 pubkey
|
||||
privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify({})); // AcceptContactPayload{}
|
||||
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Contract call ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimum base fee for CALL_CONTRACT (matches blockchain.MinCallFee). */
|
||||
const MIN_CALL_FEE = 1000;
|
||||
|
||||
/**
|
||||
* CALL_CONTRACT transaction.
|
||||
*
|
||||
* Payload shape (CallContractPayload):
|
||||
* { contract_id, method, args_json?, gas_limit }
|
||||
*
|
||||
* `amount` is the payment attached to the call and made available to the
|
||||
* contract as `tx.Amount`. Whether it's collected depends on the contract
|
||||
* — e.g. username_registry.register requires exactly 10_000 µT. Contracts
|
||||
* that don't need payment should be called with `amount: 0` (default).
|
||||
*
|
||||
* The on-chain tx envelope carries `amount` openly, so the explorer shows
|
||||
* the exact cost of a call rather than hiding it in a contract-internal
|
||||
* debit — this was the UX motivation for this field.
|
||||
*
|
||||
* `fee` is the NETWORK fee paid to the block validator (not the contract).
|
||||
* `gas` costs are additional and billed at the live gas price.
|
||||
*/
|
||||
export function buildCallContractTx(params: {
|
||||
from: string;
|
||||
contractId: string;
|
||||
method: string;
|
||||
args?: unknown[]; // JSON-serializable arguments
|
||||
amount?: number; // µT attached to the call (default 0)
|
||||
gasLimit?: number; // default 1_000_000
|
||||
privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const amount = params.amount ?? 0;
|
||||
|
||||
const argsJson = params.args && params.args.length > 0
|
||||
? JSON.stringify(params.args)
|
||||
: '';
|
||||
|
||||
const payloadObj = {
|
||||
contract_id: params.contractId,
|
||||
method: params.method,
|
||||
args_json: argsJson,
|
||||
gas_limit: params.gasLimit ?? 1_000_000,
|
||||
};
|
||||
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'CALL_CONTRACT', from: params.from, to: '',
|
||||
amount, fee: MIN_CALL_FEE, payload, timestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
id, type: 'CALL_CONTRACT', from: params.from, to: '',
|
||||
amount, fee: MIN_CALL_FEE, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat registration fee for a username, in µT.
|
||||
*
|
||||
* The native username_registry charges a single flat fee (10 000 µT = 0.01 T)
|
||||
* per register() call regardless of name length, replacing the earlier
|
||||
* length-based formula. Flat pricing is easier to communicate and the
|
||||
* 4-char minimum (enforced both in the client UI and the on-chain contract)
|
||||
* already removes the squatting pressure that tiered pricing mitigated.
|
||||
*/
|
||||
export const USERNAME_REGISTRATION_FEE = 10_000;
|
||||
|
||||
/** Minimum/maximum allowed username length. Match blockchain/native_username.go. */
|
||||
export const MIN_USERNAME_LENGTH = 4;
|
||||
export const MAX_USERNAME_LENGTH = 32;
|
||||
|
||||
/** @deprecated Kept for backward compatibility; always returns the flat fee. */
|
||||
export function usernameRegistrationFee(_name: string): number {
|
||||
return USERNAME_REGISTRATION_FEE;
|
||||
}
|
||||
168
client-app/lib/crypto.ts
Normal file
168
client-app/lib/crypto.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Cryptographic operations for DChain messenger.
|
||||
*
|
||||
* Ed25519 — transaction signing (via TweetNaCl sign)
|
||||
* X25519 — Diffie-Hellman key exchange for NaCl box
|
||||
* NaCl box — authenticated encryption for relay messages
|
||||
*/
|
||||
|
||||
import nacl from 'tweetnacl';
|
||||
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
|
||||
import { getRandomBytes } from 'expo-crypto';
|
||||
import type { KeyFile } from './types';
|
||||
|
||||
// ─── PRNG ─────────────────────────────────────────────────────────────────────
|
||||
// TweetNaCl looks for window.crypto which doesn't exist in React Native/Hermes.
|
||||
// Wire nacl to expo-crypto which uses the platform's secure RNG natively.
|
||||
nacl.setPRNG((output: Uint8Array, length: number) => {
|
||||
const bytes = getRandomBytes(length);
|
||||
for (let i = 0; i < length; i++) output[i] = bytes[i];
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
if (hex.length % 2 !== 0) throw new Error('odd hex length');
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// ─── Key generation ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a new identity: Ed25519 signing keys + X25519 encryption keys.
|
||||
* Returns a KeyFile compatible with the Go node format.
|
||||
*/
|
||||
export function generateKeyFile(): KeyFile {
|
||||
// Ed25519 for signing / blockchain identity
|
||||
const signKP = nacl.sign.keyPair();
|
||||
|
||||
// X25519 for NaCl box encryption
|
||||
// nacl.box.keyPair() returns Curve25519 keys
|
||||
const boxKP = nacl.box.keyPair();
|
||||
|
||||
return {
|
||||
pub_key: bytesToHex(signKP.publicKey),
|
||||
priv_key: bytesToHex(signKP.secretKey),
|
||||
x25519_pub: bytesToHex(boxKP.publicKey),
|
||||
x25519_priv: bytesToHex(boxKP.secretKey),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── NaCl box encryption ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext message using NaCl box.
|
||||
* Sender uses their X25519 secret key + recipient's X25519 public key.
|
||||
* Returns { nonce, ciphertext } as hex strings.
|
||||
*/
|
||||
export function encryptMessage(
|
||||
plaintext: string,
|
||||
senderSecretHex: string,
|
||||
recipientPubHex: string,
|
||||
): { nonce: string; ciphertext: string } {
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||
const message = decodeUTF8(plaintext);
|
||||
const secretKey = hexToBytes(senderSecretHex);
|
||||
const publicKey = hexToBytes(recipientPubHex);
|
||||
|
||||
const box = nacl.box(message, nonce, publicKey, secretKey);
|
||||
return {
|
||||
nonce: bytesToHex(nonce),
|
||||
ciphertext: bytesToHex(box),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a NaCl box.
|
||||
* Recipient uses their X25519 secret key + sender's X25519 public key.
|
||||
*/
|
||||
export function decryptMessage(
|
||||
ciphertextHex: string,
|
||||
nonceHex: string,
|
||||
senderPubHex: string,
|
||||
recipientSecHex: string,
|
||||
): string | null {
|
||||
try {
|
||||
const ciphertext = hexToBytes(ciphertextHex);
|
||||
const nonce = hexToBytes(nonceHex);
|
||||
const senderPub = hexToBytes(senderPubHex);
|
||||
const secretKey = hexToBytes(recipientSecHex);
|
||||
|
||||
const plain = nacl.box.open(ciphertext, nonce, senderPub, secretKey);
|
||||
if (!plain) return null;
|
||||
return encodeUTF8(plain);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Ed25519 signing ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sign arbitrary data with the Ed25519 private key.
|
||||
* Returns signature as hex.
|
||||
*/
|
||||
export function sign(data: Uint8Array, privKeyHex: string): string {
|
||||
const secretKey = hexToBytes(privKeyHex);
|
||||
const sig = nacl.sign.detached(data, secretKey);
|
||||
return bytesToHex(sig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign arbitrary data with the Ed25519 private key.
|
||||
* Returns signature as base64 — this is the format the Go blockchain node
|
||||
* expects ([]byte fields are base64 in JSON).
|
||||
*/
|
||||
export function signBase64(data: Uint8Array, privKeyHex: string): string {
|
||||
const secretKey = hexToBytes(privKeyHex);
|
||||
const sig = nacl.sign.detached(data, secretKey);
|
||||
return bytesToBase64(sig);
|
||||
}
|
||||
|
||||
/** Encode bytes as base64. Works on Hermes (btoa is available since RN 0.71). */
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 → bytes. Accepts both standard and URL-safe variants (the
|
||||
* node's /relay/inbox returns `[]byte` fields marshalled via Go's default
|
||||
* json.Marshal which uses standard base64).
|
||||
*/
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
const binary = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an Ed25519 signature.
|
||||
*/
|
||||
export function verify(data: Uint8Array, sigHex: string, pubKeyHex: string): boolean {
|
||||
try {
|
||||
return nacl.sign.detached.verify(data, hexToBytes(sigHex), hexToBytes(pubKeyHex));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Address helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Truncate a long hex address for display: 8...8 */
|
||||
export function shortAddr(hex: string, chars = 8): string {
|
||||
if (hex.length <= chars * 2 + 3) return hex;
|
||||
return `${hex.slice(0, chars)}…${hex.slice(-chars)}`;
|
||||
}
|
||||
67
client-app/lib/dates.ts
Normal file
67
client-app/lib/dates.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Date / time форматирование для UI мессенджера.
|
||||
*
|
||||
* Все функции принимают **unix-seconds** (совместимо с `Message.timestamp`,
|
||||
* который тоже в секундах). Для ms-таймштампов делаем нормализацию внутри.
|
||||
*/
|
||||
|
||||
// ─── Русские месяцы (genitive для "17 июня 2025") ────────────────────────────
|
||||
const RU_MONTHS_GEN = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
|
||||
];
|
||||
|
||||
function sameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-bucket label для сепараторов внутри чата.
|
||||
* "Сегодня" / "Вчера" / "17 июня 2025"
|
||||
*
|
||||
* @param ts unix-seconds
|
||||
*/
|
||||
export function dateBucket(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
const now = new Date();
|
||||
const yday = new Date(); yday.setDate(now.getDate() - 1);
|
||||
if (sameDay(d, now)) return 'Сегодня';
|
||||
if (sameDay(d, yday)) return 'Вчера';
|
||||
return `${d.getDate()} ${RU_MONTHS_GEN[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Короткое relative-time под углом bubble ("29m", "14m", "3h", "2d", "12:40").
|
||||
*
|
||||
* @param ts unix-seconds
|
||||
*/
|
||||
export function relTime(ts: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - ts * 1000;
|
||||
if (diff < 60_000) return 'now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
|
||||
if (diff < 24 * 3_600_000) return `${Math.floor(diff / 3_600_000)}h`;
|
||||
if (diff < 7 * 24 * 3_600_000) return `${Math.floor(diff / (24 * 3_600_000))}d`;
|
||||
const d = new Date(ts * 1000);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Похоже на relTime, но принимает как unix-seconds, так и unix-ms.
|
||||
* Используется в chat-list tiles (там timestamp бывает в ms от addedAt).
|
||||
*/
|
||||
export function formatWhen(ts: number): string {
|
||||
// Heuristic: > 1e12 → уже в ms, иначе seconds.
|
||||
const sec = ts > 1e12 ? Math.floor(ts / 1000) : ts;
|
||||
return relTime(sec);
|
||||
}
|
||||
|
||||
/** "HH:MM" — одна и та же локаль, без дня. */
|
||||
export function formatHM(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
444
client-app/lib/devSeed.ts
Normal file
444
client-app/lib/devSeed.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Dev seed — заполняет store фейковыми контактами и сообщениями для UI-теста.
|
||||
*
|
||||
* Запускается один раз при монтировании layout'а если store пустой
|
||||
* (useDevSeed). Реальные контакты через WS/HTTP приходят позже —
|
||||
* `upsertContact` перезаписывает mock'и по address'у.
|
||||
*
|
||||
* Цели seed'а:
|
||||
* 1. Показать все три типа чатов (direct / group / channel) с разным
|
||||
* поведением sender-meta.
|
||||
* 2. Наполнить список чатов до скролла (15+ контактов).
|
||||
* 3. В каждом чате — ≥15 сообщений для скролла в chat view.
|
||||
* 4. Продемонстрировать "staircase" (run'ы одного отправителя
|
||||
* внутри 1h-окна) и переключения между отправителями.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useStore } from './store';
|
||||
import type { Contact, Message } from './types';
|
||||
|
||||
// ─── Детерминированные «pubkey» (64 hex символа) ───────────────────
|
||||
function fakeHex(seed: number): string {
|
||||
let h = '';
|
||||
let x = seed;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
x = (x * 1103515245 + 12345) & 0xffffffff;
|
||||
h += (x & 0xff).toString(16).padStart(2, '0');
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const MINE = fakeHex(9999);
|
||||
|
||||
// ─── Контакты ──────────────────────────────────────────────────────
|
||||
// 16 штук: 5 DM + 6 групп + 5 каналов. Поле `addedAt` задаёт порядок в
|
||||
// списке когда нет messages — ordering-fallback.
|
||||
|
||||
const mockContacts: Contact[] = [
|
||||
// ── DM ──────────────────────────────────────────────────────────
|
||||
{ address: fakeHex(1001), x25519Pub: fakeHex(2001),
|
||||
username: 'jordan', addedAt: Date.now() - 60 * 60 * 1_000, kind: 'direct' },
|
||||
{ address: fakeHex(1002), x25519Pub: fakeHex(2002),
|
||||
alias: 'Myles Wagner', addedAt: Date.now() - 2 * 60 * 60 * 1_000, kind: 'direct' },
|
||||
{ address: fakeHex(1010), x25519Pub: fakeHex(2010),
|
||||
username: 'sarah_k', addedAt: Date.now() - 3 * 60 * 60 * 1_000, kind: 'direct',
|
||||
unread: 2 },
|
||||
{ address: fakeHex(1011), x25519Pub: fakeHex(2011),
|
||||
alias: 'Mom', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'direct' },
|
||||
{ address: fakeHex(1012), x25519Pub: fakeHex(2012),
|
||||
username: 'alex_dev', addedAt: Date.now() - 6 * 60 * 60 * 1_000, kind: 'direct' },
|
||||
|
||||
// ── Groups ─────────────────────────────────────────────────────
|
||||
{ address: fakeHex(1003), x25519Pub: fakeHex(2003),
|
||||
alias: 'Tahoe weekend 🌲', addedAt: Date.now() - 4 * 60 * 60 * 1_000, kind: 'group' },
|
||||
{ address: fakeHex(1004), x25519Pub: fakeHex(2004),
|
||||
alias: 'Knicks tickets', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'group',
|
||||
unread: 3 },
|
||||
{ address: fakeHex(1020), x25519Pub: fakeHex(2020),
|
||||
alias: 'Family', addedAt: Date.now() - 8 * 60 * 60 * 1_000, kind: 'group' },
|
||||
{ address: fakeHex(1021), x25519Pub: fakeHex(2021),
|
||||
alias: 'Work eng', addedAt: Date.now() - 12 * 60 * 60 * 1_000, kind: 'group',
|
||||
unread: 7 },
|
||||
{ address: fakeHex(1022), x25519Pub: fakeHex(2022),
|
||||
alias: 'Book club', addedAt: Date.now() - 24 * 60 * 60 * 1_000, kind: 'group' },
|
||||
{ address: fakeHex(1023), x25519Pub: fakeHex(2023),
|
||||
alias: 'Tuesday D&D 🎲', addedAt: Date.now() - 30 * 60 * 60 * 1_000, kind: 'group' },
|
||||
|
||||
// (Channel seeds removed in v2.0.0 — channels replaced by the social feed.)
|
||||
];
|
||||
|
||||
// ─── Генератор сообщений ───────────────────────────────────────────
|
||||
|
||||
// Альт-отправители для group-чатов — нужны только как идентификатор `from`.
|
||||
const P_TYRA = fakeHex(3001);
|
||||
const P_MYLES = fakeHex(3002);
|
||||
const P_NATE = fakeHex(3003);
|
||||
const P_TYLER = fakeHex(3004);
|
||||
const P_MOM = fakeHex(3005);
|
||||
const P_DAD = fakeHex(3006);
|
||||
const P_SIS = fakeHex(3007);
|
||||
const P_LEAD = fakeHex(3008);
|
||||
const P_PM = fakeHex(3009);
|
||||
const P_QA = fakeHex(3010);
|
||||
const P_DESIGN= fakeHex(3011);
|
||||
const P_ANNA = fakeHex(3012);
|
||||
const P_DM_PEER = fakeHex(3013);
|
||||
|
||||
type Msg = Omit<Message, 'id'>;
|
||||
|
||||
function list(prefix: string, list: Msg[]): Message[] {
|
||||
return list.map((m, i) => ({ ...m, id: `${prefix}_${i}` }));
|
||||
}
|
||||
|
||||
function mockMessagesFor(contact: Contact): Message[] {
|
||||
const peer = contact.x25519Pub;
|
||||
|
||||
// ── DM: @jordan ────────────────────────────────────────────────
|
||||
if (contact.username === 'jordan') {
|
||||
// Важно: id'ы сообщений используются в replyTo.id, поэтому
|
||||
// указываем их явно где нужно сшить thread.
|
||||
const msgs: Message[] = list('jordan', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 22, text: 'Hey, have a sec later today?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 21, read: true, text: 'yep around 4pm' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'cool, coffee at the corner spot?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 19, read: true, text: 'works' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'just parked 🚗' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'see you in 5' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 3, read: true, text: "that was a great catchup" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: "totally — thanks for the book rec" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 40, text: 'Hey Jordan - Got tickets to the Knicks game tomorrow, let me know if you want to come!' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 39, text: "we've got floor seats 🔥" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 38, text: "starts at 7, pregame at the bar across the street" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: 'Ah sadly I already have plans' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 13, read: true, text: 'maybe next time?' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 5, text: "no worries — enjoy whatever you're up to" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 2, text: "wish you could make it tho 🏀" },
|
||||
]);
|
||||
// Пришьём reply: MINE-сообщение "Ah sadly…" отвечает на "Hey Jordan - Got tickets…"
|
||||
const target = msgs.find(m => m.text.startsWith('Hey Jordan - Got tickets'));
|
||||
const mine = msgs.find(m => m.text === 'Ah sadly I already have plans');
|
||||
if (target && mine) {
|
||||
mine.replyTo = {
|
||||
id: target.id,
|
||||
author: '@jordan',
|
||||
text: target.text,
|
||||
};
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
// ── DM: Myles Wagner ───────────────────────────────────────────
|
||||
if (contact.alias === 'Myles Wagner') {
|
||||
return list('myles', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'saw the draft, left a bunch of comments' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 29, read: true, text: 'thx, going through them now' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 29, text: 'no rush — tomorrow is fine' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'lunch today?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: "can't, stuck in reviews" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: 'tomorrow?' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: '✅ tomorrow' },
|
||||
{
|
||||
from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: '',
|
||||
attachment: {
|
||||
kind: 'voice',
|
||||
uri: 'voice-demo://myles-1',
|
||||
duration: 17,
|
||||
},
|
||||
},
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'the dchain repo finally built for me' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'docker weirdness was the issue' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 21, read: true, text: "nice, told you the WSL path would do it" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 20, text: 'So good!' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── DM: @sarah_k (с unread=2) ──────────────────────────────────
|
||||
if (contact.username === 'sarah_k') {
|
||||
return list('sarah', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: "hey! been a while" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 28, read: true, text: 'yeah, finally surfaced after the launch crunch' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 27, text: 'how did it go?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 27, read: true, text: "pretty well actually 🙏" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'btw drinks on friday?' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'that new wine bar' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'around 7 if you can make it' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── DM: Mom ────────────────────────────────────────────────────
|
||||
if (contact.alias === 'Mom') {
|
||||
return list('mom', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Did you see the photos from the trip?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 47, read: true, text: 'not yet, send them again?' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 47, text: 'ok' },
|
||||
{
|
||||
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
|
||||
attachment: {
|
||||
kind: 'image',
|
||||
uri: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800',
|
||||
width: 800, height: 533, mime: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
{
|
||||
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
|
||||
attachment: {
|
||||
kind: 'image',
|
||||
uri: 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=800',
|
||||
width: 800, height: 533, mime: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: 'wow, grandma looks great' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'she asked about you!' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'call later?' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── DM: @alex_dev ──────────────────────────────────────────────
|
||||
if (contact.username === 'alex_dev') {
|
||||
return list('alex', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'did you try the new WASM build?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 11, read: true, text: 'yeah, loader error on start' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 11, text: 'path encoding issue again?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 10, read: true, text: 'probably, checking now' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 8, read: true, text: 'yep, was the trailing slash' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 8, text: 'classic 😅' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'PR for that incoming tomorrow' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Group: Tahoe weekend 🌲 ────────────────────────────────────
|
||||
if (contact.alias === 'Tahoe weekend 🌲') {
|
||||
const msgs: Message[] = list('tahoe', [
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: "who's in for Tahoe this weekend?" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 49, text: "me!" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 48, read: true, text: "count me in" },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: "woohoo 🎉" },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 47, text: "planning friday night → sunday evening yeah?" },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 46, text: "yep, maybe leave friday after lunch" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "I made this itinerary with Grok, what do you think?" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 1: Eagle Falls hike" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 2: Emerald bay kayak" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 3: lazy breakfast then drive back" },
|
||||
{
|
||||
from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: '',
|
||||
attachment: {
|
||||
kind: 'file',
|
||||
uri: 'https://example.com/Lake_Tahoe_Itinerary.pdf',
|
||||
name: 'Lake_Tahoe_Itinerary.pdf',
|
||||
size: 97_280, // ~95 KB
|
||||
mime: 'application/pdf',
|
||||
},
|
||||
},
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 24, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "Eagle falls was stunning last year!" },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 31, text: "who's excited for Tahoe this weekend?" },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 30, text: "I've been checking the forecast — sun all weekend 🌞" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 22, text: "I made this itinerary with Grok, what do you think?" },
|
||||
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 21, text: "Day 1 we can hit Eagle Falls" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
|
||||
{
|
||||
from: P_TYRA, mine: false, timestamp: now() - 60 * 3, text: 'pic from my last trip 😍',
|
||||
attachment: {
|
||||
kind: 'image',
|
||||
uri: 'https://images.unsplash.com/photo-1505245208761-ba872912fac0?w=800',
|
||||
width: 800,
|
||||
height: 1000,
|
||||
mime: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
]);
|
||||
// Thread: mine "Love it — Eagle falls looks insane" — ответ на
|
||||
// Myles'овский itinerary-PDF. Берём ПЕРВЫЙ match "Day 1 we can hit
|
||||
// Eagle Falls" и пришиваем его к первому mine-bubble'у.
|
||||
const target = msgs.find(m => m.text === 'Day 1 we can hit Eagle Falls');
|
||||
const reply = msgs.find(m => m.text === 'Love it — Eagle falls looks insane' && m.mine);
|
||||
if (target && reply) {
|
||||
reply.replyTo = {
|
||||
id: target.id,
|
||||
author: 'Myles Wagner',
|
||||
text: target.text,
|
||||
};
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
// ── Group: Knicks tickets ──────────────────────────────────────
|
||||
if (contact.alias === 'Knicks tickets') {
|
||||
return list('knicks', [
|
||||
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 20, text: "quick group update — got 5 tickets for thursday" },
|
||||
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'wow nice' },
|
||||
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'where are we seated?' },
|
||||
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 19, text: 'section 102, row 12' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 18, read: true, text: 'thats a great spot' },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 18, text: "can someone venmo nate 🙏" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 17, read: true, text: 'sending now' },
|
||||
{ from: P_NATE, mine: false, timestamp: now() - 60 * 32, text: "Ok who's in for tomorrow's game?" },
|
||||
{ from: P_NATE, mine: false, timestamp: now() - 60 * 31, text: 'Got 2 extra tickets, first-come-first-served' },
|
||||
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 27, text: "I'm in!" },
|
||||
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 26, text: 'What time does it start?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 20, read: true, text: "Let's meet at the bar around 6?" },
|
||||
{ from: P_NATE, mine: false, timestamp: now() - 60 * 15, text: 'Sounds good' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Group: Family ──────────────────────────────────────────────
|
||||
if (contact.alias === 'Family') {
|
||||
return list('family', [
|
||||
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 36, text: 'remember grandma birthday on sunday' },
|
||||
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 36, text: 'noted 🎂' },
|
||||
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 35, text: 'who is bringing the cake?' },
|
||||
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 35, text: "I'll get it from the bakery" },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 34, read: true, text: 'I can pick up flowers' },
|
||||
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 34, text: 'perfect' },
|
||||
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 8, text: 'forecast is rain sunday — backup plan?' },
|
||||
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 8, text: "we'll move indoors, the living room works" },
|
||||
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 7, text: 'works!' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Group: Work eng (unread=7) ─────────────────────────────────
|
||||
if (contact.alias === 'Work eng') {
|
||||
return list('work', [
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 16, text: 'standup at 10 moved to 11 today btw' },
|
||||
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 16, text: 'thanks!' },
|
||||
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 15, text: "the staging deploy broke again 🙃" },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 15, text: "ugh, looking" },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 14, text: 'fixed — migration was stuck' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 13, read: true, text: 'Worked for me now 👍' },
|
||||
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 5, text: 'reminder: demo tomorrow, slides by eod' },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Ill handle the technical half' },
|
||||
{ from: P_DESIGN,mine: false,timestamp: now() - 60 * 60 * 4, text: 'just posted the v2 mocks in figma' },
|
||||
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 3, text: 'chatting with sales — 3 new trials this week' },
|
||||
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 2, text: 'flaky test on CI — investigating' },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 30, text: 'okay seems like CI is green now' },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 28, text: 'retry passed' },
|
||||
{ from: P_PM, mine: false, timestamp: now() - 60 * 20, text: "we're good for release" },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Group: Book club ───────────────────────────────────────────
|
||||
if (contact.alias === 'Book club') {
|
||||
return list('book', [
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 96, text: 'next month: "Project Hail Mary"?' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 95, read: true, text: '👍' },
|
||||
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 94, text: 'yes please' },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'halfway through — so good' },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'love the linguistics angle' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: "rocky is my favourite character in years" },
|
||||
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 28, text: 'agreed' },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "let's meet sunday 4pm?" },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Group: Tuesday D&D 🎲 ──────────────────────────────────────
|
||||
if (contact.alias === 'Tuesday D&D 🎲') {
|
||||
return list('dnd', [
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 72, text: 'Session 14 recap up on the wiki' },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 72, text: '🙏' },
|
||||
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: 'can we start 30min late next tuesday? commute issue' },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 50, text: 'sure' },
|
||||
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 49, read: true, text: 'works for me' },
|
||||
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 32, text: 'we pick up where we left — in the dragons cave' },
|
||||
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 32, text: 'excited 🐉' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Channel: dchain_updates ────────────────────────────────────
|
||||
if (contact.username === 'dchain_updates') {
|
||||
return list('dchain_updates', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 96, text: '🔨 v0.0.1-alpha tagged on Gitea' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 72, text: 'PBFT equivocation-detection тесты зелёные' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'New: /api/peers теперь включает peer-version info' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: '📘 Docs overhaul merged: docs/README.md' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 36, text: 'Schema migration scaffold landed (no-op для текущей версии)' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: '🚀 v0.0.1 released' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 10, text: 'Includes: auto-update from Gitea, peer-version gossip, schema migrations' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 20, text: 'Check /api/well-known-version for the full feature list' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'Thanks to all testers — feedback drives the roadmap 🙏' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'v0.0.2 roadmap published: https://git.vsecoder.vodka/vsecoder/dchain' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 30, text: 'quick heads-up: nightly builds switching to new docker-slim base' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Channel: Relay broadcasts ──────────────────────────────────
|
||||
if (contact.alias === '⚡ Relay broadcasts') {
|
||||
return list('relay_bc', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Relay fleet snapshot: 12 active, 3 inactive' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Relay #3 came online in US-east' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Validator set updated: 3→4' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'PBFT view-change детектирован и отработан на блоке 184120' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 15, text: 'Mailbox eviction ran — 42 stale envelopes' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'Relay #8 slashed for equivocation — evidence at block 184202' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'Relay #12 came online in EU-west, registering now…' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Channel: Tech news ────────────────────────────────────────
|
||||
if (contact.alias === '📰 Tech news') {
|
||||
return list('tech_news', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Rust 1.78 released — new lints for raw pointers' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Go 1.23 ships range-over-func officially' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Expo SDK 54 drops — new-architecture default' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'CVE-2026-1337 patched in libsodium (update your keys)' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Matrix protocol adds post-quantum handshakes' },
|
||||
{
|
||||
from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Data-center aerial view — new hyperscaler in Iceland',
|
||||
attachment: {
|
||||
kind: 'image',
|
||||
uri: 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800',
|
||||
width: 800, height: 533, mime: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'IETF draft: "DNS-over-blockchain"' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 6, text: 'GitHub tightens 2FA defaults for orgs' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Channel: Design inspo (unread=12) ──────────────────────────
|
||||
if (contact.alias === '🎨 Design inspo') {
|
||||
return list('design_inspo', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Weekly pick: Linear UI v3 breakdown' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 140, text: 'Figma file of the week: "Command bar patterns"' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Motion study: Stripe checkout shake-error animation' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: "10 great empty-state illustrations (blogpost)" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Tool: Hatch — colour-palette extractor from photos' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: '🔮 Trend watch: glassmorphism is back (again)' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Twitter thread: why rounded buttons are the default' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'Framer templates — black friday sale' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'New typeface: "Grotesk Pro" — free for personal use' },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Channel: NBA scores ────────────────────────────────────────
|
||||
if (contact.alias === '🏀 NBA scores') {
|
||||
return list('nba', [
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Lakers 112 — Warriors 108 (OT)' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 130, text: 'Celtics 128 — Heat 115' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Nuggets 119 — Thunder 102' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 70, text: "Knicks 101 — Bulls 98" },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Mavericks 130 — Kings 127' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 24, text: 'Bucks 114 — Sixers 110' },
|
||||
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Live: Lakers leading 78-72 at half' },
|
||||
]);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// ─── Hook ──────────────────────────────────────────────────────────
|
||||
|
||||
export function useDevSeed() {
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const setContacts = useStore(s => s.setContacts);
|
||||
const setMessages = useStore(s => s.setMessages);
|
||||
|
||||
useEffect(() => {
|
||||
if (contacts.length > 0) return;
|
||||
setContacts(mockContacts);
|
||||
for (const c of mockContacts) {
|
||||
const msgs = mockMessagesFor(c);
|
||||
if (msgs.length > 0) setMessages(c.address, msgs);
|
||||
}
|
||||
}, [contacts.length, setContacts, setMessages]);
|
||||
}
|
||||
180
client-app/lib/devSeedFeed.ts
Normal file
180
client-app/lib/devSeedFeed.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Dev-only mock posts for the feed.
|
||||
*
|
||||
* Why: in __DEV__ before any real posts exist on the node, the timeline/
|
||||
* for-you/trending tabs come back empty. Empty state is fine visually but
|
||||
* doesn't let you test scrolling, like animations, view-counter bumps,
|
||||
* navigation to post detail, etc. This module injects a small set of
|
||||
* synthetic posts so the UI has something to chew on.
|
||||
*
|
||||
* Gating:
|
||||
* - Only active when __DEV__ === true (stripped from production builds).
|
||||
* - Only surfaces when the REAL API returns an empty array. If the node
|
||||
* is returning actual posts, we trust those and skip the mocks.
|
||||
*
|
||||
* These posts have made-up post_ids — tapping on them to open detail
|
||||
* WILL 404 against the real backend. That's intentional — the mock is
|
||||
* purely for scroll / tap-feedback testing.
|
||||
*/
|
||||
import type { FeedPostItem } from './feed';
|
||||
|
||||
// Fake hex-like pubkeys so Avatar's colour hash still looks varied.
|
||||
function fakeAddr(seed: number): string {
|
||||
const h = (seed * 2654435761).toString(16).padStart(8, '0');
|
||||
return (h + h + h + h).slice(0, 64);
|
||||
}
|
||||
|
||||
function fakePostID(n: number): string {
|
||||
return `dev${String(n).padStart(29, '0')}`;
|
||||
}
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Small curated pool of posts covering the render surface we care about:
|
||||
// plain text, hashtag variety, different lengths, likes / views spread,
|
||||
// reply/quote references, one with an attachment marker.
|
||||
const SEED_POSTS: FeedPostItem[] = [
|
||||
{
|
||||
post_id: fakePostID(1),
|
||||
author: fakeAddr(1),
|
||||
content: 'Добро пожаловать в ленту DChain. Это #DEV-посты — они видны только пока реальная лента пустая.',
|
||||
created_at: NOW - 60,
|
||||
size: 200,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 127, likes: 42,
|
||||
has_attachment: false,
|
||||
hashtags: ['dev'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(2),
|
||||
author: fakeAddr(2),
|
||||
content: 'Пробую новую ленту #twitter-style. Лайки, просмотры, подписки — всё on-chain, тела постов — off-chain в mailbox релея.',
|
||||
created_at: NOW - 540,
|
||||
size: 310,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 89, likes: 23,
|
||||
has_attachment: false,
|
||||
hashtags: ['twitter'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(3),
|
||||
author: fakeAddr(3),
|
||||
content: 'Сжатие изображений — максимальное на клиенте (WebP Q=50 @1080p), плюс серверный EXIF-скраб через stdlib re-encode. GPS-координаты из EXIF больше никогда не утекают. #privacy',
|
||||
created_at: NOW - 1200,
|
||||
size: 420,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 312, likes: 78,
|
||||
has_attachment: true,
|
||||
hashtags: ['privacy'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(4),
|
||||
author: fakeAddr(4),
|
||||
content: 'Короткий пост.',
|
||||
created_at: NOW - 3600,
|
||||
size: 128,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 12, likes: 3,
|
||||
has_attachment: false,
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(5),
|
||||
author: fakeAddr(1),
|
||||
content: 'Отвечаю сам себе — фича threads пока через reply_to только, без UI thread-виджета.',
|
||||
created_at: NOW - 7200,
|
||||
size: 220,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 45, likes: 11,
|
||||
has_attachment: false,
|
||||
reply_to: fakePostID(1),
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(6),
|
||||
author: fakeAddr(5),
|
||||
content: '#golang + #badgerdb + #libp2p = DChain бэкенд. Пять package в test suite, все зелёные.',
|
||||
created_at: NOW - 10800,
|
||||
size: 180,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 201, likes: 66,
|
||||
has_attachment: false,
|
||||
hashtags: ['golang', 'badgerdb', 'libp2p'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(7),
|
||||
author: fakeAddr(6),
|
||||
content: 'Feed-mailbox хранит тела постов до 30 дней (настраиваемо через DCHAIN_FEED_TTL_DAYS). Потом BadgerDB выселяет автоматически — chain-метаданные остаются навсегда.',
|
||||
created_at: NOW - 14400,
|
||||
size: 380,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 156, likes: 48,
|
||||
has_attachment: false,
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(8),
|
||||
author: fakeAddr(7),
|
||||
content: 'Pricing: BasePostFee = 1000 µT (0.001 T) + 1 µT за каждый байт. Уходит владельцу релея, принявшего пост.',
|
||||
created_at: NOW - 21600,
|
||||
size: 250,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 78, likes: 22,
|
||||
has_attachment: false,
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(9),
|
||||
author: fakeAddr(8),
|
||||
content: 'Twitter-like, но без миллиардов долларов на инфраструктуру — каждый оператор ноды платит за свой кусок хостинга и зарабатывает на публикациях. #decentralised #messaging',
|
||||
created_at: NOW - 43200,
|
||||
size: 340,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 412, likes: 103,
|
||||
has_attachment: false,
|
||||
hashtags: ['decentralised', 'messaging'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(10),
|
||||
author: fakeAddr(9),
|
||||
content: 'Короче. Лайк = on-chain tx с fee 1000 µT. Дорого для спама, дёшево для реального лайка. Пока без батчинга, но в плане. #design',
|
||||
created_at: NOW - 64800,
|
||||
size: 200,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 92, likes: 29,
|
||||
has_attachment: false,
|
||||
hashtags: ['design'],
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(11),
|
||||
author: fakeAddr(2),
|
||||
content: 'Follow граф на chain: двусторонний индекс (forward + inbound), так что Followers() и Following() — оба O(M).',
|
||||
created_at: NOW - 86400 - 1000,
|
||||
size: 230,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 61, likes: 14,
|
||||
has_attachment: false,
|
||||
},
|
||||
{
|
||||
post_id: fakePostID(12),
|
||||
author: fakeAddr(10),
|
||||
content: 'Рекомендации (For You): берём последние 48ч постов, фильтруем подписки + уже лайкнутые + свои, ранжируем по likes × 3 + views. Версия 1 — будет умнее. #recsys',
|
||||
created_at: NOW - 129600,
|
||||
size: 290,
|
||||
hosting_relay: fakeAddr(100),
|
||||
views: 189, likes: 58,
|
||||
has_attachment: false,
|
||||
hashtags: ['recsys'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the dev-seed post list. Only returns actual items in dev
|
||||
* builds; release bundles return an empty array so fake posts never
|
||||
* appear in production.
|
||||
*
|
||||
* We use the runtime `globalThis.__DEV__` lookup rather than the typed
|
||||
* `__DEV__` global because some builds can have the TS typing
|
||||
* out-of-sync with the actual injected value.
|
||||
*/
|
||||
export function getDevSeedFeed(): FeedPostItem[] {
|
||||
const g = globalThis as unknown as { __DEV__?: boolean };
|
||||
if (g.__DEV__ !== true) return [];
|
||||
return SEED_POSTS;
|
||||
}
|
||||
498
client-app/lib/feed.ts
Normal file
498
client-app/lib/feed.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Feed client — HTTP wrappers + tx builders for the v2.0.0 social feed.
|
||||
*
|
||||
* Flow for publishing a post:
|
||||
* 1. Build the post body (text + optional pre-compressed attachment).
|
||||
* 2. Sign "publish:<post_id>:<sha256(body)>:<ts>" with the author's
|
||||
* Ed25519 key.
|
||||
* 3. POST /feed/publish — server verifies sig, scrubs metadata from
|
||||
* any image attachment, stores the body, returns
|
||||
* { post_id, content_hash, size, estimated_fee_ut, hashtags }.
|
||||
* 4. Submit CREATE_POST tx on-chain with THE RETURNED content_hash +
|
||||
* size + hosting_relay. Fee = server's estimate (server credits
|
||||
* the full amount to the hosting relay).
|
||||
*
|
||||
* For reads we hit /feed/timeline, /feed/foryou, /feed/trending,
|
||||
* /feed/author/{pub}, /feed/hashtag/{tag}. All return a list of
|
||||
* FeedPostItem (chain metadata enriched with body + stats).
|
||||
*/
|
||||
|
||||
import { getNodeUrl, submitTx, type RawTx } from './api';
|
||||
import {
|
||||
bytesToBase64, bytesToHex, hexToBytes, signBase64, sign as signHex,
|
||||
} from './crypto';
|
||||
|
||||
// ─── Types (mirrors node/api_feed.go shapes) ──────────────────────────────
|
||||
|
||||
/** Single post as returned by /feed/author, /feed/timeline, etc. */
|
||||
export interface FeedPostItem {
|
||||
post_id: string;
|
||||
author: string; // hex Ed25519
|
||||
content: string;
|
||||
content_type?: string; // "text/plain" | "text/markdown"
|
||||
hashtags?: string[];
|
||||
reply_to?: string;
|
||||
quote_of?: string;
|
||||
created_at: number; // unix seconds
|
||||
size: number;
|
||||
hosting_relay: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
has_attachment: boolean;
|
||||
}
|
||||
|
||||
export interface PostStats {
|
||||
post_id: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
liked_by_me?: boolean;
|
||||
}
|
||||
|
||||
export interface PublishResponse {
|
||||
post_id: string;
|
||||
hosting_relay: string;
|
||||
content_hash: string; // hex sha256 over scrubbed bytes
|
||||
size: number;
|
||||
hashtags: string[];
|
||||
estimated_fee_ut: number;
|
||||
}
|
||||
|
||||
export interface TimelineResponse {
|
||||
count: number;
|
||||
posts: FeedPostItem[];
|
||||
}
|
||||
|
||||
// ─── HTTP helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function getJSON<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${getNodeUrl()}${path}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET ${path} → ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function postJSON<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${getNodeUrl()}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let detail = text;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed?.error) detail = parsed.error;
|
||||
} catch { /* keep raw */ }
|
||||
throw new Error(`POST ${path} → ${res.status}: ${detail}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ─── Publish flow ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute a post_id from author + nanoseconds-ish entropy + content prefix.
|
||||
* Must match the server-side hash trick (`sha256(author-nanos-content)[:16]`).
|
||||
* Details don't matter — server only checks the id is non-empty. We just
|
||||
* need uniqueness across the author's own posts.
|
||||
*/
|
||||
async function computePostID(author: string, content: string): Promise<string> {
|
||||
const { digestStringAsync, CryptoDigestAlgorithm, CryptoEncoding } =
|
||||
await import('expo-crypto');
|
||||
const seed = `${author}-${Date.now()}${Math.floor(Math.random() * 1e9)}-${content.slice(0, 64)}`;
|
||||
const hex = await digestStringAsync(
|
||||
CryptoDigestAlgorithm.SHA256,
|
||||
seed,
|
||||
{ encoding: CryptoEncoding.HEX },
|
||||
);
|
||||
return hex.slice(0, 32); // 16 bytes = 32 hex chars
|
||||
}
|
||||
|
||||
/**
|
||||
* sha256 of a UTF-8 string (optionally with appended bytes for attachments).
|
||||
* Returns hex. Uses expo-crypto for native speed.
|
||||
*/
|
||||
async function sha256Hex(content: string, attachment?: Uint8Array): Promise<string> {
|
||||
const { digest, CryptoDigestAlgorithm } = await import('expo-crypto');
|
||||
const encoder = new TextEncoder();
|
||||
const contentBytes = encoder.encode(content);
|
||||
const combined = new Uint8Array(
|
||||
contentBytes.length + (attachment ? attachment.length : 0),
|
||||
);
|
||||
combined.set(contentBytes, 0);
|
||||
if (attachment) combined.set(attachment, contentBytes.length);
|
||||
|
||||
const buf = await digest(CryptoDigestAlgorithm.SHA256, combined);
|
||||
// ArrayBuffer → hex
|
||||
const view = new Uint8Array(buf);
|
||||
return bytesToHex(view);
|
||||
}
|
||||
|
||||
export interface PublishParams {
|
||||
author: string; // hex Ed25519 pubkey
|
||||
privKey: string; // hex Ed25519 privkey
|
||||
content: string;
|
||||
attachment?: Uint8Array;
|
||||
attachmentMIME?: string; // "image/jpeg" | "image/png" | "video/mp4" | ...
|
||||
replyTo?: string;
|
||||
quoteOf?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a post: POSTs /feed/publish with a signed body. Returns the
|
||||
* server's response so the caller can submit a matching CREATE_POST tx.
|
||||
*
|
||||
* Client is expected to have compressed the attachment FIRST (see
|
||||
* `lib/media.ts`) — this function does not re-compress, only signs and
|
||||
* uploads. Server will scrub metadata regardless.
|
||||
*/
|
||||
export async function publishPost(p: PublishParams): Promise<PublishResponse> {
|
||||
const postID = await computePostID(p.author, p.content);
|
||||
const clientHash = await sha256Hex(p.content, p.attachment);
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Sign: "publish:<post_id>:<raw_content_hash>:<ts>"
|
||||
const encoder = new TextEncoder();
|
||||
const msg = encoder.encode(`publish:${postID}:${clientHash}:${ts}`);
|
||||
const sig = signBase64(msg, p.privKey);
|
||||
|
||||
const body = {
|
||||
post_id: postID,
|
||||
author: p.author,
|
||||
content: p.content,
|
||||
content_type: 'text/plain',
|
||||
attachment_b64: p.attachment ? bytesToBase64(p.attachment) : undefined,
|
||||
attachment_mime: p.attachmentMIME ?? undefined,
|
||||
reply_to: p.replyTo,
|
||||
quote_of: p.quoteOf,
|
||||
sig,
|
||||
ts,
|
||||
};
|
||||
|
||||
return postJSON<PublishResponse>('/feed/publish', body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full publish flow: POST /feed/publish → on-chain CREATE_POST tx.
|
||||
* Returns the final post_id (same as server response; echoed for UX).
|
||||
*/
|
||||
export async function publishAndCommit(p: PublishParams): Promise<string> {
|
||||
const pub = await publishPost(p);
|
||||
const tx = buildCreatePostTx({
|
||||
from: p.author,
|
||||
privKey: p.privKey,
|
||||
postID: pub.post_id,
|
||||
contentHash: pub.content_hash,
|
||||
size: pub.size,
|
||||
hostingRelay: pub.hosting_relay,
|
||||
fee: pub.estimated_fee_ut,
|
||||
replyTo: p.replyTo,
|
||||
quoteOf: p.quoteOf,
|
||||
});
|
||||
await submitTx(tx);
|
||||
return pub.post_id;
|
||||
}
|
||||
|
||||
// ─── Read endpoints ───────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchPost(postID: string): Promise<FeedPostItem | null> {
|
||||
try {
|
||||
return await getJSON<FeedPostItem>(`/feed/post/${postID}`);
|
||||
} catch (e: any) {
|
||||
if (/→\s*404\b/.test(String(e?.message))) return null;
|
||||
if (/→\s*410\b/.test(String(e?.message))) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchStats(postID: string, me?: string): Promise<PostStats | null> {
|
||||
try {
|
||||
const qs = me ? `?me=${me}` : '';
|
||||
return await getJSON<PostStats>(`/feed/post/${postID}/stats${qs}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the off-chain view counter. Fire-and-forget — failures here
|
||||
* are not fatal to the UX, so we swallow errors.
|
||||
*/
|
||||
export async function bumpView(postID: string): Promise<void> {
|
||||
try {
|
||||
await postJSON<unknown>(`/feed/post/${postID}/view`, undefined);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export async function fetchAuthorPosts(
|
||||
pub: string, opts: { limit?: number; before?: number } = {},
|
||||
): Promise<FeedPostItem[]> {
|
||||
const limit = opts.limit ?? 30;
|
||||
const qs = opts.before
|
||||
? `?limit=${limit}&before=${opts.before}`
|
||||
: `?limit=${limit}`;
|
||||
const resp = await getJSON<TimelineResponse>(`/feed/author/${pub}${qs}`);
|
||||
return resp.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchTimeline(
|
||||
followerPub: string, opts: { limit?: number; before?: number } = {},
|
||||
): Promise<FeedPostItem[]> {
|
||||
const limit = opts.limit ?? 30;
|
||||
let qs = `?follower=${followerPub}&limit=${limit}`;
|
||||
if (opts.before) qs += `&before=${opts.before}`;
|
||||
const resp = await getJSON<TimelineResponse>(`/feed/timeline${qs}`);
|
||||
return resp.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchForYou(pub: string, limit = 30): Promise<FeedPostItem[]> {
|
||||
const resp = await getJSON<TimelineResponse>(`/feed/foryou?pub=${pub}&limit=${limit}`);
|
||||
return resp.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchTrending(windowHours = 24, limit = 30): Promise<FeedPostItem[]> {
|
||||
const resp = await getJSON<TimelineResponse>(`/feed/trending?window=${windowHours}&limit=${limit}`);
|
||||
return resp.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchHashtag(tag: string, limit = 30): Promise<FeedPostItem[]> {
|
||||
const cleanTag = tag.replace(/^#/, '');
|
||||
const resp = await getJSON<TimelineResponse>(`/feed/hashtag/${encodeURIComponent(cleanTag)}?limit=${limit}`);
|
||||
return resp.posts ?? [];
|
||||
}
|
||||
|
||||
// ─── Transaction builders ─────────────────────────────────────────────────
|
||||
//
|
||||
// These match the blockchain.Event* payloads 1-to-1 and produce already-
|
||||
// signed RawTx objects ready for submitTx.
|
||||
|
||||
/** RFC3339 second-precision timestamp — matches Go time.Time default JSON. */
|
||||
function rfc3339Now(): string {
|
||||
const d = new Date();
|
||||
d.setMilliseconds(0);
|
||||
return d.toISOString().replace('.000Z', 'Z');
|
||||
}
|
||||
|
||||
function newTxID(): string {
|
||||
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
const _encoder = new TextEncoder();
|
||||
|
||||
function txCanonicalBytes(tx: {
|
||||
id: string; type: string; from: string; to: string;
|
||||
amount: number; fee: number; payload: string; timestamp: string;
|
||||
}): Uint8Array {
|
||||
return _encoder.encode(JSON.stringify({
|
||||
id: tx.id,
|
||||
type: tx.type,
|
||||
from: tx.from,
|
||||
to: tx.to,
|
||||
amount: tx.amount,
|
||||
fee: tx.fee,
|
||||
payload: tx.payload,
|
||||
timestamp: tx.timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
function strToBase64(s: string): string {
|
||||
return bytesToBase64(_encoder.encode(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* CREATE_POST tx. content_hash is the HEX sha256 returned by /feed/publish;
|
||||
* must be converted to base64 bytes for the on-chain payload (Go []byte →
|
||||
* base64 in JSON).
|
||||
*/
|
||||
export function buildCreatePostTx(params: {
|
||||
from: string;
|
||||
privKey: string;
|
||||
postID: string;
|
||||
contentHash: string; // hex from server
|
||||
size: number;
|
||||
hostingRelay: string;
|
||||
fee: number;
|
||||
replyTo?: string;
|
||||
quoteOf?: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payloadObj = {
|
||||
post_id: params.postID,
|
||||
content_hash: bytesToBase64(hexToBytes(params.contentHash)),
|
||||
size: params.size,
|
||||
hosting_relay: params.hostingRelay,
|
||||
reply_to: params.replyTo ?? '',
|
||||
quote_of: params.quoteOf ?? '',
|
||||
};
|
||||
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'CREATE_POST', from: params.from, to: '',
|
||||
amount: 0, fee: params.fee, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'CREATE_POST', from: params.from, to: '',
|
||||
amount: 0, fee: params.fee, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDeletePostTx(params: {
|
||||
from: string;
|
||||
privKey: string;
|
||||
postID: string;
|
||||
fee?: number;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
|
||||
const fee = params.fee ?? 1000;
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'DELETE_POST', from: params.from, to: '',
|
||||
amount: 0, fee, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'DELETE_POST', from: params.from, to: '',
|
||||
amount: 0, fee, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFollowTx(params: {
|
||||
from: string;
|
||||
privKey: string;
|
||||
target: string;
|
||||
fee?: number;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64('{}');
|
||||
const fee = params.fee ?? 1000;
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'FOLLOW', from: params.from, to: params.target,
|
||||
amount: 0, fee, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'FOLLOW', from: params.from, to: params.target,
|
||||
amount: 0, fee, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUnfollowTx(params: {
|
||||
from: string;
|
||||
privKey: string;
|
||||
target: string;
|
||||
fee?: number;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64('{}');
|
||||
const fee = params.fee ?? 1000;
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'UNFOLLOW', from: params.from, to: params.target,
|
||||
amount: 0, fee, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'UNFOLLOW', from: params.from, to: params.target,
|
||||
amount: 0, fee, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLikePostTx(params: {
|
||||
from: string;
|
||||
privKey: string;
|
||||
postID: string;
|
||||
fee?: number;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
|
||||
const fee = params.fee ?? 1000;
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'LIKE_POST', from: params.from, to: '',
|
||||
amount: 0, fee, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'LIKE_POST', from: params.from, to: '',
|
||||
amount: 0, fee, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUnlikePostTx(params: {
|
||||
from: string;
|
||||
privKey: string;
|
||||
postID: string;
|
||||
fee?: number;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
|
||||
const fee = params.fee ?? 1000;
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'UNLIKE_POST', from: params.from, to: '',
|
||||
amount: 0, fee, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'UNLIKE_POST', from: params.from, to: '',
|
||||
amount: 0, fee, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── High-level helpers (combine build + submit) ─────────────────────────
|
||||
|
||||
export async function likePost(params: { from: string; privKey: string; postID: string }) {
|
||||
await submitTx(buildLikePostTx(params));
|
||||
}
|
||||
|
||||
export async function unlikePost(params: { from: string; privKey: string; postID: string }) {
|
||||
await submitTx(buildUnlikePostTx(params));
|
||||
}
|
||||
|
||||
export async function followUser(params: { from: string; privKey: string; target: string }) {
|
||||
await submitTx(buildFollowTx(params));
|
||||
}
|
||||
|
||||
export async function unfollowUser(params: { from: string; privKey: string; target: string }) {
|
||||
await submitTx(buildUnfollowTx(params));
|
||||
}
|
||||
|
||||
export async function deletePost(params: { from: string; privKey: string; postID: string }) {
|
||||
await submitTx(buildDeletePostTx(params));
|
||||
}
|
||||
|
||||
// ─── Formatting helpers ───────────────────────────────────────────────────
|
||||
|
||||
/** Convert µT to display token amount (0.xxx T). */
|
||||
export function formatFee(feeUT: number): string {
|
||||
const t = feeUT / 1_000_000;
|
||||
if (t < 0.001) return `${feeUT} µT`;
|
||||
return `${t.toFixed(4)} T`;
|
||||
}
|
||||
|
||||
/** Relative time formatter, Twitter-like ("5m", "3h", "Dec 15"). */
|
||||
export function formatRelativeTime(unixSeconds: number): string {
|
||||
const now = Date.now() / 1000;
|
||||
const delta = now - unixSeconds;
|
||||
if (delta < 60) return 'just now';
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h`;
|
||||
if (delta < 7 * 86400) return `${Math.floor(delta / 86400)}d`;
|
||||
const d = new Date(unixSeconds * 1000);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/** Compact count formatter ("1.2K", "3.4M"). */
|
||||
export function formatCount(n: number): string {
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}K`;
|
||||
return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
// Prevent unused-import lint when nothing else touches signHex.
|
||||
export const _feedSignRaw = signHex;
|
||||
145
client-app/lib/forwardPost.ts
Normal file
145
client-app/lib/forwardPost.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Forward a feed post into a direct chat as a "post reference" message.
|
||||
*
|
||||
* What the receiver sees
|
||||
* ----------------------
|
||||
* A special chat bubble rendering a compact card:
|
||||
* [avatar] @author "post excerpt…" [📷 if has image]
|
||||
*
|
||||
* Tapping the card opens the full post detail. Design is modelled on
|
||||
* VK/Twitter's "shared post" embed: visually distinct from a plain
|
||||
* message so the user sees at a glance that this came from the feed,
|
||||
* not from the sender directly.
|
||||
*
|
||||
* Transport
|
||||
* ---------
|
||||
* Same encrypted envelope as a normal chat message. The payload is
|
||||
* plaintext JSON with a discriminator:
|
||||
*
|
||||
* { "kind": "post_ref", "post_id": "...", "author": "...",
|
||||
* "excerpt": "first 120 chars of body", "has_image": true }
|
||||
*
|
||||
* The receiver's useMessages / useGlobalInbox hooks detect the JSON
|
||||
* shape after decryption and assign it to Message.postRef for rendering.
|
||||
* Plain-text messages stay wrapped in the same envelope format — the
|
||||
* only difference is whether the decrypted body parses as our JSON
|
||||
* schema.
|
||||
*/
|
||||
|
||||
import { encryptMessage } from './crypto';
|
||||
import { sendEnvelope } from './api';
|
||||
import { appendMessage } from './storage';
|
||||
import { useStore } from './store';
|
||||
import { randomId } from './utils';
|
||||
import type { Contact, Message } from './types';
|
||||
import type { FeedPostItem } from './feed';
|
||||
|
||||
const POST_REF_MARKER = 'dchain-post-ref';
|
||||
const EXCERPT_MAX = 120;
|
||||
|
||||
export interface PostRefPayload {
|
||||
kind: typeof POST_REF_MARKER;
|
||||
post_id: string;
|
||||
author: string;
|
||||
excerpt: string;
|
||||
has_image: boolean;
|
||||
}
|
||||
|
||||
/** Serialise a post ref for the wire. */
|
||||
export function encodePostRef(post: FeedPostItem): string {
|
||||
const payload: PostRefPayload = {
|
||||
kind: POST_REF_MARKER,
|
||||
post_id: post.post_id,
|
||||
author: post.author,
|
||||
excerpt: truncate(post.content, EXCERPT_MAX),
|
||||
has_image: !!post.has_attachment,
|
||||
};
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse an incoming plaintext message as a post reference.
|
||||
* Returns null if the payload isn't the expected shape — the caller
|
||||
* then treats it as a normal text message.
|
||||
*/
|
||||
export function tryParsePostRef(plaintext: string): PostRefPayload | null {
|
||||
const trimmed = plaintext.trim();
|
||||
if (!trimmed.startsWith('{')) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Partial<PostRefPayload>;
|
||||
if (parsed.kind !== POST_REF_MARKER) return null;
|
||||
if (!parsed.post_id || !parsed.author) return null;
|
||||
return {
|
||||
kind: POST_REF_MARKER,
|
||||
post_id: String(parsed.post_id),
|
||||
author: String(parsed.author),
|
||||
excerpt: String(parsed.excerpt ?? ''),
|
||||
has_image: !!parsed.has_image,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward `post` to each of the given contacts as a post-ref message.
|
||||
* Creates a fresh envelope per recipient (can't fan-out a single
|
||||
* ciphertext — each recipient's x25519 key seals differently) and
|
||||
* drops a mirrored Message into our local chat history so the user
|
||||
* sees the share in their own view too.
|
||||
*
|
||||
* Contacts without an x25519 public key are skipped with a warning
|
||||
* instead of failing the whole batch.
|
||||
*/
|
||||
export async function forwardPostToContacts(params: {
|
||||
post: FeedPostItem;
|
||||
contacts: Contact[];
|
||||
keyFile: { pub_key: string; priv_key: string; x25519_pub: string; x25519_priv: string };
|
||||
}): Promise<{ ok: number; failed: number }> {
|
||||
const { post, contacts, keyFile } = params;
|
||||
const appendMsg = useStore.getState().appendMessage;
|
||||
const body = encodePostRef(post);
|
||||
|
||||
let ok = 0, failed = 0;
|
||||
for (const c of contacts) {
|
||||
if (!c.x25519Pub) { failed++; continue; }
|
||||
try {
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
body, keyFile.x25519_priv, c.x25519Pub,
|
||||
);
|
||||
await sendEnvelope({
|
||||
senderPub: keyFile.x25519_pub,
|
||||
recipientPub: c.x25519Pub,
|
||||
senderEd25519Pub: keyFile.pub_key,
|
||||
nonce, ciphertext,
|
||||
});
|
||||
|
||||
// Mirror into local history so the sender sees "you shared this".
|
||||
const mirrored: Message = {
|
||||
id: randomId(),
|
||||
from: keyFile.x25519_pub,
|
||||
text: '', // postRef carries all the content
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
mine: true,
|
||||
postRef: {
|
||||
postID: post.post_id,
|
||||
author: post.author,
|
||||
excerpt: truncate(post.content, EXCERPT_MAX),
|
||||
hasImage: !!post.has_attachment,
|
||||
},
|
||||
};
|
||||
appendMsg(c.address, mirrored);
|
||||
await appendMessage(c.address, mirrored);
|
||||
ok++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { ok, failed };
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
if (!s) return '';
|
||||
if (s.length <= n) return s;
|
||||
return s.slice(0, n).trimEnd() + '…';
|
||||
}
|
||||
101
client-app/lib/storage.ts
Normal file
101
client-app/lib/storage.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Persistent storage for keys and app settings.
|
||||
* On mobile: expo-secure-store for key material, AsyncStorage for settings.
|
||||
* On web: falls back to localStorage (dev only).
|
||||
*/
|
||||
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { KeyFile, Contact, NodeSettings } from './types';
|
||||
|
||||
// ─── Keys ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const KEYFILE_KEY = 'dchain_keyfile';
|
||||
const CONTACTS_KEY = 'dchain_contacts';
|
||||
const SETTINGS_KEY = 'dchain_settings';
|
||||
const CHATS_KEY = 'dchain_chats';
|
||||
|
||||
/** Save the key file in secure storage (encrypted on device). */
|
||||
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
||||
await SecureStore.setItemAsync(KEYFILE_KEY, JSON.stringify(kf));
|
||||
}
|
||||
|
||||
/** Load key file. Returns null if not set. */
|
||||
export async function loadKeyFile(): Promise<KeyFile | null> {
|
||||
const raw = await SecureStore.getItemAsync(KEYFILE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as KeyFile;
|
||||
}
|
||||
|
||||
/** Delete key file (logout / factory reset). */
|
||||
export async function deleteKeyFile(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(KEYFILE_KEY);
|
||||
}
|
||||
|
||||
// ─── Node settings ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_SETTINGS: NodeSettings = {
|
||||
nodeUrl: 'http://localhost:8081',
|
||||
contractId: '',
|
||||
};
|
||||
|
||||
export async function loadSettings(): Promise<NodeSettings> {
|
||||
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
|
||||
if (!raw) return DEFAULT_SETTINGS;
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
||||
}
|
||||
|
||||
export async function saveSettings(s: Partial<NodeSettings>): Promise<void> {
|
||||
const current = await loadSettings();
|
||||
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...current, ...s }));
|
||||
}
|
||||
|
||||
// ─── Contacts ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function loadContacts(): Promise<Contact[]> {
|
||||
const raw = await AsyncStorage.getItem(CONTACTS_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as Contact[];
|
||||
}
|
||||
|
||||
export async function saveContact(c: Contact): Promise<void> {
|
||||
const contacts = await loadContacts();
|
||||
const idx = contacts.findIndex(x => x.address === c.address);
|
||||
if (idx >= 0) contacts[idx] = c;
|
||||
else contacts.push(c);
|
||||
await AsyncStorage.setItem(CONTACTS_KEY, JSON.stringify(contacts));
|
||||
}
|
||||
|
||||
export async function deleteContact(address: string): Promise<void> {
|
||||
const contacts = await loadContacts();
|
||||
await AsyncStorage.setItem(
|
||||
CONTACTS_KEY,
|
||||
JSON.stringify(contacts.filter(c => c.address !== address)),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Message cache (per-chat local store) ────────────────────────────────────
|
||||
|
||||
export interface CachedMessage {
|
||||
id: string;
|
||||
from: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
mine: boolean;
|
||||
}
|
||||
|
||||
export async function loadMessages(chatId: string): Promise<CachedMessage[]> {
|
||||
const raw = await AsyncStorage.getItem(`${CHATS_KEY}_${chatId}`);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as CachedMessage[];
|
||||
}
|
||||
|
||||
export async function appendMessage(chatId: string, msg: CachedMessage): Promise<void> {
|
||||
const msgs = await loadMessages(chatId);
|
||||
// Deduplicate by id
|
||||
if (msgs.find(m => m.id === msg.id)) return;
|
||||
msgs.push(msg);
|
||||
// Keep last 500 messages per chat
|
||||
const trimmed = msgs.slice(-500);
|
||||
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
|
||||
}
|
||||
128
client-app/lib/store.ts
Normal file
128
client-app/lib/store.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Global app state via Zustand.
|
||||
* Keeps runtime state; persistent data lives in storage.ts.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { KeyFile, Contact, Chat, Message, ContactRequest, NodeSettings } from './types';
|
||||
|
||||
interface AppState {
|
||||
// Identity
|
||||
keyFile: KeyFile | null;
|
||||
username: string | null;
|
||||
setKeyFile: (kf: KeyFile | null) => void;
|
||||
setUsername: (u: string | null) => void;
|
||||
|
||||
// Node settings
|
||||
settings: NodeSettings;
|
||||
setSettings: (s: Partial<NodeSettings>) => void;
|
||||
|
||||
// Contacts
|
||||
contacts: Contact[];
|
||||
setContacts: (contacts: Contact[]) => void;
|
||||
upsertContact: (c: Contact) => void;
|
||||
|
||||
// Chats (derived from contacts + messages)
|
||||
chats: Chat[];
|
||||
setChats: (chats: Chat[]) => void;
|
||||
|
||||
// Active chat messages
|
||||
messages: Record<string, Message[]>; // key: contactAddress
|
||||
setMessages: (chatId: string, msgs: Message[]) => void;
|
||||
appendMessage: (chatId: string, msg: Message) => void;
|
||||
|
||||
// Contact requests (pending)
|
||||
requests: ContactRequest[];
|
||||
setRequests: (reqs: ContactRequest[]) => void;
|
||||
|
||||
// Balance
|
||||
balance: number;
|
||||
setBalance: (b: number) => void;
|
||||
|
||||
// Loading / error states
|
||||
loading: boolean;
|
||||
setLoading: (v: boolean) => void;
|
||||
error: string | null;
|
||||
setError: (e: string | null) => void;
|
||||
|
||||
// Nonce cache (to avoid refetching)
|
||||
nonce: number;
|
||||
setNonce: (n: number) => void;
|
||||
|
||||
// Per-contact unread counter (reset on chat open, bumped on peer msg arrive).
|
||||
// Keyed by contactAddress (Ed25519 pubkey hex).
|
||||
unreadByContact: Record<string, number>;
|
||||
incrementUnread: (contactAddress: string) => void;
|
||||
clearUnread: (contactAddress: string) => void;
|
||||
/** Bootstrap state: `true` after initial loadKeyFile + loadSettings done. */
|
||||
booted: boolean;
|
||||
setBooted: (b: boolean) => void;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
keyFile: null,
|
||||
username: null,
|
||||
setKeyFile: (kf) => set({ keyFile: kf }),
|
||||
setUsername: (u) => set({ username: u }),
|
||||
|
||||
settings: {
|
||||
nodeUrl: 'http://localhost:8081',
|
||||
contractId: '',
|
||||
},
|
||||
setSettings: (s) => set(state => ({ settings: { ...state.settings, ...s } })),
|
||||
|
||||
contacts: [],
|
||||
setContacts: (contacts) => set({ contacts }),
|
||||
upsertContact: (c) => set(state => {
|
||||
const idx = state.contacts.findIndex(x => x.address === c.address);
|
||||
if (idx >= 0) {
|
||||
const updated = [...state.contacts];
|
||||
updated[idx] = c;
|
||||
return { contacts: updated };
|
||||
}
|
||||
return { contacts: [...state.contacts, c] };
|
||||
}),
|
||||
|
||||
chats: [],
|
||||
setChats: (chats) => set({ chats }),
|
||||
|
||||
messages: {},
|
||||
setMessages: (chatId, msgs) => set(state => ({
|
||||
messages: { ...state.messages, [chatId]: msgs },
|
||||
})),
|
||||
appendMessage: (chatId, msg) => set(state => {
|
||||
const current = state.messages[chatId] ?? [];
|
||||
if (current.find(m => m.id === msg.id)) return {};
|
||||
return { messages: { ...state.messages, [chatId]: [...current, msg] } };
|
||||
}),
|
||||
|
||||
requests: [],
|
||||
setRequests: (reqs) => set({ requests: reqs }),
|
||||
|
||||
balance: 0,
|
||||
setBalance: (b) => set({ balance: b }),
|
||||
|
||||
loading: false,
|
||||
setLoading: (v) => set({ loading: v }),
|
||||
error: null,
|
||||
setError: (e) => set({ error: e }),
|
||||
|
||||
nonce: 0,
|
||||
setNonce: (n) => set({ nonce: n }),
|
||||
|
||||
unreadByContact: {},
|
||||
incrementUnread: (addr) => set(state => ({
|
||||
unreadByContact: {
|
||||
...state.unreadByContact,
|
||||
[addr]: (state.unreadByContact[addr] ?? 0) + 1,
|
||||
},
|
||||
})),
|
||||
clearUnread: (addr) => set(state => {
|
||||
if (!state.unreadByContact[addr]) return {};
|
||||
const next = { ...state.unreadByContact };
|
||||
delete next[addr];
|
||||
return { unreadByContact: next };
|
||||
}),
|
||||
booted: false,
|
||||
setBooted: (b) => set({ booted: b }),
|
||||
}));
|
||||
165
client-app/lib/types.ts
Normal file
165
client-app/lib/types.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// ─── Key material ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface KeyFile {
|
||||
pub_key: string; // hex Ed25519 public key (32 bytes)
|
||||
priv_key: string; // hex Ed25519 private key (64 bytes)
|
||||
x25519_pub: string; // hex X25519 public key (32 bytes)
|
||||
x25519_priv: string; // hex X25519 private key (32 bytes)
|
||||
}
|
||||
|
||||
// ─── Contact ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Тип беседы в v2.0.0 — только direct (1:1 E2E чат). Каналы убраны в
|
||||
* пользу публичной ленты (см. lib/feed.ts). Поле `kind` осталось ради
|
||||
* обратной совместимости со старыми записями в AsyncStorage; новые
|
||||
* контакты не пишут его.
|
||||
*/
|
||||
export type ContactKind = 'direct' | 'group';
|
||||
|
||||
export interface Contact {
|
||||
address: string; // Ed25519 pubkey hex — blockchain address
|
||||
x25519Pub: string; // X25519 pubkey hex — encryption key
|
||||
username?: string; // @name from registry contract
|
||||
alias?: string; // local nickname
|
||||
addedAt: number; // unix ms
|
||||
/** Legacy field (kept for backward compat with existing AsyncStorage). */
|
||||
kind?: ContactKind;
|
||||
/** Количество непрочитанных — опционально, проставляется WS read-receipt'ами. */
|
||||
unread?: number;
|
||||
}
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Envelope {
|
||||
/** sha256(nonce||ciphertext)[:16] hex — stable server-assigned id. */
|
||||
id: string;
|
||||
sender_pub: string; // X25519 hex
|
||||
recipient_pub: string; // X25519 hex
|
||||
nonce: string; // hex 24 bytes
|
||||
ciphertext: string; // hex NaCl box
|
||||
timestamp: number; // unix seconds (server's sent_at, normalised client-side)
|
||||
}
|
||||
|
||||
/**
|
||||
* Вложение к сообщению. MVP — хранится как URI на локальной файловой
|
||||
* системе клиента (expo-image-picker / expo-document-picker / expo-av
|
||||
* возвращают именно такие URI). Wire-формат для передачи attachment'ов
|
||||
* через relay-envelope ещё не финализирован — пока этот тип для UI'а и
|
||||
* локального отображения.
|
||||
*
|
||||
* Формат по kind:
|
||||
* image — width/height опциональны (image-picker их отдаёт)
|
||||
* video — same + duration в секундах
|
||||
* voice — duration в секундах, нет дизайна превью кроме waveform-stub
|
||||
* file — name + size в байтах, тип через mime
|
||||
*/
|
||||
export type AttachmentKind = 'image' | 'video' | 'voice' | 'file';
|
||||
|
||||
export interface Attachment {
|
||||
kind: AttachmentKind;
|
||||
uri: string; // локальный file:// URI или https:// (incoming decoded)
|
||||
mime?: string; // 'image/jpeg', 'application/pdf', …
|
||||
name?: string; // имя файла (для file)
|
||||
size?: number; // байты (для file)
|
||||
width?: number; // image/video
|
||||
height?: number; // image/video
|
||||
duration?: number; // seconds (video/voice)
|
||||
/** Для kind='video' — рендерить как круглое видео-сообщение (Telegram-style). */
|
||||
circle?: boolean;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
from: string; // X25519 pubkey of sender
|
||||
text: string;
|
||||
timestamp: number;
|
||||
mine: boolean;
|
||||
/** true если сообщение было отредактировано. Показываем "Edited" в углу. */
|
||||
edited?: boolean;
|
||||
/**
|
||||
* Для mine=true — true если получатель его прочитал.
|
||||
* UI: пустая галочка = отправлено, filled = прочитано.
|
||||
* Для mine=false не используется.
|
||||
*/
|
||||
read?: boolean;
|
||||
/** Одно вложение. Multi-attach пока не поддерживается — будет массивом. */
|
||||
attachment?: Attachment;
|
||||
/**
|
||||
* Если сообщение — ответ на другое, здесь лежит ссылка + short preview
|
||||
* того оригинала. id используется для scroll-to + highlight; text/author
|
||||
* — для рендера "quoted"-блока внутри текущего bubble'а без запроса
|
||||
* исходного сообщения (копия замороженная в момент ответа).
|
||||
*/
|
||||
replyTo?: {
|
||||
id: string;
|
||||
text: string;
|
||||
author: string; // @username / alias / "you"
|
||||
};
|
||||
/**
|
||||
* Ссылка на пост из ленты. Если присутствует — сообщение рендерится как
|
||||
* карточка-превью поста (аватар автора, хэндл, текст-excerpt, картинка
|
||||
* если есть). Тап на карточку → открывается полный пост. Сценарий — юзер
|
||||
* нажал Share в ленте и отправил пост в этот чат/ЛС.
|
||||
*
|
||||
* Содержимое (автор, excerpt) дублируется тут, чтобы карточку можно было
|
||||
* рендерить оффлайн / когда у хостящей релей-ноды пропал пост — чат
|
||||
* остаётся читаемым независимо от жизни ленты.
|
||||
*/
|
||||
postRef?: {
|
||||
postID: string;
|
||||
author: string; // Ed25519 hex — для чипа имени в карточке
|
||||
excerpt: string; // первые 120 символов тела поста
|
||||
hasImage?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Chat ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Chat {
|
||||
contactAddress: string; // Ed25519 pubkey hex
|
||||
contactX25519: string; // X25519 pubkey hex
|
||||
username?: string;
|
||||
alias?: string;
|
||||
lastMessage?: string;
|
||||
lastTime?: number;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
// ─── Contact request ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ContactRequest {
|
||||
from: string; // Ed25519 pubkey hex
|
||||
x25519Pub: string; // X25519 pubkey hex; empty until fetched from identity
|
||||
username?: string;
|
||||
intro: string; // plaintext intro (stored on-chain)
|
||||
timestamp: number;
|
||||
txHash: string;
|
||||
}
|
||||
|
||||
// ─── Transaction ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TxRecord {
|
||||
hash: string;
|
||||
type: string;
|
||||
from: string;
|
||||
to?: string;
|
||||
amount?: number;
|
||||
fee: number;
|
||||
timestamp: number;
|
||||
status: 'confirmed' | 'pending';
|
||||
}
|
||||
|
||||
// ─── Node info ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface NetStats {
|
||||
total_blocks: number;
|
||||
total_txs: number;
|
||||
peer_count: number;
|
||||
chain_id: string;
|
||||
}
|
||||
|
||||
export interface NodeSettings {
|
||||
nodeUrl: string;
|
||||
contractId: string; // username_registry contract
|
||||
}
|
||||
35
client-app/lib/utils.ts
Normal file
35
client-app/lib/utils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Format µT amount to human-readable string */
|
||||
export function formatAmount(microTokens: number | undefined | null): string {
|
||||
if (microTokens == null) return '—';
|
||||
if (microTokens >= 1_000_000) return `${(microTokens / 1_000_000).toFixed(2)} T`;
|
||||
if (microTokens >= 1_000) return `${(microTokens / 1_000).toFixed(1)} mT`;
|
||||
return `${microTokens} µT`;
|
||||
}
|
||||
|
||||
/** Format unix seconds to relative time */
|
||||
export function relativeTime(unixSeconds: number | undefined | null): string {
|
||||
if (!unixSeconds) return '';
|
||||
const diff = Date.now() / 1000 - unixSeconds;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return new Date(unixSeconds * 1000).toLocaleDateString();
|
||||
}
|
||||
|
||||
/** Format unix seconds to HH:MM */
|
||||
export function formatTime(unixSeconds: number | undefined | null): string {
|
||||
if (!unixSeconds) return '';
|
||||
return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** Generate a random nonce string */
|
||||
export function randomId(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
401
client-app/lib/ws.ts
Normal file
401
client-app/lib/ws.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* DChain WebSocket client — replaces balance / inbox / contacts polling with
|
||||
* server-push. Matches `node/ws.go` exactly.
|
||||
*
|
||||
* Usage:
|
||||
* const ws = getWSClient();
|
||||
* ws.connect(); // idempotent
|
||||
* const off = ws.subscribe('addr:ab12…', ev => { ... });
|
||||
* // later:
|
||||
* off(); // unsubscribe + stop handler
|
||||
* ws.disconnect();
|
||||
*
|
||||
* Features:
|
||||
* - Auto-reconnect with exponential backoff (1s → 30s cap).
|
||||
* - Re-subscribes all topics after a reconnect.
|
||||
* - `hello` frame exposes chain_id + tip_height for connection state UI.
|
||||
* - Degrades silently if the endpoint returns 501 (old node without WS).
|
||||
*/
|
||||
|
||||
import { getNodeUrl, onNodeUrlChange } from './api';
|
||||
import { sign } from './crypto';
|
||||
|
||||
export type WSEventName =
|
||||
| 'hello'
|
||||
| 'block'
|
||||
| 'tx'
|
||||
| 'contract_log'
|
||||
| 'inbox'
|
||||
| 'typing'
|
||||
| 'pong'
|
||||
| 'error'
|
||||
| 'subscribed'
|
||||
| 'submit_ack'
|
||||
| 'lag';
|
||||
|
||||
export interface WSFrame {
|
||||
event: WSEventName;
|
||||
data?: unknown;
|
||||
topic?: string;
|
||||
msg?: string;
|
||||
chain_id?: string;
|
||||
tip_height?: number;
|
||||
/** Server-issued nonce in the hello frame; client signs it for auth. */
|
||||
auth_nonce?: string;
|
||||
// submit_ack fields
|
||||
id?: string;
|
||||
status?: 'accepted' | 'rejected';
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
type Handler = (frame: WSFrame) => void;
|
||||
|
||||
class WSClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string | null = null;
|
||||
private reconnectMs: number = 1000;
|
||||
private closing: boolean = false;
|
||||
|
||||
/** topic → set of handlers interested in frames for this topic */
|
||||
private handlers: Map<string, Set<Handler>> = new Map();
|
||||
/** topics we want the server to push — replayed on every reconnect */
|
||||
private wantedTopics: Set<string> = new Set();
|
||||
|
||||
private connectionListeners: Set<(ok: boolean, err?: string) => void> = new Set();
|
||||
private helloInfo: { chainId?: string; tipHeight?: number; authNonce?: string } = {};
|
||||
|
||||
/**
|
||||
* Credentials used for auto-auth on every (re)connect. The signer runs on
|
||||
* each hello frame so scoped subscriptions (addr:*, inbox:*) are accepted.
|
||||
* Without these, subscribe requests to scoped topics get rejected by the
|
||||
* server; global topics (blocks, tx, …) still work unauthenticated.
|
||||
*/
|
||||
private authCreds: { pubKey: string; privKey: string } | null = null;
|
||||
|
||||
/** Current connection state (read-only for UI). */
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
getHelloInfo(): { chainId?: string; tipHeight?: number } {
|
||||
return this.helloInfo;
|
||||
}
|
||||
|
||||
/** Subscribe to a connection-state listener — fires on connect/disconnect. */
|
||||
onConnectionChange(cb: (ok: boolean, err?: string) => void): () => void {
|
||||
this.connectionListeners.add(cb);
|
||||
return () => this.connectionListeners.delete(cb) as unknown as void;
|
||||
}
|
||||
private fireConnectionChange(ok: boolean, err?: string) {
|
||||
for (const cb of this.connectionListeners) {
|
||||
try { cb(ok, err); } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Ed25519 keypair used for auto-auth. The signer runs on each
|
||||
* (re)connect against the server-issued nonce so the connection is bound
|
||||
* to this identity. Pass null to disable auth (only global topics will
|
||||
* work — useful for observers).
|
||||
*/
|
||||
setAuthCreds(creds: { pubKey: string; privKey: string } | null): void {
|
||||
this.authCreds = creds;
|
||||
// If we're already connected, kick off auth immediately.
|
||||
if (creds && this.isConnected() && this.helloInfo.authNonce) {
|
||||
this.sendAuth(this.helloInfo.authNonce);
|
||||
}
|
||||
}
|
||||
|
||||
/** Idempotent connect. Call once on app boot. */
|
||||
connect(): void {
|
||||
const base = getNodeUrl();
|
||||
const newURL = base.replace(/^http/, 'ws') + '/api/ws';
|
||||
if (this.ws) {
|
||||
const state = this.ws.readyState;
|
||||
// Already pointing at this URL and connected / connecting — nothing to do.
|
||||
if (this.url === newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
// URL changed (operator flipped nodes in settings) — tear down and
|
||||
// re-dial. Existing subscriptions live in wantedTopics and will be
|
||||
// replayed after the new onopen fires.
|
||||
if (this.url !== newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
|
||||
try { this.ws.close(); } catch { /* noop */ }
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
this.closing = false;
|
||||
this.url = newURL;
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
} catch (e: any) {
|
||||
this.fireConnectionChange(false, e?.message ?? 'ws construct failed');
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectMs = 1000; // reset backoff
|
||||
this.fireConnectionChange(true);
|
||||
// Replay all wanted subscriptions.
|
||||
for (const topic of this.wantedTopics) {
|
||||
this.sendRaw({ op: 'subscribe', topic });
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (ev) => {
|
||||
let frame: WSFrame;
|
||||
try {
|
||||
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (frame.event === 'hello') {
|
||||
this.helloInfo = {
|
||||
chainId: frame.chain_id,
|
||||
tipHeight: frame.tip_height,
|
||||
authNonce: frame.auth_nonce,
|
||||
};
|
||||
// Auto-authenticate if credentials are set. The server binds this
|
||||
// connection to the signed pubkey so scoped subscriptions (addr:*,
|
||||
// inbox:*) get through. On reconnect a new nonce is issued, so the
|
||||
// auth dance repeats transparently.
|
||||
if (this.authCreds && frame.auth_nonce) {
|
||||
this.sendAuth(frame.auth_nonce);
|
||||
}
|
||||
}
|
||||
// Dispatch to all handlers for any topic that could match this frame.
|
||||
// We use a simple predicate: look at the frame to decide which topics it
|
||||
// was fanned out to, then fire every matching handler.
|
||||
for (const topic of this.topicsForFrame(frame)) {
|
||||
const set = this.handlers.get(topic);
|
||||
if (!set) continue;
|
||||
for (const h of set) {
|
||||
try { h(frame); } catch (e) { console.warn('[ws] handler error', e); }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (e: any) => {
|
||||
this.fireConnectionChange(false, 'ws error');
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.ws = null;
|
||||
this.fireConnectionChange(false);
|
||||
if (!this.closing) this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.closing = true;
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch { /* noop */ }
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a topic. Returns an `off()` function that unsubscribes AND
|
||||
* removes the handler. If multiple callers subscribe to the same topic,
|
||||
* the server is only notified on the first and last caller.
|
||||
*/
|
||||
subscribe(topic: string, handler: Handler): () => void {
|
||||
let set = this.handlers.get(topic);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.handlers.set(topic, set);
|
||||
}
|
||||
set.add(handler);
|
||||
|
||||
// Notify server only on the first handler for this topic.
|
||||
if (!this.wantedTopics.has(topic)) {
|
||||
this.wantedTopics.add(topic);
|
||||
if (this.isConnected()) {
|
||||
this.sendRaw({ op: 'subscribe', topic });
|
||||
} else {
|
||||
this.connect(); // lazy-connect on first subscribe
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
const s = this.handlers.get(topic);
|
||||
if (!s) return;
|
||||
s.delete(handler);
|
||||
if (s.size === 0) {
|
||||
this.handlers.delete(topic);
|
||||
this.wantedTopics.delete(topic);
|
||||
if (this.isConnected()) {
|
||||
this.sendRaw({ op: 'unsubscribe', topic });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Force a keepalive ping. Useful for debugging. */
|
||||
ping(): void {
|
||||
this.sendRaw({ op: 'ping' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a typing indicator to another user. Recipient is their X25519 pubkey
|
||||
* (the one used for inbox encryption). Ephemeral — no ack, no retry; just
|
||||
* fire and forget. Call on each keystroke but throttle to once per 2-3s
|
||||
* at the caller side so we don't flood the WS with frames.
|
||||
*/
|
||||
sendTyping(recipientX25519: string): void {
|
||||
if (!this.isConnected()) return;
|
||||
try {
|
||||
this.ws!.send(JSON.stringify({ op: 'typing', to: recipientX25519 }));
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a signed transaction over the WebSocket and resolve once the
|
||||
* server returns a `submit_ack`. Saves the HTTP round-trip on every tx
|
||||
* and gives the UI immediate accept/reject feedback.
|
||||
*
|
||||
* Rejects if:
|
||||
* - WS is not connected (caller should fall back to HTTP)
|
||||
* - Server returns `status: "rejected"` — `reason` is surfaced as error msg
|
||||
* - No ack within `timeoutMs` (default 10 s)
|
||||
*/
|
||||
submitTx(tx: unknown, timeoutMs = 10_000): Promise<{ id: string }> {
|
||||
if (!this.isConnected()) {
|
||||
return Promise.reject(new Error('WS not connected'));
|
||||
}
|
||||
const reqId = 's_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const off = this.subscribe('$system', (frame) => {
|
||||
if (frame.event !== 'submit_ack' || frame.id !== reqId) return;
|
||||
off();
|
||||
clearTimeout(timer);
|
||||
if (frame.status === 'accepted') {
|
||||
// `msg` carries the server-confirmed tx id.
|
||||
resolve({ id: typeof frame.msg === 'string' ? frame.msg : '' });
|
||||
} else {
|
||||
reject(new Error(frame.reason || 'submit_tx rejected'));
|
||||
}
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
off();
|
||||
reject(new Error('submit_tx timeout (' + timeoutMs + 'ms)'));
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
this.ws!.send(JSON.stringify({ op: 'submit_tx', tx, id: reqId }));
|
||||
} catch (e: any) {
|
||||
off();
|
||||
clearTimeout(timer);
|
||||
reject(new Error('WS send failed: ' + (e?.message ?? 'unknown')));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── internals ───────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.closing) return;
|
||||
const delay = Math.min(this.reconnectMs, 30_000);
|
||||
this.reconnectMs = Math.min(this.reconnectMs * 2, 30_000);
|
||||
setTimeout(() => {
|
||||
if (!this.closing) this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private sendRaw(cmd: { op: string; topic?: string }): void {
|
||||
if (!this.isConnected()) return;
|
||||
try { this.ws!.send(JSON.stringify(cmd)); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the server nonce with our Ed25519 private key and send the `auth`
|
||||
* op. The server binds this connection to `authCreds.pubKey`; subsequent
|
||||
* subscribe requests to `addr:<pubKey>` / `inbox:<my_x25519>` are accepted.
|
||||
*/
|
||||
private sendAuth(nonce: string): void {
|
||||
if (!this.authCreds || !this.isConnected()) return;
|
||||
try {
|
||||
const bytes = new TextEncoder().encode(nonce);
|
||||
const sig = sign(bytes, this.authCreds.privKey);
|
||||
this.ws!.send(JSON.stringify({
|
||||
op: 'auth',
|
||||
pubkey: this.authCreds.pubKey,
|
||||
sig,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn('[ws] auth send failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an incoming frame, enumerate every topic that handlers could have
|
||||
* subscribed to and still be interested. This mirrors the fan-out logic in
|
||||
* node/ws.go:EmitBlock / EmitTx / EmitContractLog.
|
||||
*/
|
||||
private topicsForFrame(frame: WSFrame): string[] {
|
||||
switch (frame.event) {
|
||||
case 'block':
|
||||
return ['blocks'];
|
||||
case 'tx': {
|
||||
const d = frame.data as { from?: string; to?: string } | undefined;
|
||||
const topics = ['tx'];
|
||||
if (d?.from) topics.push('addr:' + d.from);
|
||||
if (d?.to && d.to !== d.from) topics.push('addr:' + d.to);
|
||||
return topics;
|
||||
}
|
||||
case 'contract_log': {
|
||||
const d = frame.data as { contract_id?: string } | undefined;
|
||||
const topics = ['contract_log'];
|
||||
if (d?.contract_id) topics.push('contract:' + d.contract_id);
|
||||
return topics;
|
||||
}
|
||||
case 'inbox': {
|
||||
// Node fans inbox events to `inbox` + `inbox:<recipient_x25519>`;
|
||||
// we mirror that here so both firehose listeners and address-scoped
|
||||
// subscribers see the event.
|
||||
const d = frame.data as { recipient_pub?: string } | undefined;
|
||||
const topics = ['inbox'];
|
||||
if (d?.recipient_pub) topics.push('inbox:' + d.recipient_pub);
|
||||
return topics;
|
||||
}
|
||||
case 'typing': {
|
||||
// Server fans to `typing:<to>` only (the recipient).
|
||||
const d = frame.data as { to?: string } | undefined;
|
||||
return d?.to ? ['typing:' + d.to] : [];
|
||||
}
|
||||
// Control-plane events — no topic fan-out; use a pseudo-topic so UI
|
||||
// can listen for them via subscribe('$system', ...).
|
||||
case 'hello':
|
||||
case 'pong':
|
||||
case 'error':
|
||||
case 'subscribed':
|
||||
case 'submit_ack':
|
||||
case 'lag':
|
||||
return ['$system'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _singleton: WSClient | null = null;
|
||||
|
||||
/**
|
||||
* Return the app-wide WebSocket client. Safe to call from any component;
|
||||
* `.connect()` is idempotent.
|
||||
*
|
||||
* On first creation we register a node-URL listener so flipping the node
|
||||
* in Settings tears down the existing socket and dials the new one — the
|
||||
* user's active subscriptions (addr:*, inbox:*) replay automatically.
|
||||
*/
|
||||
export function getWSClient(): WSClient {
|
||||
if (!_singleton) {
|
||||
_singleton = new WSClient();
|
||||
onNodeUrlChange(() => {
|
||||
// Fire and forget — connect() is idempotent and handles stale URLs.
|
||||
_singleton!.connect();
|
||||
});
|
||||
}
|
||||
return _singleton;
|
||||
}
|
||||
6
client-app/metro.config.js
Normal file
6
client-app/metro.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
1
client-app/nativewind-env.d.ts
vendored
Normal file
1
client-app/nativewind-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
11482
client-app/package-lock.json
generated
Normal file
11482
client-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
client-app/package.json
Normal file
61
client-app/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "dchain-messenger",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"expo": "~54.0.0",
|
||||
"expo-asset": "~12.0.12",
|
||||
"expo-audio": "~1.1.1",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-video": "~3.0.16",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"nativewind": "^4.1.23",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/react": "~19.1.0",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
35
client-app/tailwind.config.js
Normal file
35
client-app/tailwind.config.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
//
|
||||
// DChain messenger — dark-first palette inspired by the X-style Messages screen.
|
||||
//
|
||||
// `bg` is true black for OLED. `surface` / `surface2` lift for tiles,
|
||||
// inputs and pressed states. `accent` is the icy blue used by the
|
||||
// composer FAB and active filter pills.
|
||||
module.exports = {
|
||||
content: [
|
||||
'./app/**/*.{js,jsx,ts,tsx}',
|
||||
'./components/**/*.{js,jsx,ts,tsx}',
|
||||
'./hooks/**/*.{js,jsx,ts,tsx}',
|
||||
'./lib/**/*.{js,jsx,ts,tsx}',
|
||||
],
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: '#000000',
|
||||
bg2: '#0a0a0a',
|
||||
surface: '#111111',
|
||||
surface2: '#1a1a1a',
|
||||
line: '#1f1f1f',
|
||||
text: '#ffffff',
|
||||
subtext: '#8b8b8b',
|
||||
muted: '#5a5a5a',
|
||||
accent: '#1d9bf0',
|
||||
ok: '#3ba55d',
|
||||
warn: '#f0b35a',
|
||||
err: '#f4212e',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
9
client-app/tsconfig.json
Normal file
9
client-app/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"go-blockchain/consensus"
|
||||
"go-blockchain/economy"
|
||||
"go-blockchain/identity"
|
||||
"go-blockchain/media"
|
||||
"go-blockchain/node"
|
||||
"go-blockchain/node/version"
|
||||
"go-blockchain/p2p"
|
||||
@@ -77,6 +78,10 @@ func main() {
|
||||
registerRelay := flag.Bool("register-relay", envBoolOr("DCHAIN_REGISTER_RELAY", false), "submit REGISTER_RELAY tx on startup (env: DCHAIN_REGISTER_RELAY)")
|
||||
relayFee := flag.Uint64("relay-fee", envUint64Or("DCHAIN_RELAY_FEE", 1_000), "relay fee per message in µT (env: DCHAIN_RELAY_FEE)")
|
||||
mailboxDB := flag.String("mailbox-db", envOr("DCHAIN_MAILBOX_DB", "./mailboxdata"), "BadgerDB directory for relay mailbox (env: DCHAIN_MAILBOX_DB)")
|
||||
feedDB := flag.String("feed-db", envOr("DCHAIN_FEED_DB", "./feeddata"), "BadgerDB directory for social-feed post bodies (env: DCHAIN_FEED_DB)")
|
||||
feedTTLDays := flag.Int("feed-ttl-days", int(envUint64Or("DCHAIN_FEED_TTL_DAYS", 30)), "how long feed posts are retained before auto-eviction (env: DCHAIN_FEED_TTL_DAYS)")
|
||||
mediaSidecarURL := flag.String("media-sidecar-url", envOr("DCHAIN_MEDIA_SIDECAR_URL", ""), "URL of the media scrubber sidecar (FFmpeg-based video/audio re-encoder). Empty = images only (env: DCHAIN_MEDIA_SIDECAR_URL)")
|
||||
allowUnscrubbedVideo := flag.Bool("allow-unscrubbed-video", envBoolOr("DCHAIN_ALLOW_UNSCRUBBED_VIDEO", false), "accept video uploads without server-side metadata scrubbing (only when no sidecar is configured). DANGEROUS — leaves EXIF/GPS/author tags intact (env: DCHAIN_ALLOW_UNSCRUBBED_VIDEO)")
|
||||
govContractID := flag.String("governance-contract", envOr("DCHAIN_GOVERNANCE_CONTRACT", ""), "governance contract ID for dynamic chain parameters (env: DCHAIN_GOVERNANCE_CONTRACT)")
|
||||
joinSeedURL := flag.String("join", envOr("DCHAIN_JOIN", ""), "bootstrap from a running node: comma-separated HTTP URLs (env: DCHAIN_JOIN)")
|
||||
// Observer mode: the node participates in the P2P network, applies
|
||||
@@ -634,6 +639,15 @@ func main() {
|
||||
go mailbox.RunGC()
|
||||
log.Printf("[NODE] relay mailbox: %s", *mailboxDB)
|
||||
|
||||
// --- Feed mailbox (social-feed post bodies, v2.0.0) ---
|
||||
feedTTL := time.Duration(*feedTTLDays) * 24 * time.Hour
|
||||
feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL)
|
||||
if err != nil {
|
||||
log.Fatalf("[NODE] feed mailbox: %v", err)
|
||||
}
|
||||
defer feedMailbox.Close()
|
||||
log.Printf("[NODE] feed mailbox: %s (TTL %d days)", *feedDB, *feedTTLDays)
|
||||
|
||||
// Push-notify bus consumers whenever a fresh envelope lands in the
|
||||
// mailbox. Clients subscribed to `inbox:<my_x25519>` (via WS) get the
|
||||
// event immediately so they no longer need to poll /relay/inbox.
|
||||
@@ -854,8 +868,6 @@ func main() {
|
||||
GetNFT: chain.NFT,
|
||||
GetNFTs: chain.NFTs,
|
||||
NFTsByOwner: chain.NFTsByOwner,
|
||||
GetChannel: chain.Channel,
|
||||
GetChannelMembers: chain.ChannelMembers,
|
||||
Events: sseHub,
|
||||
WS: wsHub,
|
||||
// Onboarding: expose libp2p peers + chain_id so new nodes/clients can
|
||||
@@ -920,6 +932,36 @@ func main() {
|
||||
ContactRequests: func(pubKey string) ([]blockchain.ContactInfo, error) {
|
||||
return chain.ContactRequests(pubKey)
|
||||
},
|
||||
ResolveX25519: func(ed25519PubHex string) string {
|
||||
info, err := chain.Identity(ed25519PubHex)
|
||||
if err != nil || info == nil {
|
||||
return ""
|
||||
}
|
||||
return info.X25519PubKey
|
||||
},
|
||||
}
|
||||
|
||||
// Media scrubber — strips EXIF/GPS/author/camera metadata from every
|
||||
// uploaded image in-process, and forwards video/audio to the FFmpeg
|
||||
// sidecar when configured. Mandatory for all /feed/publish traffic.
|
||||
scrubber := media.NewScrubber(media.SidecarConfig{URL: *mediaSidecarURL})
|
||||
if *mediaSidecarURL != "" {
|
||||
log.Printf("[NODE] media sidecar: %s", *mediaSidecarURL)
|
||||
} else {
|
||||
log.Printf("[NODE] media sidecar: not configured (images scrubbed in-process; video/audio %s)",
|
||||
map[bool]string{true: "stored unscrubbed (DANGEROUS)", false: "rejected"}[*allowUnscrubbedVideo])
|
||||
}
|
||||
|
||||
feedConfig := node.FeedConfig{
|
||||
Mailbox: feedMailbox,
|
||||
HostingRelayPub: id.PubKeyHex(),
|
||||
Scrubber: scrubber,
|
||||
AllowUnscrubbedVideo: *allowUnscrubbedVideo,
|
||||
GetPost: chain.Post,
|
||||
LikeCount: chain.LikeCount,
|
||||
HasLiked: chain.HasLiked,
|
||||
PostsByAuthor: chain.PostsByAuthor,
|
||||
Following: chain.Following,
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -942,6 +984,7 @@ func main() {
|
||||
if err := stats.ListenAndServe(*statsAddr, statsQuery, func(mux *http.ServeMux) {
|
||||
node.RegisterExplorerRoutes(mux, explorerQuery, routeFlags)
|
||||
node.RegisterRelayRoutes(mux, relayConfig)
|
||||
node.RegisterFeedRoutes(mux, feedConfig)
|
||||
|
||||
// POST /api/governance/link — link deployed contracts at runtime.
|
||||
// Body: {"governance": "<id>"}
|
||||
|
||||
35
docker/media-sidecar/Dockerfile
Normal file
35
docker/media-sidecar/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# media-sidecar — FFmpeg-based metadata scrubber for DChain node.
|
||||
#
|
||||
# Build: docker build -t dchain/media-sidecar -f docker/media-sidecar/Dockerfile .
|
||||
# Run: docker run -p 8090:8090 dchain/media-sidecar
|
||||
# Compose: see docker-compose.yml; node points DCHAIN_MEDIA_SIDECAR_URL at it.
|
||||
#
|
||||
# Stage 1 — build a tiny static Go binary.
|
||||
FROM golang:1.22-alpine AS build
|
||||
WORKDIR /src
|
||||
# Copy only what we need (the sidecar main is self-contained, no module
|
||||
# deps on the rest of the repo, so this is a cheap, cache-friendly build).
|
||||
COPY docker/media-sidecar/main.go ./main.go
|
||||
RUN go mod init dchain-media-sidecar 2>/dev/null || true
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/media-sidecar ./main.go
|
||||
|
||||
# Stage 2 — runtime with ffmpeg. Alpine has a lean ffmpeg build (~90 MB
|
||||
# total image, most of it codecs we actually need).
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache ffmpeg ca-certificates \
|
||||
&& addgroup -S dchain && adduser -S -G dchain dchain
|
||||
COPY --from=build /out/media-sidecar /usr/local/bin/media-sidecar
|
||||
|
||||
USER dchain
|
||||
EXPOSE 8090
|
||||
|
||||
# Pin sensible defaults; operator overrides via docker-compose env.
|
||||
ENV LISTEN_ADDR=:8090 \
|
||||
FFMPEG_BIN=ffmpeg \
|
||||
MAX_INPUT_MB=32 \
|
||||
JOB_TIMEOUT_SECS=60
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8090/healthz || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/media-sidecar"]
|
||||
201
docker/media-sidecar/main.go
Normal file
201
docker/media-sidecar/main.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Media scrubber sidecar — tiny HTTP service that re-encodes video/audio
|
||||
// through ffmpeg with all metadata stripped. Runs alongside the DChain
|
||||
// node in docker-compose; the node calls it via DCHAIN_MEDIA_SIDECAR_URL.
|
||||
//
|
||||
// Contract (matches media.Scrubber in the node):
|
||||
//
|
||||
// POST /scrub/video Content-Type: video/* body: raw bytes
|
||||
// → 200, Content-Type: video/mp4, body: cleaned bytes
|
||||
// POST /scrub/audio Content-Type: audio/* body: raw bytes
|
||||
// → 200, Content-Type: audio/ogg, body: cleaned bytes
|
||||
//
|
||||
// ffmpeg flags of note:
|
||||
//
|
||||
// -map_metadata -1 drop ALL metadata streams (title, author, encoder,
|
||||
// GPS location atoms, XMP blocks, etc.)
|
||||
// -map 0:v -map 0:a keep only video and audio streams — dumps attached
|
||||
// pictures, subtitles, data channels that might carry
|
||||
// hidden info
|
||||
// -movflags +faststart
|
||||
// put MOOV atom at the front so clients can start
|
||||
// playback before the full download lands
|
||||
// -c:v libx264 -crf 28 -preset fast
|
||||
// h264 with aggressive-but-not-painful CRF; ~70-80%
|
||||
// size reduction on phone-camera source
|
||||
// -c:a libopus -b:a 64k
|
||||
// opus at 64 kbps is transparent for speech, fine
|
||||
// for music at feed quality
|
||||
//
|
||||
// Environment:
|
||||
//
|
||||
// LISTEN_ADDR default ":8090"
|
||||
// FFMPEG_BIN default "ffmpeg" (must be in PATH)
|
||||
// MAX_INPUT_MB default 32 — reject anything larger pre-ffmpeg
|
||||
// JOB_TIMEOUT_SECS default 60
|
||||
//
|
||||
// The service is deliberately dumb: no queuing, no DB, no state. If you
|
||||
// need higher throughput, run N replicas behind a TCP load balancer.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := envOr("LISTEN_ADDR", ":8090")
|
||||
ffmpegBin := envOr("FFMPEG_BIN", "ffmpeg")
|
||||
maxInputMB := envInt("MAX_INPUT_MB", 32)
|
||||
jobTimeoutSecs := envInt("JOB_TIMEOUT_SECS", 60)
|
||||
|
||||
// Fail fast if ffmpeg is missing — easier to debug at container start
|
||||
// than to surface cryptic errors per-request.
|
||||
if _, err := exec.LookPath(ffmpegBin); err != nil {
|
||||
log.Fatalf("ffmpeg not found in PATH (looked for %q): %v", ffmpegBin, err)
|
||||
}
|
||||
|
||||
srv := &server{
|
||||
ffmpegBin: ffmpegBin,
|
||||
maxInputSize: int64(maxInputMB) * 1024 * 1024,
|
||||
jobTimeout: time.Duration(jobTimeoutSecs) * time.Second,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/scrub/video", srv.scrubVideo)
|
||||
mux.HandleFunc("/scrub/audio", srv.scrubAudio)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
log.Printf("media-sidecar: listening on %s, ffmpeg=%s, max_input=%d MiB, timeout=%ds",
|
||||
addr, ffmpegBin, maxInputMB, jobTimeoutSecs)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("ListenAndServe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type server struct {
|
||||
ffmpegBin string
|
||||
maxInputSize int64
|
||||
jobTimeout time.Duration
|
||||
}
|
||||
|
||||
func (s *server) scrubVideo(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := s.readLimited(r)
|
||||
if err != nil {
|
||||
httpErr(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), s.jobTimeout)
|
||||
defer cancel()
|
||||
// Video path: re-encode with metadata strip, H.264 CRF 28, opus audio.
|
||||
// Output format is MP4 (widest client compatibility).
|
||||
args := []string{
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-i", "pipe:0",
|
||||
"-map", "0:v", "-map", "0:a?",
|
||||
"-map_metadata", "-1",
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "28",
|
||||
"-c:a", "libopus", "-b:a", "64k",
|
||||
"-movflags", "+faststart+frag_keyframe",
|
||||
"-f", "mp4",
|
||||
"pipe:1",
|
||||
}
|
||||
out, ffErr, err := s.runFFmpeg(ctx, args, body)
|
||||
if err != nil {
|
||||
log.Printf("video scrub failed: %v | stderr=%s", err, ffErr)
|
||||
httpErr(w, "ffmpeg failed: "+err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
|
||||
_, _ = w.Write(out)
|
||||
}
|
||||
|
||||
func (s *server) scrubAudio(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := s.readLimited(r)
|
||||
if err != nil {
|
||||
httpErr(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), s.jobTimeout)
|
||||
defer cancel()
|
||||
args := []string{
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-i", "pipe:0",
|
||||
"-vn", "-map", "0:a",
|
||||
"-map_metadata", "-1",
|
||||
"-c:a", "libopus", "-b:a", "64k",
|
||||
"-f", "ogg",
|
||||
"pipe:1",
|
||||
}
|
||||
out, ffErr, err := s.runFFmpeg(ctx, args, body)
|
||||
if err != nil {
|
||||
log.Printf("audio scrub failed: %v | stderr=%s", err, ffErr)
|
||||
httpErr(w, "ffmpeg failed: "+err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/ogg")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
|
||||
_, _ = w.Write(out)
|
||||
}
|
||||
|
||||
func (s *server) runFFmpeg(ctx context.Context, args []string, input []byte) ([]byte, string, error) {
|
||||
cmd := exec.CommandContext(ctx, s.ffmpegBin, args...)
|
||||
cmd.Stdin = bytes.NewReader(input)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return nil, stderr.String(), err
|
||||
}
|
||||
return stdout.Bytes(), stderr.String(), nil
|
||||
}
|
||||
|
||||
func (s *server) readLimited(r *http.Request) ([]byte, error) {
|
||||
if r.Method != http.MethodPost {
|
||||
return nil, fmt.Errorf("method not allowed")
|
||||
}
|
||||
limited := io.LimitReader(r.Body, s.maxInputSize+1)
|
||||
buf, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
if int64(len(buf)) > s.maxInputSize {
|
||||
return nil, fmt.Errorf("input exceeds %d bytes", s.maxInputSize)
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func httpErr(w http.ResponseWriter, msg string, status int) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(msg))
|
||||
}
|
||||
|
||||
func envOr(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
func envInt(k string, d int) int {
|
||||
v := os.Getenv(k)
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return d
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -14,7 +14,8 @@ Swagger UI на `/swagger`. Эти два эндпоинта можно выкл
|
||||
|---------|----------|
|
||||
| [chain.md](chain.md) | Блоки, транзакции, балансы, адреса, netstats, validators |
|
||||
| [contracts.md](contracts.md) | Деплой, вызов, state, логи контрактов |
|
||||
| [relay.md](relay.md) | Relay-mailbox: отправка/приём encrypted envelopes |
|
||||
| [relay.md](relay.md) | Relay-mailbox: 1:1 E2E-шифрованные envelopes |
|
||||
| [feed.md](feed.md) | Социальная лента: публикация постов, лайки, подписки, рекомендации, хэштеги (v2.0.0) |
|
||||
|
||||
## Discovery / metadata (always on)
|
||||
|
||||
|
||||
279
docs/api/feed.md
Normal file
279
docs/api/feed.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# Feed API (v2.0.0)
|
||||
|
||||
REST API для социальной ленты: публикация постов, лента подписок,
|
||||
рекомендации, трендинг, хэштеги. Введено в **v2.0.0**, заменило
|
||||
channel-модель.
|
||||
|
||||
## Архитектура
|
||||
|
||||
Лента устроена гибридно — **метаданные on-chain**, **тела постов
|
||||
off-chain** в relay feed-mailbox:
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌──────────────────────────┐
|
||||
│ on-chain state │ │ feed mailbox (BadgerDB)│
|
||||
│ │ │ │
|
||||
│ post:<id> → │ │ feedpost:<id> → │
|
||||
│ {author, size, │ POST │ {content, attachment, │
|
||||
│ content_hash, │ /feed/publish │ hashtags, type, ts} │
|
||||
│ hosting_relay, │───────────────────►│ │
|
||||
│ reply_to?, │ │ feedview:<id> → │
|
||||
│ deleted?} │ │ uint64 (counter) │
|
||||
│ │ │ │
|
||||
│ follow:<A>:<B> │ │ feedtag:<tag>:<ts>:<id> │
|
||||
│ like:<post>:<liker>│ │ (hashtag index) │
|
||||
│ likecount:<post> │ │ │
|
||||
└─────────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
Разделение решает два противоречия: on-chain-состояние обеспечивает
|
||||
provable авторство + экономические стимулы (оплата за байт); off-chain
|
||||
тела позволяют хранить мегабайты картинок без раздувания истории
|
||||
блоков. Hosting relay получает плату автора (через CREATE_POST tx);
|
||||
другие ноды могут реплицировать через gossipsub (роадмап).
|
||||
|
||||
## Оплата постов
|
||||
|
||||
Автор поста платит `BasePostFee + Size × PostByteFee` µT за публикацию.
|
||||
Полный fee кредитуется на баланс hosting-релея.
|
||||
|
||||
| Константа | Значение | Примечание |
|
||||
|-----------|----------|-----------|
|
||||
| `BasePostFee` | 1 000 µT (0.001 T) | Совпадает с MinFee (block-validation floor) |
|
||||
| `PostByteFee` | 1 µT/byte | Размер = контент + attachment + ~128 B метаданных |
|
||||
| `MaxPostSize` | 256 KiB | Hard cap, больше — 413 на `/feed/publish` |
|
||||
|
||||
Пример:
|
||||
- 200-байтовый текстовый пост → ~1 328 µT (~0.0013 T)
|
||||
- Текст + 30 KiB WebP-картинка → ~31 848 µT (~0.032 T)
|
||||
- Видео 200 KiB → ~205 128 µT (~0.21 T)
|
||||
|
||||
## Server-side metadata scrubbing
|
||||
|
||||
**Обязательная** для всех аттачментов на `/feed/publish`:
|
||||
|
||||
- **Изображения** (JPEG/PNG/GIF/WebP): decode → downscale до 1080 px
|
||||
→ re-encode как JPEG Q=75 через stdlib. EXIF/GPS/ICC/XMP/MakerNote
|
||||
стираются by construction (stdlib JPEG encoder не пишет их).
|
||||
- **Видео/аудио**: переадресуется во внешний **FFmpeg-сайдкар**
|
||||
(контейнер `docker/media-sidecar/`). Если сайдкар не настроен через
|
||||
`DCHAIN_MEDIA_SIDECAR_URL` и оператор не выставил
|
||||
`DCHAIN_ALLOW_UNSCRUBBED_VIDEO`, видео-аплоад отклоняется с 503.
|
||||
- **MIME mismatch**: если magic-байты не соответствуют заявленному
|
||||
`Content-Type` — 400. Защита от PDF'а, замаскированного под картинку.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `POST /feed/publish`
|
||||
|
||||
Загружает тело поста, запускает скраббинг, сохраняет в feed-mailbox,
|
||||
возвращает метаданные для последующей on-chain CREATE_POST tx.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"post_id": "<hex 16 B>",
|
||||
"author": "<ed25519_hex>",
|
||||
"content": "Text of the post",
|
||||
"content_type": "text/plain",
|
||||
"attachment_b64": "<base64 image/video/audio>",
|
||||
"attachment_mime": "image/jpeg",
|
||||
"reply_to": "<optional parent post_id>",
|
||||
"quote_of": "<optional quoted post_id>",
|
||||
"sig": "<base64 Ed25519 sig>",
|
||||
"ts": 1710000000
|
||||
}
|
||||
```
|
||||
|
||||
`sig = Ed25519.Sign(author_priv, "publish:<post_id>:<raw_content_hash_hex>:<ts>")`
|
||||
|
||||
где `raw_content_hash = sha256(content + raw_attachment_bytes)` —
|
||||
клиентский хэш **до** серверного скраба.
|
||||
|
||||
`ts` в пределах ±5 минут от серверного времени (anti-replay).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"post_id": "<echo>",
|
||||
"hosting_relay": "<ed25519_hex of this node>",
|
||||
"content_hash": "<hex sha256 of post-scrub bytes>",
|
||||
"size": 1234,
|
||||
"hashtags": ["golang", "dchain"],
|
||||
"estimated_fee_ut": 2234
|
||||
}
|
||||
```
|
||||
|
||||
Клиент затем собирает CREATE_POST tx с этими полями и сабмитит на
|
||||
`/api/tx`. См. `buildCreatePostTx` в клиентской библиотеке.
|
||||
|
||||
### `GET /feed/post/{id}`
|
||||
|
||||
Тело поста со статами.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"post_id": "...",
|
||||
"author": "...",
|
||||
"content": "...",
|
||||
"content_type": "text/plain",
|
||||
"hashtags": ["golang"],
|
||||
"created_at": 1710000000,
|
||||
"reply_to": "",
|
||||
"quote_of": "",
|
||||
"attachment": "<base64 bytes>",
|
||||
"attachment_mime": "image/jpeg"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** 404 если не найден; 410 если on-chain soft-deleted.
|
||||
|
||||
### `GET /feed/post/{id}/attachment`
|
||||
|
||||
Сырые байты аттачмента с корректным `Content-Type`. Используется
|
||||
нативным image-loader'ом клиента:
|
||||
|
||||
```html
|
||||
<img src="http://node/feed/post/abc/attachment" />
|
||||
```
|
||||
|
||||
`Cache-Control: public, max-age=3600, immutable` — content-addressed
|
||||
ресурс, кэш безопасен.
|
||||
|
||||
### `POST /feed/post/{id}/view`
|
||||
|
||||
Инкремент off-chain счётчика просмотров. Fire-and-forget, не требует
|
||||
подписи (per-view tx был бы нереалистичен).
|
||||
|
||||
**Response:** `{"post_id": "...", "views": 42}`
|
||||
|
||||
### `GET /feed/post/{id}/stats?me=<pub>`
|
||||
|
||||
Агрегат для отображения. `me` опционален — если передан, в ответе
|
||||
будет `liked_by_me`.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"post_id": "...",
|
||||
"views": 127,
|
||||
"likes": 42,
|
||||
"liked_by_me": true
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /feed/author/{pub}?limit=N&before=<ts>`
|
||||
|
||||
Посты конкретного автора, newest-first.
|
||||
|
||||
Пагинация: передавайте `before=<created_at>` самого старого уже
|
||||
загруженного поста, чтобы получить следующую страницу.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"author": "<pub>", "count": 20, "posts": [FeedPostItem]}
|
||||
```
|
||||
|
||||
### `GET /feed/timeline?follower=<pub>&limit=N&before=<ts>`
|
||||
|
||||
Лента подписок: merged посты всех, на кого подписан follower, newest-
|
||||
first. Требует, чтобы on-chain FOLLOW-транзакции уже скоммитились.
|
||||
|
||||
Пагинация через `before` идентично `/feed/author`.
|
||||
|
||||
### `GET /feed/trending?window=24&limit=N`
|
||||
|
||||
Топ постов за последние N часов, ранжированы по `likes × 3 + views`.
|
||||
|
||||
`window` — часы (1..168), по умолчанию 24.
|
||||
|
||||
Пагинация **не поддерживается** — это топ-срез, не список.
|
||||
|
||||
### `GET /feed/foryou?pub=<pub>&limit=N`
|
||||
|
||||
Рекомендации. Простая эвристика v2.0.0:
|
||||
|
||||
1. Кандидаты: посты последних 48 часов
|
||||
2. Исключить: авторы, на которых уже подписан; уже лайкнутые; свои
|
||||
3. Ранжировать: `likes × 3 + views + 1` (seed чтобы свежее вылезало)
|
||||
|
||||
Никаких ML — база для `v2.2.0 Feed algorithm` (half-life decay,
|
||||
mutual-follow boost, hashtag-affinity).
|
||||
|
||||
### `GET /feed/hashtag/{tag}?limit=N`
|
||||
|
||||
Посты, помеченные хэштегом `#tag`. Tag case-insensitive.
|
||||
Хэштеги авто-индексируются на `/feed/publish` из текста (regex
|
||||
`#[A-Za-z0-9_\p{L}]{1,40}`, dedup, cap 8 per post).
|
||||
|
||||
## On-chain события
|
||||
|
||||
Клиент сабмитит эти транзакции через обычный `/api/tx` endpoint.
|
||||
|
||||
### `CREATE_POST`
|
||||
```json
|
||||
{
|
||||
"type": "CREATE_POST",
|
||||
"fee": <estimated_fee_ut>,
|
||||
"payload": {
|
||||
"post_id": "...",
|
||||
"content_hash": "<base64 32-byte sha256>",
|
||||
"size": 1234,
|
||||
"hosting_relay": "<ed25519_hex>",
|
||||
"reply_to": "",
|
||||
"quote_of": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Валидации on-chain: `size > 0 && size <= MaxPostSize`,
|
||||
`fee >= BasePostFee + size × PostByteFee`, `reply_to` и `quote_of`
|
||||
взаимно исключают друг друга. Дубликат `post_id` отклоняется.
|
||||
|
||||
### `DELETE_POST`
|
||||
```json
|
||||
{"type": "DELETE_POST", "fee": 1000, "payload": {"post_id": "..."}}
|
||||
```
|
||||
Только автор может удалить. Soft-delete (post остаётся в chain, но
|
||||
помечен `deleted=true`; читатели получают 410).
|
||||
|
||||
### `FOLLOW` / `UNFOLLOW`
|
||||
```json
|
||||
{"type": "FOLLOW", "to": "<target_ed25519>", "fee": 1000, "payload": {}}
|
||||
```
|
||||
Двусторонний on-chain индекс: `follow:<A>:<B>` + `followin:<B>:<A>`.
|
||||
`UNFOLLOW` зеркально удаляет оба ключа.
|
||||
|
||||
### `LIKE_POST` / `UNLIKE_POST`
|
||||
```json
|
||||
{"type": "LIKE_POST", "fee": 1000, "payload": {"post_id": "..."}}
|
||||
```
|
||||
Дубль-лайк отклоняется. Кэшированный counter `likecount:<post>`
|
||||
хранит O(1) счётчик.
|
||||
|
||||
## Pricing / economics (резюме)
|
||||
|
||||
| Операция | Cost | Куда уходит |
|
||||
|----------|------|-------------|
|
||||
| `CREATE_POST` | 1000 + size×1 µT | Hosting relay |
|
||||
| `DELETE_POST` | 1000 µT | Block validator |
|
||||
| `FOLLOW` / `UNFOLLOW` | 1000 µT | Block validator |
|
||||
| `LIKE_POST` / `UNLIKE_POST` | 1000 µT | Block validator |
|
||||
| `/feed/post/{id}/view` | 0 | Off-chain counter |
|
||||
|
||||
Причина такого разделения: основные бизнес-действия (публикация) едут
|
||||
на «хостера контента», а мелкие социальные tx (лайк/подписка) — на
|
||||
валидатора сети, как обычные tx.
|
||||
|
||||
## Клиентские вспомогательные функции
|
||||
|
||||
В `client-app/lib/feed.ts`:
|
||||
- `publishPost(params)` — подпись + POST /feed/publish
|
||||
- `publishAndCommit(params)` — publish + CREATE_POST tx в одну операцию
|
||||
- `fetchTimeline`, `fetchForYou`, `fetchTrending`, `fetchAuthorPosts`,
|
||||
`fetchHashtag`, `fetchPost`, `fetchStats`, `bumpView`
|
||||
- `buildCreatePostTx`, `buildDeletePostTx`, `buildFollowTx`,
|
||||
`buildUnfollowTx`, `buildLikePostTx`, `buildUnlikePostTx`
|
||||
- `likePost`, `unlikePost`, `followUser`, `unfollowUser`, `deletePost`
|
||||
(build + submitTx bundled)
|
||||
@@ -1,28 +1,83 @@
|
||||
# Relay API
|
||||
|
||||
REST API для работы с шифрованными сообщениями через relay-сеть.
|
||||
REST API для работы с зашифрованными 1:1-сообщениями через relay-сеть.
|
||||
|
||||
Сообщения шифруются E2E с использованием NaCl (X25519 + XSalsa20-Poly1305). Relay хранит зашифрованные конверты и доставляет их получателям.
|
||||
Сообщения шифруются E2E с использованием NaCl (X25519 + XSalsa20-Poly1305).
|
||||
Relay хранит зашифрованные конверты до 7 дней (настраиваемо через
|
||||
`DCHAIN_MAILBOX_TTL_DAYS`) и доставляет их получателям по запросу или
|
||||
через WebSocket push.
|
||||
|
||||
## Отправить сообщение
|
||||
> **Это API для 1:1 чатов.** Для публичных постов и ленты — см.
|
||||
> [`feed.md`](feed.md).
|
||||
|
||||
### `POST /relay/send`
|
||||
Server-side guarantees (v1.0.x hardening):
|
||||
- `envelope.ID` пересчитывается канонически (`sha256(nonce||ct)[:16]`)
|
||||
на `mailbox.Store` — защита от content-replay.
|
||||
- `envelope.SentAt` переписывается серверным `time.Now().Unix()` — клиент
|
||||
не может back-date/future-date сообщения.
|
||||
- `/relay/broadcast` и `/relay/inbox/*` обёрнуты в rate-limiter
|
||||
(`withSubmitTxGuards` / `withReadLimit`); одиночный атакующий не может
|
||||
через FIFO-eviction выбросить реальные сообщения получателя.
|
||||
- `DELETE /relay/inbox/{id}` требует Ed25519-подпись владельца, связанную
|
||||
с x25519-ключом через identity-реестр.
|
||||
|
||||
Зашифровать и отправить сообщение получателю.
|
||||
## Опубликовать envelope
|
||||
|
||||
### `POST /relay/broadcast` — **рекомендованный E2E-путь**
|
||||
|
||||
Клиент запечатывает сообщение через NaCl `box` своим X25519-privkey на
|
||||
публичный ключ получателя → отправляет готовый envelope. Сервер
|
||||
проверяет размер, канонизирует id/timestamp, сохраняет в mailbox и
|
||||
гошепит пирам.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"envelope": {
|
||||
"id": "<hex>",
|
||||
"sender_pub": "<x25519_hex>",
|
||||
"recipient_pub": "<x25519_hex>",
|
||||
"sender_ed25519_pub": "<ed25519_hex>",
|
||||
"fee_ut": 0,
|
||||
"fee_sig": null,
|
||||
"nonce": "<base64 24B>",
|
||||
"ciphertext": "<base64 NaCl box>",
|
||||
"sent_at": 1710000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `{"id": "<canonical_id>", "status": "broadcast"}`
|
||||
|
||||
> Поле `envelope.id` сервер **перезапишет** на
|
||||
> `hex(sha256(nonce||ciphertext)[:16])`; клиент может прислать любое
|
||||
> непустое значение.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8081/relay/broadcast \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @envelope.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Отправить plaintext через релей (legacy / non-E2E)
|
||||
|
||||
### `POST /relay/send` — **⚠ НЕ E2E**
|
||||
|
||||
Нода запечатывает сообщение **своим** X25519-ключом. Получатель не
|
||||
сможет расшифровать без знания privkey релея, и отправитель не
|
||||
аутентифицируется. Оставлено для backward-compat; в продакшн-чатах
|
||||
используйте `/relay/broadcast`.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"recipient_pub": "<x25519_hex>",
|
||||
"msg_b64": "<base64_encoded_message>"
|
||||
"msg_b64": "<base64_encoded_plaintext>"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `recipient_pub` | string | X25519 public key получателя (hex) |
|
||||
| `msg_b64` | string | Сообщение в base64 |
|
||||
|
||||
```bash
|
||||
MSG=$(echo -n "Hello, Bob!" | base64)
|
||||
curl -X POST http://localhost:8081/relay/send \
|
||||
@@ -30,22 +85,15 @@ curl -X POST http://localhost:8081/relay/send \
|
||||
-d "{\"recipient_pub\":\"$BOB_X25519\",\"msg_b64\":\"$MSG\"}"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "env-abc123",
|
||||
"recipient_pub": "...",
|
||||
"status": "sent"
|
||||
}
|
||||
```
|
||||
**Response:** `{"id": "env-abc123", "recipient_pub": "...", "status": "sent"}`
|
||||
|
||||
---
|
||||
|
||||
## Broadcast конверта
|
||||
## Broadcast конверта (legacy alias)
|
||||
|
||||
### `POST /relay/broadcast`
|
||||
|
||||
Опубликовать pre-sealed конверт (для light-клиентов, которые шифруют на своей стороне).
|
||||
См. выше. Это основной E2E-путь.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
@@ -132,18 +180,35 @@ curl "http://localhost:8081/relay/inbox/count?pub=$MY_X25519"
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /relay/inbox/{envID}?pub=<hex>`
|
||||
### `DELETE /relay/inbox/{envID}?pub=<x25519hex>`
|
||||
|
||||
Удалить сообщение из inbox.
|
||||
Удалить сообщение из inbox. **Требует подписи** Ed25519-ключа, чья
|
||||
identity связана с `?pub=<x25519>` через on-chain identity-реестр —
|
||||
защита от grief-DELETE любым знающим ваш pubkey.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"ed25519_pub": "<hex>",
|
||||
"sig": "<base64 Ed25519 signature>",
|
||||
"ts": 1710000000
|
||||
}
|
||||
```
|
||||
|
||||
Где `sig = Ed25519.Sign(priv, "inbox-delete:<envID>:<x25519pub>:<ts>")`.
|
||||
`ts` должен быть в пределах ±5 минут от серверного времени (anti-replay).
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:8081/relay/inbox/env-abc123?pub=$MY_X25519"
|
||||
# подпись: printf 'inbox-delete:env-abc123:%s:%d' "$MY_X25519" "$TS" | sign
|
||||
curl -X DELETE "http://localhost:8081/relay/inbox/env-abc123?pub=$MY_X25519" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"ed25519_pub\":\"$MY_ED25519\",\"sig\":\"$SIG\",\"ts\":$TS}"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"id": "env-abc123", "status": "deleted"}
|
||||
```
|
||||
**Response:** `{"id": "env-abc123", "status": "deleted"}`
|
||||
|
||||
**Errors:** `403` если подпись/идентичность не совпадает; `503` если
|
||||
на ноде не настроен identity-resolver (DCHAIN_DISABLE_INBOX_DELETE).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
## Обзор
|
||||
|
||||
DChain — это L1-блокчейн для децентрализованного мессенджера. Архитектура разделена на четыре слоя:
|
||||
DChain — это L1-блокчейн для децентрализованного мессенджера + социальной ленты. Архитектура разделена на четыре слоя:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ L3 Application — messaging, usernames, auctions, escrow │
|
||||
│ L3 Application — messaging, social feed, usernames, │
|
||||
│ auctions, escrow │
|
||||
│ (Smart contracts: username_registry, governance, auction, │
|
||||
│ escrow; deployed on-chain via DEPLOY_CONTRACT) │
|
||||
│ escrow; native feed events; deployed on-chain) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ L2 Transport — relay mailbox, E2E NaCl encryption │
|
||||
│ (relay/, mailbox, GossipSub envelopes, RELAY_PROOF tx) │
|
||||
│ L2 Transport — relay mailbox (1:1 E2E) + │
|
||||
│ feed mailbox (public posts + attachments)│
|
||||
│ (relay/, media scrubber, GossipSub envelopes, RELAY_PROOF) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ L1 Chain — PBFT consensus, WASM VM, BadgerDB │
|
||||
│ (blockchain/, consensus/, vm/, identity/) │
|
||||
@@ -189,6 +191,50 @@ Alice node1 (relay) Bob
|
||||
|
||||
---
|
||||
|
||||
## Социальная лента (v2.0.0)
|
||||
|
||||
Публичные посты в Twitter/VK-стиле. Заменила channel-модель (каналы
|
||||
удалены полностью). Живёт гибридно — метаданные on-chain, тела в relay
|
||||
feed-mailbox:
|
||||
|
||||
```
|
||||
Alice node1 (hosting relay) Bob
|
||||
│ │ │
|
||||
│─ POST /feed/publish ──────▶│ │
|
||||
│ (body + EXIF-scrub) │ │
|
||||
│ │ store in feed-mailbox │
|
||||
│ │ (TTL 30 days) │
|
||||
│ │ │
|
||||
│◀── response: content_hash, │ │
|
||||
│ estimated_fee_ut, id │ │
|
||||
│ │ │
|
||||
│─ CREATE_POST tx ──────────▶│── PBFT commit ──────────▶ │
|
||||
│ (fee = 1000 + size × 1) │ │
|
||||
│ │ on-chain: post:<id> │
|
||||
│ │ │
|
||||
│ │ │─ GET /feed/timeline
|
||||
│ │◀── merge followed authors │ (from chain)
|
||||
│ │ │
|
||||
│ │── GET /feed/post/{id} ───▶│
|
||||
│ │ (body from mailbox) │
|
||||
```
|
||||
|
||||
**Экономика:**
|
||||
- Автор платит `BasePostFee(1000) + size × PostByteFee(1)` µT
|
||||
- Вся плата уходит `hosting_relay` пубкею — компенсация за хранение
|
||||
- `MaxPostSize = 256 KiB` — hard cap, защищает мейлбокс от abuse
|
||||
|
||||
**Метаданные:**
|
||||
- EXIF/GPS/camera-info удаляется обязательно на сервере (in-proc для
|
||||
изображений, через FFmpeg-сайдкар для видео)
|
||||
- Лайки / follows — on-chain транзакции (provable + anti-Sybil fee)
|
||||
- Просмотры — off-chain counter в relay (on-chain было бы абсурдно)
|
||||
- Хэштеги — авто-индекс при publish, inverted-index в BadgerDB
|
||||
|
||||
Детали — [api/feed.md](api/feed.md).
|
||||
|
||||
---
|
||||
|
||||
## Динамические валидаторы
|
||||
|
||||
Сет валидаторов хранится on-chain в `validator:<pubkey>`. Любой текущий валидатор может добавить или убрать другого (ADD_VALIDATOR / REMOVE_VALIDATOR tx). После commit такого блока PBFT-движок перезагружает сет без рестарта ноды.
|
||||
|
||||
@@ -147,8 +147,12 @@ txchron:<block20d>:<seq04d> → tx_id (recent-tx index)
|
||||
balance:<pubkey> → uint64 (µT)
|
||||
stake:<pubkey> → uint64 (µT)
|
||||
id:<pubkey> → JSON RegisterKeyPayload
|
||||
chan:<channelID> → JSON CreateChannelPayload
|
||||
chan-member:<ch>:<pub> → ""
|
||||
post:<postID> → JSON PostRecord (v2.0.0 social feed)
|
||||
postbyauthor:<pub>:<ts>:<id> → "" (chrono index, newest-first scan)
|
||||
follow:<A>:<B> → "" (A follows B)
|
||||
followin:<B>:<A> → "" (reverse index for Followers())
|
||||
like:<postID>:<liker> → "" (presence)
|
||||
likecount:<postID> → uint64 (cached counter, O(1) reads)
|
||||
contract:<contractID> → JSON ContractRecord
|
||||
cstate:<contractID>:<key> → bytes
|
||||
clog:<ct>:<block>:<seq> → JSON ContractLogEntry
|
||||
|
||||
@@ -44,10 +44,10 @@ curl -s http://localhost:8080/api/well-known-version | jq .
|
||||
},
|
||||
"protocol_version": 1,
|
||||
"features": [
|
||||
"access_token", "chain_id", "channels_v1", "contract_logs",
|
||||
"fan_out", "identity_registry", "native_username_registry",
|
||||
"onboarding_api", "payment_channels", "relay_mailbox",
|
||||
"ws_submit_tx"
|
||||
"access_token", "chain_id", "contract_logs",
|
||||
"fan_out", "feed_v2", "identity_registry", "media_scrub",
|
||||
"native_username_registry", "onboarding_api", "payment_channels",
|
||||
"relay_broadcast", "relay_mailbox", "ws_submit_tx"
|
||||
],
|
||||
"chain_id": "dchain-ddb9a7e37fc8"
|
||||
}
|
||||
|
||||
21
go.mod
21
go.mod
@@ -1,6 +1,6 @@
|
||||
module go-blockchain
|
||||
|
||||
go 1.21
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dgraph-io/badger/v4 v4.2.0
|
||||
@@ -9,7 +9,12 @@ require (
|
||||
github.com/libp2p/go-libp2p-pubsub v0.10.0
|
||||
github.com/multiformats/go-multiaddr v0.12.3
|
||||
github.com/tetratelabs/wazero v1.7.3
|
||||
golang.org/x/crypto v0.18.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -114,12 +119,12 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/mod v0.13.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sync v0.4.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/tools v0.14.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
gonum.org/v1/gonum v0.13.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
lukechampine.com/blake3 v1.2.1 // indirect
|
||||
|
||||
36
go.sum
36
go.sum
@@ -123,8 +123,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
@@ -443,11 +443,13 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -459,8 +461,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
|
||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -479,8 +481,8 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -494,8 +496,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -517,15 +519,17 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -545,8 +549,8 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
|
||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
332
media/scrub.go
Normal file
332
media/scrub.go
Normal file
@@ -0,0 +1,332 @@
|
||||
// Package media contains metadata scrubbing and re-compression helpers for
|
||||
// files uploaded to the social feed.
|
||||
//
|
||||
// Why this exists
|
||||
// ---------------
|
||||
// Every image file carries an EXIF block that can leak:
|
||||
// - GPS coordinates where the photo was taken
|
||||
// - Camera model, serial number, lens
|
||||
// - Original timestamp (even if the user clears their clock)
|
||||
// - Software name / version
|
||||
// - Author / copyright fields
|
||||
// - A small embedded thumbnail that may leak even after cropping
|
||||
//
|
||||
// Videos and audio have analogous containers (MOV/MP4 atoms, ID3 tags,
|
||||
// Matroska tags). For a social feed that prides itself on privacy we
|
||||
// can't trust the client to have stripped all of it — we scrub again
|
||||
// on the server before persisting the file to the feed mailbox.
|
||||
//
|
||||
// Strategy
|
||||
// --------
|
||||
// Images: decode → strip any ICC profile → re-encode with the stdlib
|
||||
// JPEG/PNG encoders. These encoders DO NOT emit EXIF, so re-encoding is
|
||||
// a complete scrub by construction. Output is JPEG (quality 75) unless
|
||||
// the input is a lossless PNG small enough to keep as PNG.
|
||||
//
|
||||
// Videos: require an external ffmpeg worker (the "media sidecar") —
|
||||
// cannot do this in pure Go without a huge CGo footprint. A tiny HTTP
|
||||
// contract (see docs/media-sidecar.md) lets node operators plug in
|
||||
// compressO-like services behind an env var. If the sidecar is not
|
||||
// configured, videos are stored as-is with a LOG WARNING — the operator
|
||||
// decides whether to accept that risk.
|
||||
//
|
||||
// Magic-byte detection: the claimed Content-Type must match what's
|
||||
// actually in the bytes; mismatches are rejected (prevents a PDF
|
||||
// labelled as image/jpeg from bypassing the scrubber).
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
// Register decoders for the formats we accept.
|
||||
_ "image/gif"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// Errors returned by scrubber.
|
||||
var (
|
||||
// ErrUnsupportedMIME is returned when the caller claims a MIME we
|
||||
// don't know how to scrub.
|
||||
ErrUnsupportedMIME = errors.New("unsupported media type")
|
||||
|
||||
// ErrMIMEMismatch is returned when the bytes don't match the claimed
|
||||
// MIME — blocks a crafted upload from bypassing the scrubber.
|
||||
ErrMIMEMismatch = errors.New("actual bytes don't match claimed content-type")
|
||||
|
||||
// ErrSidecarUnavailable is returned when video scrubbing was required
|
||||
// but no external worker is configured and the operator policy does
|
||||
// not allow unscrubbed video storage.
|
||||
ErrSidecarUnavailable = errors.New("media sidecar required for video scrubbing but not configured")
|
||||
)
|
||||
|
||||
// ── Image scrubbing ────────────────────────────────────────────────────────
|
||||
|
||||
// ImageMaxDim caps the larger dimension of a stored image. 1080px is the
|
||||
// "full-HD-ish" sweet spot — larger rarely matters on a phone feed and
|
||||
// drops file size dramatically. The client is expected to have downscaled
|
||||
// already (expo-image-manipulator), but we re-apply the cap server-side
|
||||
// as a defence-in-depth and to guarantee uniform storage cost.
|
||||
const ImageMaxDim = 1080
|
||||
|
||||
// ImageJPEGQuality is the re-encode quality for JPEG output. 75 balances
|
||||
// perceived quality with size — below 60 artifacts become visible, above
|
||||
// 85 we're paying for noise we can't see.
|
||||
const ImageJPEGQuality = 75
|
||||
|
||||
// ScrubImage decodes src, removes all metadata (by way of re-encoding
|
||||
// with the stdlib JPEG encoder), optionally downscales to ImageMaxDim,
|
||||
// and returns the clean JPEG bytes + the canonical MIME the caller
|
||||
// should store.
|
||||
//
|
||||
// claimedMIME is what the client said the file is; if the bytes don't
|
||||
// match, ErrMIMEMismatch is returned. Accepts image/jpeg, image/png,
|
||||
// image/gif, image/webp on input; output is always image/jpeg (one less
|
||||
// branch in the reader, and no decoder has to touch EXIF).
|
||||
func ScrubImage(src []byte, claimedMIME string) (out []byte, outMIME string, err error) {
|
||||
actualMIME := detectMIME(src)
|
||||
if !isImageMIME(actualMIME) {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrUnsupportedMIME, actualMIME)
|
||||
}
|
||||
if claimedMIME != "" && !mimesCompatible(claimedMIME, actualMIME) {
|
||||
return nil, "", fmt.Errorf("%w: claimed %s, actual %s",
|
||||
ErrMIMEMismatch, claimedMIME, actualMIME)
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(src))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode image: %w", err)
|
||||
}
|
||||
|
||||
// Downscale if needed. We use a draw-based nearest-neighbour style
|
||||
// approach via stdlib to avoid pulling in x/image/draw unless we need
|
||||
// higher-quality resampling. For feed thumbnails nearest is fine since
|
||||
// content is typically downsampled already.
|
||||
if bounds := img.Bounds(); bounds.Dx() > ImageMaxDim || bounds.Dy() > ImageMaxDim {
|
||||
img = downscale(img, ImageMaxDim)
|
||||
}
|
||||
|
||||
// Re-encode as JPEG. stdlib's jpeg.Encode writes ZERO metadata —
|
||||
// no EXIF, no ICC, no XMP, no MakerNote. That's the scrub.
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: ImageJPEGQuality}); err != nil {
|
||||
return nil, "", fmt.Errorf("encode jpeg: %w", err)
|
||||
}
|
||||
return buf.Bytes(), "image/jpeg", nil
|
||||
}
|
||||
|
||||
// downscale returns a new image whose larger dimension equals maxDim,
|
||||
// preserving aspect ratio. Uses stdlib image.NewRGBA + a nearest-neighbour
|
||||
// copy loop — good enough for feed images that are already compressed.
|
||||
func downscale(src image.Image, maxDim int) image.Image {
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
var nw, nh int
|
||||
if w >= h {
|
||||
nw = maxDim
|
||||
nh = h * maxDim / w
|
||||
} else {
|
||||
nh = maxDim
|
||||
nw = w * maxDim / h
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
for y := 0; y < nh; y++ {
|
||||
sy := b.Min.Y + y*h/nh
|
||||
for x := 0; x < nw; x++ {
|
||||
sx := b.Min.X + x*w/nw
|
||||
dst.Set(x, y, src.At(sx, sy))
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// pngEncoder is kept for callers that explicitly want lossless output
|
||||
// (future — not used by ScrubImage which always produces JPEG).
|
||||
var pngEncoder = png.Encoder{CompressionLevel: png.BestCompression}
|
||||
|
||||
// ── MIME detection & validation ────────────────────────────────────────────
|
||||
|
||||
// detectMIME inspects magic bytes to figure out what the data actually is,
|
||||
// independent of what the caller claimed. Matches the subset of types
|
||||
// stdlib http.DetectContentType handles, refined for our use.
|
||||
func detectMIME(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
// http.DetectContentType handles most formats correctly (JPEG, PNG,
|
||||
// GIF, WebP, MP4, WebM, MP3, OGG). We only refine when needed.
|
||||
return strings.SplitN(http.DetectContentType(data), ";", 2)[0]
|
||||
}
|
||||
|
||||
func isImageMIME(m string) bool {
|
||||
switch m {
|
||||
case "image/jpeg", "image/png", "image/gif", "image/webp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isVideoMIME(m string) bool {
|
||||
switch m {
|
||||
case "video/mp4", "video/webm", "video/quicktime":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isAudioMIME(m string) bool {
|
||||
switch m {
|
||||
case "audio/mpeg", "audio/ogg", "audio/webm", "audio/wav", "audio/mp4":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mimesCompatible tolerates small aliases (image/jpg vs image/jpeg, etc.)
|
||||
// so a misspelled client header doesn't cause a 400. Claimed MIME is
|
||||
// the caller's; actual is from magic bytes — we trust magic bytes when
|
||||
// they disagree with a known-silly alias.
|
||||
func mimesCompatible(claimed, actual string) bool {
|
||||
claimed = strings.ToLower(strings.TrimSpace(claimed))
|
||||
if claimed == actual {
|
||||
return true
|
||||
}
|
||||
aliases := map[string]string{
|
||||
"image/jpg": "image/jpeg",
|
||||
"image/x-png": "image/png",
|
||||
"video/mov": "video/quicktime",
|
||||
}
|
||||
if canon, ok := aliases[claimed]; ok && canon == actual {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Video scrubbing (sidecar) ──────────────────────────────────────────────
|
||||
|
||||
// SidecarConfig describes how to reach an external media scrubber worker
|
||||
// (typically a tiny FFmpeg-wrapper HTTP service running alongside the
|
||||
// node — see docs/media-sidecar.md). Leaving URL empty disables sidecar
|
||||
// use; callers then decide whether to fall back to "store as-is and warn"
|
||||
// or to reject video uploads entirely.
|
||||
type SidecarConfig struct {
|
||||
// URL is the base URL of the sidecar. Expected routes:
|
||||
//
|
||||
// POST /scrub/video body: raw bytes → returns scrubbed bytes
|
||||
// POST /scrub/audio body: raw bytes → returns scrubbed bytes
|
||||
//
|
||||
// Both MUST strip metadata (-map_metadata -1 in ffmpeg terms) and
|
||||
// re-encode with a sane bitrate cap (default: H.264 CRF 28 for
|
||||
// video, libopus 96k for audio). See the reference implementation
|
||||
// at docker/media-sidecar/ in this repo.
|
||||
URL string
|
||||
|
||||
// Timeout guards against a hung sidecar. 30s is enough for a 5 MB
|
||||
// video on modest hardware; larger inputs should be pre-compressed
|
||||
// by the client.
|
||||
Timeout time.Duration
|
||||
|
||||
// MaxInputBytes caps what we forward to the sidecar (protects
|
||||
// against an attacker tying up the sidecar on a 1 GB upload).
|
||||
MaxInputBytes int64
|
||||
}
|
||||
|
||||
// Scrubber bundles image + sidecar capabilities. Create once at node
|
||||
// startup and reuse.
|
||||
type Scrubber struct {
|
||||
sidecar SidecarConfig
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewScrubber returns a Scrubber. sidecar.URL may be empty (image-only
|
||||
// mode) — in that case ScrubVideo / ScrubAudio return ErrSidecarUnavailable.
|
||||
func NewScrubber(sidecar SidecarConfig) *Scrubber {
|
||||
if sidecar.Timeout == 0 {
|
||||
sidecar.Timeout = 30 * time.Second
|
||||
}
|
||||
if sidecar.MaxInputBytes == 0 {
|
||||
sidecar.MaxInputBytes = 16 * 1024 * 1024 // 16 MiB input → client should have shrunk
|
||||
}
|
||||
return &Scrubber{
|
||||
sidecar: sidecar,
|
||||
http: &http.Client{
|
||||
Timeout: sidecar.Timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Scrub picks the right strategy based on the actual MIME of the bytes.
|
||||
// Returns the cleaned payload and the canonical MIME to store under.
|
||||
func (s *Scrubber) Scrub(ctx context.Context, src []byte, claimedMIME string) ([]byte, string, error) {
|
||||
actual := detectMIME(src)
|
||||
if claimedMIME != "" && !mimesCompatible(claimedMIME, actual) {
|
||||
return nil, "", fmt.Errorf("%w: claimed %s, actual %s",
|
||||
ErrMIMEMismatch, claimedMIME, actual)
|
||||
}
|
||||
switch {
|
||||
case isImageMIME(actual):
|
||||
// Images handled in-process, no sidecar needed.
|
||||
return ScrubImage(src, claimedMIME)
|
||||
case isVideoMIME(actual):
|
||||
return s.scrubViaSidecar(ctx, "/scrub/video", src, actual)
|
||||
case isAudioMIME(actual):
|
||||
return s.scrubViaSidecar(ctx, "/scrub/audio", src, actual)
|
||||
default:
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrUnsupportedMIME, actual)
|
||||
}
|
||||
}
|
||||
|
||||
// scrubViaSidecar POSTs src to the configured sidecar route and returns
|
||||
// the response bytes. Errors:
|
||||
// - ErrSidecarUnavailable if sidecar.URL is empty
|
||||
// - wrapping the HTTP error otherwise
|
||||
func (s *Scrubber) scrubViaSidecar(ctx context.Context, path string, src []byte, actual string) ([]byte, string, error) {
|
||||
if s.sidecar.URL == "" {
|
||||
return nil, "", ErrSidecarUnavailable
|
||||
}
|
||||
if int64(len(src)) > s.sidecar.MaxInputBytes {
|
||||
return nil, "", fmt.Errorf("input exceeds sidecar max %d bytes", s.sidecar.MaxInputBytes)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
strings.TrimRight(s.sidecar.URL, "/")+path, bytes.NewReader(src))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("build sidecar request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", actual)
|
||||
resp, err := s.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("call sidecar: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, "", fmt.Errorf("sidecar returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
// Limit the reply we buffer — an evil sidecar could try to amplify.
|
||||
const maxReply = 64 * 1024 * 1024 // 64 MiB hard cap
|
||||
out, err := io.ReadAll(io.LimitReader(resp.Body, maxReply))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read sidecar reply: %w", err)
|
||||
}
|
||||
respMIME := resp.Header.Get("Content-Type")
|
||||
if respMIME == "" {
|
||||
respMIME = actual
|
||||
}
|
||||
return out, strings.SplitN(respMIME, ";", 2)[0], nil
|
||||
}
|
||||
|
||||
// IsSidecarConfigured reports whether video/audio scrubbing is available.
|
||||
// Callers can use this to decide whether to accept video attachments or
|
||||
// reject them with a clear "this node doesn't support video" message.
|
||||
func (s *Scrubber) IsSidecarConfigured() bool {
|
||||
return s.sidecar.URL != ""
|
||||
}
|
||||
149
media/scrub_test.go
Normal file
149
media/scrub_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestScrubImageRemovesEXIF: our scrubber re-encodes via stdlib JPEG, which
|
||||
// does not preserve EXIF by construction. We verify that a crafted input
|
||||
// carrying an EXIF marker produces an output without one.
|
||||
func TestScrubImageRemovesEXIF(t *testing.T) {
|
||||
// Build a JPEG that explicitly contains an APP1 EXIF segment.
|
||||
// Structure: JPEG SOI + APP1 with "Exif\x00\x00" header + real image data.
|
||||
var base bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
|
||||
for y := 0; y < 8; y++ {
|
||||
for x := 0; x < 8; x++ {
|
||||
img.Set(x, y, color.RGBA{uint8(x * 32), uint8(y * 32), 128, 255})
|
||||
}
|
||||
}
|
||||
if err := jpeg.Encode(&base, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||
t.Fatalf("encode base: %v", err)
|
||||
}
|
||||
input := injectEXIF(t, base.Bytes())
|
||||
|
||||
if !bytes.Contains(input, []byte("Exif\x00\x00")) {
|
||||
t.Fatalf("test setup broken: EXIF not injected")
|
||||
}
|
||||
// Also drop an identifiable string in the EXIF payload so we can prove
|
||||
// it's gone.
|
||||
if !bytes.Contains(input, []byte("SECRETGPS")) {
|
||||
t.Fatalf("test setup broken: EXIF marker not injected")
|
||||
}
|
||||
|
||||
cleaned, mime, err := ScrubImage(input, "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("ScrubImage: %v", err)
|
||||
}
|
||||
if mime != "image/jpeg" {
|
||||
t.Errorf("mime: got %q, want image/jpeg", mime)
|
||||
}
|
||||
// Verify the scrubbed output doesn't contain our canary string.
|
||||
if bytes.Contains(cleaned, []byte("SECRETGPS")) {
|
||||
t.Errorf("EXIF canary survived scrub — metadata not stripped")
|
||||
}
|
||||
// Verify the output doesn't contain the EXIF segment marker.
|
||||
if bytes.Contains(cleaned, []byte("Exif\x00\x00")) {
|
||||
t.Errorf("EXIF header string survived scrub")
|
||||
}
|
||||
// Output must still be a valid JPEG.
|
||||
if _, err := jpeg.Decode(bytes.NewReader(cleaned)); err != nil {
|
||||
t.Errorf("scrubbed output is not a valid JPEG: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// injectEXIF splices a synthetic APP1 EXIF segment after the JPEG SOI.
|
||||
// Segment layout: FF E1 <len_hi> <len_lo> "Exif\0\0" + arbitrary payload.
|
||||
// The payload is NOT valid TIFF — that's fine; stdlib JPEG decoder skips
|
||||
// unknown APP1 segments rather than aborting.
|
||||
func injectEXIF(t *testing.T, src []byte) []byte {
|
||||
t.Helper()
|
||||
if len(src) < 2 || src[0] != 0xFF || src[1] != 0xD8 {
|
||||
t.Fatalf("not a JPEG")
|
||||
}
|
||||
payload := []byte("Exif\x00\x00" + "SECRETGPS-51.5074N-0.1278W-Canon-EOS-R5")
|
||||
segmentLen := len(payload) + 2 // +2 = 2 bytes of len field itself
|
||||
var seg bytes.Buffer
|
||||
seg.Write([]byte{0xFF, 0xE1})
|
||||
seg.WriteByte(byte(segmentLen >> 8))
|
||||
seg.WriteByte(byte(segmentLen & 0xff))
|
||||
seg.Write(payload)
|
||||
out := make([]byte, 0, len(src)+seg.Len())
|
||||
out = append(out, src[:2]...) // SOI
|
||||
out = append(out, seg.Bytes()...)
|
||||
out = append(out, src[2:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestScrubImageMIMEMismatch: rejects bytes that don't match claimed MIME.
|
||||
func TestScrubImageMIMEMismatch(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||
jpeg.Encode(&buf, img, nil)
|
||||
// Claim it's a PNG.
|
||||
_, _, err := ScrubImage(buf.Bytes(), "image/png")
|
||||
if err == nil {
|
||||
t.Fatalf("expected ErrMIMEMismatch, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrubImageDownscale: images over ImageMaxDim are shrunk.
|
||||
func TestScrubImageDownscale(t *testing.T) {
|
||||
// Make a 2000×1000 image — larger dim 2000 > 1080.
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2000, 1000))
|
||||
for y := 0; y < 1000; y++ {
|
||||
for x := 0; x < 2000; x++ {
|
||||
img.Set(x, y, color.RGBA{128, 64, 200, 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
cleaned, _, err := ScrubImage(buf.Bytes(), "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("ScrubImage: %v", err)
|
||||
}
|
||||
decoded, err := jpeg.Decode(bytes.NewReader(cleaned))
|
||||
if err != nil {
|
||||
t.Fatalf("decode scrubbed: %v", err)
|
||||
}
|
||||
b := decoded.Bounds()
|
||||
if b.Dx() > ImageMaxDim || b.Dy() > ImageMaxDim {
|
||||
t.Errorf("not downscaled: got %dx%d, want max %d", b.Dx(), b.Dy(), ImageMaxDim)
|
||||
}
|
||||
// Aspect ratio roughly preserved (2:1 → 1080:540 with rounding slack).
|
||||
if b.Dx() != ImageMaxDim {
|
||||
t.Errorf("larger dim: got %d, want %d", b.Dx(), ImageMaxDim)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectMIME: a few magic-byte cases to ensure magic detection works.
|
||||
func TestDetectMIME(t *testing.T) {
|
||||
cases := []struct {
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
{[]byte("\xff\xd8\xff\xe0garbage"), "image/jpeg"},
|
||||
{[]byte("\x89PNG\r\n\x1a\n..."), "image/png"},
|
||||
{[]byte("GIF89a..."), "image/gif"},
|
||||
{[]byte{}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := detectMIME(tc.data)
|
||||
if got != tc.want {
|
||||
t.Errorf("detectMIME(%q): got %q want %q", string(tc.data[:min(len(tc.data), 12)]), got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// Package node — channel endpoints.
|
||||
//
|
||||
// `/api/channels/:id/members` returns every Ed25519 pubkey registered as a
|
||||
// channel member together with their current X25519 pubkey (from the
|
||||
// identity registry). Clients sealing a message to a channel iterate this
|
||||
// list and call relay.Seal once per recipient — that's the "fan-out"
|
||||
// group-messaging model (R1 in the roadmap).
|
||||
//
|
||||
// Why enrich with X25519 here rather than making the client do it?
|
||||
// - One HTTP round trip vs N. At 10+ members the latency difference is
|
||||
// significant over mobile networks.
|
||||
// - The server already holds the identity state; no extra DB hops.
|
||||
// - Clients get a stable, already-joined view — if a member hasn't
|
||||
// published an X25519 key yet, we return them with `x25519_pub_key=""`
|
||||
// so the caller knows to skip or retry later.
|
||||
package node
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-blockchain/blockchain"
|
||||
"go-blockchain/wallet"
|
||||
)
|
||||
|
||||
func registerChannelAPI(mux *http.ServeMux, q ExplorerQuery) {
|
||||
// GET /api/channels/{id} → channel metadata
|
||||
// GET /api/channels/{id}/members → enriched member list
|
||||
//
|
||||
// One HandleFunc deals with both by sniffing the path suffix.
|
||||
mux.HandleFunc("/api/channels/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/channels/")
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" {
|
||||
jsonErr(w, fmt.Errorf("channel id required"), 400)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/members"):
|
||||
id := strings.TrimSuffix(path, "/members")
|
||||
handleChannelMembers(w, q, id)
|
||||
default:
|
||||
handleChannelInfo(w, q, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func handleChannelInfo(w http.ResponseWriter, q ExplorerQuery, channelID string) {
|
||||
if q.GetChannel == nil {
|
||||
jsonErr(w, fmt.Errorf("channel queries not configured"), 503)
|
||||
return
|
||||
}
|
||||
ch, err := q.GetChannel(channelID)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
if ch == nil {
|
||||
jsonErr(w, fmt.Errorf("channel %s not found", channelID), 404)
|
||||
return
|
||||
}
|
||||
jsonOK(w, ch)
|
||||
}
|
||||
|
||||
func handleChannelMembers(w http.ResponseWriter, q ExplorerQuery, channelID string) {
|
||||
if q.GetChannelMembers == nil {
|
||||
jsonErr(w, fmt.Errorf("channel queries not configured"), 503)
|
||||
return
|
||||
}
|
||||
pubs, err := q.GetChannelMembers(channelID)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
out := make([]blockchain.ChannelMember, 0, len(pubs))
|
||||
for _, pub := range pubs {
|
||||
member := blockchain.ChannelMember{
|
||||
PubKey: pub,
|
||||
Address: wallet.PubKeyToAddress(pub),
|
||||
}
|
||||
// Best-effort X25519 lookup — skip silently on miss so a member
|
||||
// who hasn't published their identity yet doesn't prevent the
|
||||
// whole list from returning. The sender will just skip them on
|
||||
// fan-out and retry later (after that member does register).
|
||||
if q.IdentityInfo != nil {
|
||||
if info, err := q.IdentityInfo(pub); err == nil && info != nil {
|
||||
member.X25519PubKey = info.X25519Pub
|
||||
}
|
||||
}
|
||||
out = append(out, member)
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"channel_id": channelID,
|
||||
"count": len(out),
|
||||
"members": out,
|
||||
})
|
||||
}
|
||||
@@ -38,6 +38,20 @@ func queryInt(r *http.Request, key string, def int) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// queryInt64 reads a non-negative int64 query param — typically a unix
|
||||
// timestamp cursor for pagination. Returns def when missing or invalid.
|
||||
func queryInt64(r *http.Request, key string, def int64) int64 {
|
||||
s := r.URL.Query().Get(key)
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// queryIntMin0 parses a query param as a non-negative integer; returns 0 if absent or invalid.
|
||||
func queryIntMin0(r *http.Request, key string) int {
|
||||
s := r.URL.Query().Get(key)
|
||||
|
||||
768
node/api_feed.go
Normal file
768
node/api_feed.go
Normal file
@@ -0,0 +1,768 @@
|
||||
package node
|
||||
|
||||
// Feed HTTP endpoints (v2.0.0).
|
||||
//
|
||||
// Mount points:
|
||||
//
|
||||
// POST /feed/publish — store a post body (authenticated)
|
||||
// GET /feed/post/{id} — fetch a post body
|
||||
// GET /feed/post/{id}/stats — {views, likes, liked_by_me?} aggregate
|
||||
// POST /feed/post/{id}/view — increment off-chain view counter
|
||||
// GET /feed/author/{pub} — ?limit=N, posts by an author
|
||||
// GET /feed/timeline — ?follower=<pub>&limit=N, merged feed of follows
|
||||
// GET /feed/trending — ?window=h&limit=N, top by likes + views
|
||||
// GET /feed/foryou — ?pub=<pub>&limit=N, recommendations
|
||||
// GET /feed/hashtag/{tag} — posts matching a hashtag
|
||||
//
|
||||
// Publish flow:
|
||||
// 1. Client POSTs {content, attachment, post_id, author, sig, ts}.
|
||||
// 2. Node verifies sig (Ed25519 over canonical bytes), hashes body,
|
||||
// stores in FeedMailbox, returns hosting_relay + content_hash + size.
|
||||
// 3. Client then submits on-chain CREATE_POST tx with that metadata.
|
||||
// Node charges the fee (base + size×byte_fee) and credits the relay.
|
||||
// 4. Subsequent GET /feed/post/{id} serves the stored body to anyone.
|
||||
//
|
||||
// Why the split? On-chain metadata gives us provable authorship + the
|
||||
// pay-for-storage incentive; off-chain body storage keeps the block
|
||||
// history small. If the hosting relay dies, the on-chain record stays
|
||||
// (with a "body unavailable" fallback on the reader side) — authors can
|
||||
// re-publish to another relay.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-blockchain/blockchain"
|
||||
"go-blockchain/identity"
|
||||
"go-blockchain/media"
|
||||
"go-blockchain/relay"
|
||||
)
|
||||
|
||||
// FeedConfig wires feed HTTP endpoints to the relay mailbox and the
|
||||
// chain for read-after-write queries.
|
||||
type FeedConfig struct {
|
||||
Mailbox *relay.FeedMailbox
|
||||
|
||||
// HostingRelayPub is this node's Ed25519 pubkey — returned from
|
||||
// /feed/publish so the client knows who to put in CREATE_POST tx.
|
||||
HostingRelayPub string
|
||||
|
||||
// Scrubber strips metadata from image/video/audio attachments before
|
||||
// they are stored. MUST be non-nil; a zero Scrubber (NewScrubber with
|
||||
// empty sidecar URL) still handles images in-process — only video/audio
|
||||
// require sidecar config.
|
||||
Scrubber *media.Scrubber
|
||||
|
||||
// AllowUnscrubbedVideo controls server behaviour when a video upload
|
||||
// arrives and no sidecar is configured. false (default) → reject; true
|
||||
// → store as-is with a warning log. Set via --allow-unscrubbed-video
|
||||
// flag on the node. Leave false in production.
|
||||
AllowUnscrubbedVideo bool
|
||||
|
||||
// Chain lookups (nil-safe; endpoints degrade gracefully).
|
||||
GetPost func(postID string) (*blockchain.PostRecord, error)
|
||||
LikeCount func(postID string) (uint64, error)
|
||||
HasLiked func(postID, likerPub string) (bool, error)
|
||||
PostsByAuthor func(authorPub string, beforeTs int64, limit int) ([]*blockchain.PostRecord, error)
|
||||
Following func(followerPub string) ([]string, error)
|
||||
}
|
||||
|
||||
// RegisterFeedRoutes wires feed endpoints onto mux. Writes are rate-limited
|
||||
// via withSubmitTxGuards; reads via withReadLimit (same limiters as /relay).
|
||||
func RegisterFeedRoutes(mux *http.ServeMux, cfg FeedConfig) {
|
||||
if cfg.Mailbox == nil {
|
||||
return
|
||||
}
|
||||
mux.HandleFunc("/feed/publish", withSubmitTxGuards(feedPublish(cfg)))
|
||||
mux.HandleFunc("/feed/post/", withReadLimit(feedPostRouter(cfg)))
|
||||
mux.HandleFunc("/feed/author/", withReadLimit(feedAuthor(cfg)))
|
||||
mux.HandleFunc("/feed/timeline", withReadLimit(feedTimeline(cfg)))
|
||||
mux.HandleFunc("/feed/trending", withReadLimit(feedTrending(cfg)))
|
||||
mux.HandleFunc("/feed/foryou", withReadLimit(feedForYou(cfg)))
|
||||
mux.HandleFunc("/feed/hashtag/", withReadLimit(feedHashtag(cfg)))
|
||||
}
|
||||
|
||||
// ── POST /feed/publish ────────────────────────────────────────────────────
|
||||
|
||||
// feedPublishRequest — what the client sends. Signature is Ed25519 over
|
||||
// canonical bytes: "publish:<post_id>:<content_sha256_hex>:<ts>".
|
||||
// ts must be within ±5 minutes of server clock.
|
||||
type feedPublishRequest struct {
|
||||
PostID string `json:"post_id"`
|
||||
Author string `json:"author"` // hex Ed25519
|
||||
Content string `json:"content"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
AttachmentB64 string `json:"attachment_b64,omitempty"`
|
||||
AttachmentMIME string `json:"attachment_mime,omitempty"`
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
QuoteOf string `json:"quote_of,omitempty"`
|
||||
Sig string `json:"sig"` // base64 Ed25519 sig
|
||||
Ts int64 `json:"ts"`
|
||||
}
|
||||
|
||||
type feedPublishResponse struct {
|
||||
PostID string `json:"post_id"`
|
||||
HostingRelay string `json:"hosting_relay"`
|
||||
ContentHash string `json:"content_hash"` // hex sha256
|
||||
Size uint64 `json:"size"`
|
||||
Hashtags []string `json:"hashtags"`
|
||||
EstimatedFeeUT uint64 `json:"estimated_fee_ut"` // base + size*byte_fee
|
||||
}
|
||||
|
||||
func feedPublish(cfg FeedConfig) http.HandlerFunc {
|
||||
const publishSkewSecs = 300
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
var req feedPublishRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
|
||||
return
|
||||
}
|
||||
if req.PostID == "" || req.Author == "" || req.Sig == "" || req.Ts == 0 {
|
||||
jsonErr(w, fmt.Errorf("post_id, author, sig, ts are required"), 400)
|
||||
return
|
||||
}
|
||||
if req.Content == "" && req.AttachmentB64 == "" {
|
||||
jsonErr(w, fmt.Errorf("post must have content or attachment"), 400)
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if req.Ts < now-publishSkewSecs || req.Ts > now+publishSkewSecs {
|
||||
jsonErr(w, fmt.Errorf("ts out of range (±%ds)", publishSkewSecs), 400)
|
||||
return
|
||||
}
|
||||
if req.ReplyTo != "" && req.QuoteOf != "" {
|
||||
jsonErr(w, fmt.Errorf("reply_to and quote_of are mutually exclusive"), 400)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode attachment (raw upload — before scrub).
|
||||
var rawAttachment []byte
|
||||
var attachmentMIME string
|
||||
if req.AttachmentB64 != "" {
|
||||
b, err := base64.StdEncoding.DecodeString(req.AttachmentB64)
|
||||
if err != nil {
|
||||
if b, err = base64.RawURLEncoding.DecodeString(req.AttachmentB64); err != nil {
|
||||
jsonErr(w, fmt.Errorf("attachment_b64: invalid base64"), 400)
|
||||
return
|
||||
}
|
||||
}
|
||||
rawAttachment = b
|
||||
attachmentMIME = req.AttachmentMIME
|
||||
}
|
||||
|
||||
// ── Step 1: verify signature over the RAW-upload hash ──────────
|
||||
// The client signs what it sent. The server recomputes hash over
|
||||
// the as-received bytes and verifies — this proves the upload
|
||||
// came from the claimed author and wasn't tampered with in transit.
|
||||
rawHasher := sha256.New()
|
||||
rawHasher.Write([]byte(req.Content))
|
||||
rawHasher.Write(rawAttachment)
|
||||
rawContentHash := rawHasher.Sum(nil)
|
||||
rawContentHashHex := hex.EncodeToString(rawContentHash)
|
||||
|
||||
msg := []byte(fmt.Sprintf("publish:%s:%s:%d", req.PostID, rawContentHashHex, req.Ts))
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(req.Sig)
|
||||
if err != nil {
|
||||
if sigBytes, err = base64.RawURLEncoding.DecodeString(req.Sig); err != nil {
|
||||
jsonErr(w, fmt.Errorf("sig: invalid base64"), 400)
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := hex.DecodeString(req.Author); err != nil {
|
||||
jsonErr(w, fmt.Errorf("author: invalid hex"), 400)
|
||||
return
|
||||
}
|
||||
ok, err := identity.Verify(req.Author, msg, sigBytes)
|
||||
if err != nil || !ok {
|
||||
jsonErr(w, fmt.Errorf("signature invalid"), 403)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Step 2: MANDATORY server-side metadata scrub ─────────────
|
||||
// Runs AFTER signature verification so a fake client can't burn
|
||||
// CPU by triggering expensive scrub work on unauthenticated inputs.
|
||||
//
|
||||
// Images: in-process stdlib re-encode → kills EXIF/GPS/ICC/XMP by
|
||||
// construction. Videos/audio: forwarded to FFmpeg sidecar; without
|
||||
// one, we reject unless operator opted in to unscrubbed video.
|
||||
attachment := rawAttachment
|
||||
if len(attachment) > 0 {
|
||||
if cfg.Scrubber == nil {
|
||||
jsonErr(w, fmt.Errorf("media scrubber not configured on this node"), 503)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
cleaned, newMIME, err := cfg.Scrubber.Scrub(ctx, attachment, attachmentMIME)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if err == media.ErrSidecarUnavailable && cfg.AllowUnscrubbedVideo {
|
||||
log.Printf("[feed] WARNING: storing unscrubbed video — no sidecar configured (author=%s)", req.Author)
|
||||
} else {
|
||||
status := 400
|
||||
if err == media.ErrSidecarUnavailable {
|
||||
status = 503
|
||||
}
|
||||
jsonErr(w, fmt.Errorf("scrub attachment: %w", err), status)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
attachment = cleaned
|
||||
attachmentMIME = newMIME
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: recompute content hash over the SCRUBBED bytes ────
|
||||
// This is what goes into the response + on-chain CREATE_POST, so
|
||||
// anyone fetching the body can verify integrity against the chain.
|
||||
// The signature check already used the raw-upload hash above;
|
||||
// this final hash binds the on-chain record to what readers will
|
||||
// actually download.
|
||||
finalHasher := sha256.New()
|
||||
finalHasher.Write([]byte(req.Content))
|
||||
finalHasher.Write(attachment)
|
||||
contentHash := finalHasher.Sum(nil)
|
||||
contentHashHex := hex.EncodeToString(contentHash)
|
||||
|
||||
post := &relay.FeedPost{
|
||||
PostID: req.PostID,
|
||||
Author: req.Author,
|
||||
Content: req.Content,
|
||||
ContentType: req.ContentType,
|
||||
Attachment: attachment,
|
||||
AttachmentMIME: attachmentMIME,
|
||||
ReplyTo: req.ReplyTo,
|
||||
QuoteOf: req.QuoteOf,
|
||||
}
|
||||
hashtags, err := cfg.Mailbox.Store(post, req.Ts)
|
||||
if err != nil {
|
||||
if err == relay.ErrPostTooLarge {
|
||||
jsonErr(w, err, 413)
|
||||
return
|
||||
}
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Report what the client should put into CREATE_POST.
|
||||
size := uint64(len(req.Content)) + uint64(len(attachment)) + 128
|
||||
fee := blockchain.BasePostFee + size*blockchain.PostByteFee
|
||||
jsonOK(w, feedPublishResponse{
|
||||
PostID: req.PostID,
|
||||
HostingRelay: cfg.HostingRelayPub,
|
||||
ContentHash: contentHashHex,
|
||||
Size: size,
|
||||
Hashtags: hashtags,
|
||||
EstimatedFeeUT: fee,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /feed/post/{id} [+ /stats subroute, POST /view] ─────────────────
|
||||
|
||||
// feedPostRouter dispatches /feed/post/{id}, /feed/post/{id}/stats,
|
||||
// /feed/post/{id}/view to the right handler.
|
||||
func feedPostRouter(cfg FeedConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/feed/post/")
|
||||
rest = strings.Trim(rest, "/")
|
||||
if rest == "" {
|
||||
jsonErr(w, fmt.Errorf("post id required"), 400)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(rest, "/")
|
||||
postID := parts[0]
|
||||
if len(parts) == 1 {
|
||||
feedGetPost(cfg)(w, r, postID)
|
||||
return
|
||||
}
|
||||
switch parts[1] {
|
||||
case "stats":
|
||||
feedPostStats(cfg)(w, r, postID)
|
||||
case "view":
|
||||
feedPostView(cfg)(w, r, postID)
|
||||
case "attachment":
|
||||
feedPostAttachment(cfg)(w, r, postID)
|
||||
default:
|
||||
jsonErr(w, fmt.Errorf("unknown sub-route %q", parts[1]), 404)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// feedPostAttachment handles GET /feed/post/{id}/attachment — returns the
|
||||
// raw attachment bytes with the correct Content-Type so clients can use
|
||||
// the URL directly as an <Image source={uri: ...}>.
|
||||
//
|
||||
// Why a dedicated endpoint? The /feed/post/{id} response wraps the body
|
||||
// as base64 inside JSON; fetching that + decoding for N posts in a feed
|
||||
// list would blow up memory. Native image loaders stream bytes straight
|
||||
// to the GPU — this route lets them do that without intermediate JSON.
|
||||
//
|
||||
// Respects on-chain soft-delete: returns 410 when the post is tombstoned.
|
||||
func feedPostAttachment(cfg FeedConfig) postHandler {
|
||||
return func(w http.ResponseWriter, r *http.Request, postID string) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
if cfg.GetPost != nil {
|
||||
if rec, _ := cfg.GetPost(postID); rec != nil && rec.Deleted {
|
||||
jsonErr(w, fmt.Errorf("post %s deleted", postID), 410)
|
||||
return
|
||||
}
|
||||
}
|
||||
post, err := cfg.Mailbox.Get(postID)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
if post == nil || len(post.Attachment) == 0 {
|
||||
jsonErr(w, fmt.Errorf("no attachment for post %s", postID), 404)
|
||||
return
|
||||
}
|
||||
mime := post.AttachmentMIME
|
||||
if mime == "" {
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(post.Attachment)))
|
||||
// Cache for 1 hour — attachments are immutable (tied to content_hash),
|
||||
// so aggressive client-side caching is safe and saves bandwidth.
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600, immutable")
|
||||
w.Header().Set("ETag", `"`+postID+`"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(post.Attachment)
|
||||
}
|
||||
}
|
||||
|
||||
type postHandler func(w http.ResponseWriter, r *http.Request, postID string)
|
||||
|
||||
func feedGetPost(cfg FeedConfig) postHandler {
|
||||
return func(w http.ResponseWriter, r *http.Request, postID string) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
post, err := cfg.Mailbox.Get(postID)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
if post == nil {
|
||||
jsonErr(w, fmt.Errorf("post %s not found", postID), 404)
|
||||
return
|
||||
}
|
||||
// Respect on-chain soft-delete.
|
||||
if cfg.GetPost != nil {
|
||||
if rec, _ := cfg.GetPost(postID); rec != nil && rec.Deleted {
|
||||
jsonErr(w, fmt.Errorf("post %s deleted", postID), 410)
|
||||
return
|
||||
}
|
||||
}
|
||||
jsonOK(w, post)
|
||||
}
|
||||
}
|
||||
|
||||
type postStatsResponse struct {
|
||||
PostID string `json:"post_id"`
|
||||
Views uint64 `json:"views"`
|
||||
Likes uint64 `json:"likes"`
|
||||
LikedByMe *bool `json:"liked_by_me,omitempty"` // set only when ?me=<pub> given
|
||||
}
|
||||
|
||||
func feedPostStats(cfg FeedConfig) postHandler {
|
||||
return func(w http.ResponseWriter, r *http.Request, postID string) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
views, _ := cfg.Mailbox.ViewCount(postID)
|
||||
var likes uint64
|
||||
if cfg.LikeCount != nil {
|
||||
likes, _ = cfg.LikeCount(postID)
|
||||
}
|
||||
resp := postStatsResponse{
|
||||
PostID: postID,
|
||||
Views: views,
|
||||
Likes: likes,
|
||||
}
|
||||
if me := r.URL.Query().Get("me"); me != "" && cfg.HasLiked != nil {
|
||||
if liked, err := cfg.HasLiked(postID, me); err == nil {
|
||||
resp.LikedByMe = &liked
|
||||
}
|
||||
}
|
||||
jsonOK(w, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func feedPostView(cfg FeedConfig) postHandler {
|
||||
return func(w http.ResponseWriter, r *http.Request, postID string) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
next, err := cfg.Mailbox.IncrementView(postID)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"post_id": postID,
|
||||
"views": next,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /feed/author/{pub} ────────────────────────────────────────────────
|
||||
|
||||
func feedAuthor(cfg FeedConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
pub := strings.TrimPrefix(r.URL.Path, "/feed/author/")
|
||||
pub = strings.Trim(pub, "/")
|
||||
if pub == "" {
|
||||
jsonErr(w, fmt.Errorf("author pub required"), 400)
|
||||
return
|
||||
}
|
||||
limit := queryInt(r, "limit", 30)
|
||||
beforeTs := queryInt64(r, "before", 0) // pagination cursor (unix seconds)
|
||||
|
||||
// Prefer chain-authoritative list (includes soft-deleted flag) so
|
||||
// clients can't be fooled by a stale relay that has an already-
|
||||
// deleted post. If chain isn't wired, fall back to relay index.
|
||||
if cfg.PostsByAuthor != nil {
|
||||
records, err := cfg.PostsByAuthor(pub, beforeTs, limit)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
out := make([]feedAuthorItem, 0, len(records))
|
||||
for _, rec := range records {
|
||||
if rec == nil || rec.Deleted {
|
||||
continue
|
||||
}
|
||||
out = append(out, buildAuthorItem(cfg, rec))
|
||||
}
|
||||
jsonOK(w, map[string]any{"author": pub, "count": len(out), "posts": out})
|
||||
return
|
||||
}
|
||||
// Fallback: relay index (no chain). Doesn't support `before` yet;
|
||||
// the chain-authoritative path above is what production serves.
|
||||
ids, err := cfg.Mailbox.PostsByAuthor(pub, limit)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
out := expandByID(cfg, ids)
|
||||
jsonOK(w, map[string]any{"author": pub, "count": len(out), "posts": out})
|
||||
}
|
||||
}
|
||||
|
||||
// feedAuthorItem is a chain record enriched with the body and live stats.
|
||||
type feedAuthorItem struct {
|
||||
PostID string `json:"post_id"`
|
||||
Author string `json:"author"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Hashtags []string `json:"hashtags,omitempty"`
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
QuoteOf string `json:"quote_of,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Size uint64 `json:"size"`
|
||||
HostingRelay string `json:"hosting_relay"`
|
||||
Views uint64 `json:"views"`
|
||||
Likes uint64 `json:"likes"`
|
||||
HasAttachment bool `json:"has_attachment"`
|
||||
}
|
||||
|
||||
func buildAuthorItem(cfg FeedConfig, rec *blockchain.PostRecord) feedAuthorItem {
|
||||
item := feedAuthorItem{
|
||||
PostID: rec.PostID,
|
||||
Author: rec.Author,
|
||||
ReplyTo: rec.ReplyTo,
|
||||
QuoteOf: rec.QuoteOf,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
Size: rec.Size,
|
||||
HostingRelay: rec.HostingRelay,
|
||||
}
|
||||
if body, _ := cfg.Mailbox.Get(rec.PostID); body != nil {
|
||||
item.Content = body.Content
|
||||
item.ContentType = body.ContentType
|
||||
item.Hashtags = body.Hashtags
|
||||
item.HasAttachment = len(body.Attachment) > 0
|
||||
}
|
||||
if cfg.LikeCount != nil {
|
||||
item.Likes, _ = cfg.LikeCount(rec.PostID)
|
||||
}
|
||||
item.Views, _ = cfg.Mailbox.ViewCount(rec.PostID)
|
||||
return item
|
||||
}
|
||||
|
||||
// expandByID fetches bodies+stats for a list of post IDs (no chain record).
|
||||
func expandByID(cfg FeedConfig, ids []string) []feedAuthorItem {
|
||||
out := make([]feedAuthorItem, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
body, _ := cfg.Mailbox.Get(id)
|
||||
if body == nil {
|
||||
continue
|
||||
}
|
||||
item := feedAuthorItem{
|
||||
PostID: id,
|
||||
Author: body.Author,
|
||||
Content: body.Content,
|
||||
ContentType: body.ContentType,
|
||||
Hashtags: body.Hashtags,
|
||||
ReplyTo: body.ReplyTo,
|
||||
QuoteOf: body.QuoteOf,
|
||||
CreatedAt: body.CreatedAt,
|
||||
HasAttachment: len(body.Attachment) > 0,
|
||||
}
|
||||
if cfg.LikeCount != nil {
|
||||
item.Likes, _ = cfg.LikeCount(id)
|
||||
}
|
||||
item.Views, _ = cfg.Mailbox.ViewCount(id)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── GET /feed/timeline ────────────────────────────────────────────────────
|
||||
|
||||
func feedTimeline(cfg FeedConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
follower := r.URL.Query().Get("follower")
|
||||
if follower == "" {
|
||||
jsonErr(w, fmt.Errorf("follower parameter required"), 400)
|
||||
return
|
||||
}
|
||||
if cfg.Following == nil || cfg.PostsByAuthor == nil {
|
||||
jsonErr(w, fmt.Errorf("timeline requires chain queries"), 503)
|
||||
return
|
||||
}
|
||||
limit := queryInt(r, "limit", 30)
|
||||
beforeTs := queryInt64(r, "before", 0) // pagination cursor
|
||||
perAuthor := limit
|
||||
if perAuthor > 30 {
|
||||
perAuthor = 30
|
||||
}
|
||||
|
||||
following, err := cfg.Following(follower)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
var merged []*blockchain.PostRecord
|
||||
for _, target := range following {
|
||||
posts, err := cfg.PostsByAuthor(target, beforeTs, perAuthor)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range posts {
|
||||
if p != nil && !p.Deleted {
|
||||
merged = append(merged, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort newest-first, take top N.
|
||||
sort.Slice(merged, func(i, j int) bool { return merged[i].CreatedAt > merged[j].CreatedAt })
|
||||
if len(merged) > limit {
|
||||
merged = merged[:limit]
|
||||
}
|
||||
out := make([]feedAuthorItem, 0, len(merged))
|
||||
for _, rec := range merged {
|
||||
out = append(out, buildAuthorItem(cfg, rec))
|
||||
}
|
||||
jsonOK(w, map[string]any{"count": len(out), "posts": out})
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /feed/trending ────────────────────────────────────────────────────
|
||||
|
||||
func feedTrending(cfg FeedConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
limit := queryInt(r, "limit", 30)
|
||||
// Window defaults to 24h; cap 7d so a viral post from a week ago
|
||||
// doesn't permanently dominate.
|
||||
windowHours := queryInt(r, "window", 24)
|
||||
if windowHours > 24*7 {
|
||||
windowHours = 24 * 7
|
||||
}
|
||||
if windowHours < 1 {
|
||||
windowHours = 1
|
||||
}
|
||||
ids, err := cfg.Mailbox.RecentPostIDs(int64(windowHours)*3600, 500)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
// Score each = likes*3 + views, honoring soft-delete.
|
||||
type scored struct {
|
||||
id string
|
||||
score uint64
|
||||
}
|
||||
scoredList := make([]scored, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if cfg.GetPost != nil {
|
||||
if rec, _ := cfg.GetPost(id); rec != nil && rec.Deleted {
|
||||
continue
|
||||
}
|
||||
}
|
||||
views, _ := cfg.Mailbox.ViewCount(id)
|
||||
var likes uint64
|
||||
if cfg.LikeCount != nil {
|
||||
likes, _ = cfg.LikeCount(id)
|
||||
}
|
||||
scoredList = append(scoredList, scored{id: id, score: likes*3 + views})
|
||||
}
|
||||
sort.Slice(scoredList, func(i, j int) bool { return scoredList[i].score > scoredList[j].score })
|
||||
if len(scoredList) > limit {
|
||||
scoredList = scoredList[:limit]
|
||||
}
|
||||
pickedIDs := make([]string, len(scoredList))
|
||||
for i, s := range scoredList {
|
||||
pickedIDs[i] = s.id
|
||||
}
|
||||
out := expandByID(cfg, pickedIDs)
|
||||
jsonOK(w, map[string]any{"count": len(out), "posts": out})
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /feed/foryou ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Simple recommendations heuristic for v2.0.0:
|
||||
// 1. Compute the set of authors the user already follows.
|
||||
// 2. Fetch recent posts from the relay (last 48h).
|
||||
// 3. Filter OUT posts from followed authors (those live in /timeline).
|
||||
// 4. Filter OUT posts the user has already liked.
|
||||
// 5. Rank remaining by (likes × 3 + views) and return top N.
|
||||
//
|
||||
// Future improvements (tracked as v2.2.0 "Feed algorithm"):
|
||||
// - Weight by "followed-of-followed" signal (friends-of-friends boost).
|
||||
// - Decay by age (exp half-life ~12h).
|
||||
// - Penalise self-engagement (author liking own post).
|
||||
// - Collaborative filtering on hashtag co-occurrence.
|
||||
|
||||
func feedForYou(cfg FeedConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
pub := r.URL.Query().Get("pub")
|
||||
limit := queryInt(r, "limit", 30)
|
||||
|
||||
// Gather user's follows + likes to exclude from the candidate pool.
|
||||
excludedAuthors := make(map[string]struct{})
|
||||
if cfg.Following != nil && pub != "" {
|
||||
if list, err := cfg.Following(pub); err == nil {
|
||||
for _, a := range list {
|
||||
excludedAuthors[a] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Post pool: last 48h on this relay.
|
||||
ids, err := cfg.Mailbox.RecentPostIDs(48*3600, 500)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
type scored struct {
|
||||
id string
|
||||
score uint64
|
||||
}
|
||||
scoredList := make([]scored, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
body, _ := cfg.Mailbox.Get(id)
|
||||
if body == nil {
|
||||
continue
|
||||
}
|
||||
if _, followed := excludedAuthors[body.Author]; followed {
|
||||
continue
|
||||
}
|
||||
if body.Author == pub {
|
||||
continue // don't recommend user's own posts
|
||||
}
|
||||
if cfg.GetPost != nil {
|
||||
if rec, _ := cfg.GetPost(id); rec != nil && rec.Deleted {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Skip already-liked.
|
||||
if cfg.HasLiked != nil && pub != "" {
|
||||
if liked, _ := cfg.HasLiked(id, pub); liked {
|
||||
continue
|
||||
}
|
||||
}
|
||||
views, _ := cfg.Mailbox.ViewCount(id)
|
||||
var likes uint64
|
||||
if cfg.LikeCount != nil {
|
||||
likes, _ = cfg.LikeCount(id)
|
||||
}
|
||||
// Small "seed" score so posts with no engagement still get shown
|
||||
// sometimes (otherwise a silent but fresh post can't break in).
|
||||
scoredList = append(scoredList, scored{id: id, score: likes*3 + views + 1})
|
||||
}
|
||||
sort.Slice(scoredList, func(i, j int) bool { return scoredList[i].score > scoredList[j].score })
|
||||
if len(scoredList) > limit {
|
||||
scoredList = scoredList[:limit]
|
||||
}
|
||||
pickedIDs := make([]string, len(scoredList))
|
||||
for i, s := range scoredList {
|
||||
pickedIDs[i] = s.id
|
||||
}
|
||||
out := expandByID(cfg, pickedIDs)
|
||||
jsonOK(w, map[string]any{"count": len(out), "posts": out})
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /feed/hashtag/{tag} ──────────────────────────────────────────────
|
||||
|
||||
func feedHashtag(cfg FeedConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
||||
return
|
||||
}
|
||||
tag := strings.TrimPrefix(r.URL.Path, "/feed/hashtag/")
|
||||
tag = strings.Trim(tag, "/")
|
||||
if tag == "" {
|
||||
jsonErr(w, fmt.Errorf("tag required"), 400)
|
||||
return
|
||||
}
|
||||
limit := queryInt(r, "limit", 50)
|
||||
ids, err := cfg.Mailbox.PostsByHashtag(tag, limit)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
out := expandByID(cfg, ids)
|
||||
jsonOK(w, map[string]any{"tag": strings.ToLower(tag), "count": len(out), "posts": out})
|
||||
}
|
||||
}
|
||||
|
||||
// (queryInt helper is shared with the rest of the node HTTP surface;
|
||||
// see api_common.go.)
|
||||
@@ -2,6 +2,7 @@ package node
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-blockchain/blockchain"
|
||||
"go-blockchain/identity"
|
||||
"go-blockchain/relay"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,12 @@ type RelayConfig struct {
|
||||
|
||||
// ContactRequests returns incoming contact records for the given Ed25519 pubkey.
|
||||
ContactRequests func(pubKey string) ([]blockchain.ContactInfo, error)
|
||||
|
||||
// ResolveX25519 returns the X25519 hex published by the Ed25519 identity,
|
||||
// or "" if the identity has not registered or does not exist. Used by
|
||||
// authenticated mutating endpoints (e.g. DELETE /relay/inbox) to link a
|
||||
// signing key back to its mailbox pubkey. nil disables those endpoints.
|
||||
ResolveX25519 func(ed25519PubHex string) string
|
||||
}
|
||||
|
||||
// registerRelayRoutes wires relay mailbox endpoints onto mux.
|
||||
@@ -37,12 +45,19 @@ type RelayConfig struct {
|
||||
// DELETE /relay/inbox/{envID} ?pub=<x25519hex>
|
||||
// GET /relay/contacts ?pub=<ed25519hex>
|
||||
func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
|
||||
mux.HandleFunc("/relay/send", relaySend(rc))
|
||||
mux.HandleFunc("/relay/broadcast", relayBroadcast(rc))
|
||||
mux.HandleFunc("/relay/inbox/count", relayInboxCount(rc))
|
||||
mux.HandleFunc("/relay/inbox/", relayInboxDelete(rc))
|
||||
mux.HandleFunc("/relay/inbox", relayInboxList(rc))
|
||||
mux.HandleFunc("/relay/contacts", relayContacts(rc))
|
||||
// Writes go through withSubmitTxGuards: per-IP rate limit (10/s, burst 20)
|
||||
// + 256 KiB body cap. Without these, a single attacker could spam
|
||||
// 500 envelopes per victim in a few seconds and evict every real message
|
||||
// via the mailbox FIFO cap.
|
||||
mux.HandleFunc("/relay/send", withSubmitTxGuards(relaySend(rc)))
|
||||
mux.HandleFunc("/relay/broadcast", withSubmitTxGuards(relayBroadcast(rc)))
|
||||
|
||||
// Reads go through withReadLimit: per-IP rate limit (20/s, burst 40).
|
||||
// Protects against inbox-scraping floods from a single origin.
|
||||
mux.HandleFunc("/relay/inbox/count", withReadLimit(relayInboxCount(rc)))
|
||||
mux.HandleFunc("/relay/inbox/", withReadLimit(relayInboxDelete(rc)))
|
||||
mux.HandleFunc("/relay/inbox", withReadLimit(relayInboxList(rc)))
|
||||
mux.HandleFunc("/relay/contacts", withReadLimit(relayContacts(rc)))
|
||||
}
|
||||
|
||||
// relayInboxList handles GET /relay/inbox?pub=<hex>[&since=<ts>][&limit=N]
|
||||
@@ -109,8 +124,24 @@ func relayInboxList(rc RelayConfig) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<hex>
|
||||
// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<x25519hex>
|
||||
//
|
||||
// Auth model:
|
||||
// Query: ?pub=<x25519hex>
|
||||
// Body: {"ed25519_pub":"<hex>", "sig":"<base64>", "ts":<unix_seconds>}
|
||||
// sig = Ed25519(privEd25519,
|
||||
// "inbox-delete:" + envID + ":" + x25519Pub + ":" + ts)
|
||||
// ts must be within ±5 minutes of server clock (anti-replay).
|
||||
//
|
||||
// Server then:
|
||||
// 1. Verifies sig over the canonical bytes above.
|
||||
// 2. Looks up identity(ed25519_pub).X25519Pub — must equal the ?pub= query.
|
||||
//
|
||||
// This links the signing key to the mailbox key without exposing the user's
|
||||
// X25519 private material.
|
||||
func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
|
||||
const inboxDeleteSkewSecs = 300 // ±5 minutes
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
// Also serve GET /relay/inbox/{id} for convenience (fetch single envelope)
|
||||
@@ -133,6 +164,61 @@ func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Auth. Unauthenticated DELETE historically let anyone wipe any
|
||||
// mailbox by just knowing the pub — fixed in v1.0.2 via signed
|
||||
// Ed25519 identity linked to the x25519 via identity registry.
|
||||
if rc.ResolveX25519 == nil {
|
||||
jsonErr(w, fmt.Errorf("mailbox delete not available on this node"), 503)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Ed25519Pub string `json:"ed25519_pub"`
|
||||
Sig string `json:"sig"`
|
||||
Ts int64 `json:"ts"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid JSON body: %w", err), 400)
|
||||
return
|
||||
}
|
||||
if body.Ed25519Pub == "" || body.Sig == "" || body.Ts == 0 {
|
||||
jsonErr(w, fmt.Errorf("ed25519_pub, sig, ts are required"), 400)
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if body.Ts < now-inboxDeleteSkewSecs || body.Ts > now+inboxDeleteSkewSecs {
|
||||
jsonErr(w, fmt.Errorf("timestamp out of range (±%ds)", inboxDeleteSkewSecs), 400)
|
||||
return
|
||||
}
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(body.Sig)
|
||||
if err != nil {
|
||||
// Also try URL-safe for defensive UX.
|
||||
sigBytes, err = base64.RawURLEncoding.DecodeString(body.Sig)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("sig: invalid base64"), 400)
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := hex.DecodeString(body.Ed25519Pub); err != nil {
|
||||
jsonErr(w, fmt.Errorf("ed25519_pub: invalid hex"), 400)
|
||||
return
|
||||
}
|
||||
msg := []byte(fmt.Sprintf("inbox-delete:%s:%s:%d", envID, pub, body.Ts))
|
||||
ok, err := identity.Verify(body.Ed25519Pub, msg, sigBytes)
|
||||
if err != nil || !ok {
|
||||
jsonErr(w, fmt.Errorf("signature invalid"), 403)
|
||||
return
|
||||
}
|
||||
// Link ed25519 → x25519 via identity registry.
|
||||
registeredX := rc.ResolveX25519(body.Ed25519Pub)
|
||||
if registeredX == "" {
|
||||
jsonErr(w, fmt.Errorf("identity has no registered X25519 key"), 403)
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(registeredX, pub) {
|
||||
jsonErr(w, fmt.Errorf("pub does not match identity's registered X25519"), 403)
|
||||
return
|
||||
}
|
||||
|
||||
if err := rc.Mailbox.Delete(pub, envID); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
@@ -167,9 +253,16 @@ func relayInboxCount(rc RelayConfig) http.HandlerFunc {
|
||||
// "msg_b64": "<base64-encoded plaintext>",
|
||||
// }
|
||||
//
|
||||
// The relay node seals the message using its own X25519 keypair and broadcasts
|
||||
// it on the relay gossipsub topic. No on-chain fee is attached — delivery is
|
||||
// free for light clients using this endpoint.
|
||||
// WARNING — NOT END-TO-END ENCRYPTED.
|
||||
// The relay node seals the message using its OWN X25519 keypair, not the
|
||||
// sender's. That means:
|
||||
// - The relay can read the plaintext (msg_b64 arrives in the clear).
|
||||
// - The recipient cannot authenticate the sender — they only see "a
|
||||
// message from the relay".
|
||||
// For real E2E messaging, clients should seal the envelope themselves and
|
||||
// use POST /relay/broadcast instead. This endpoint is retained only for
|
||||
// backwards compatibility with legacy integrations and for bootstrap
|
||||
// scenarios where the sender doesn't have a long-lived X25519 key yet.
|
||||
func relaySend(rc RelayConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
|
||||
@@ -79,12 +79,6 @@ type ExplorerQuery struct {
|
||||
GetNFTs func() ([]blockchain.NFTRecord, error)
|
||||
NFTsByOwner func(ownerPub string) ([]blockchain.NFTRecord, error)
|
||||
|
||||
// Channel group-messaging lookups (R1). GetChannel returns metadata;
|
||||
// GetChannelMembers returns the Ed25519 pubkey of every current member.
|
||||
// Both may be nil on nodes that don't expose channel state (tests).
|
||||
GetChannel func(channelID string) (*blockchain.CreateChannelPayload, error)
|
||||
GetChannelMembers func(channelID string) ([]string, error)
|
||||
|
||||
// Events is the SSE hub for the live event stream. Optional — if nil the
|
||||
// /api/events endpoint returns 501 Not Implemented.
|
||||
Events *SSEHub
|
||||
@@ -127,7 +121,6 @@ func RegisterExplorerRoutes(mux *http.ServeMux, q ExplorerQuery, flags ...Explor
|
||||
registerUpdateCheckAPI(mux, q)
|
||||
registerOnboardingAPI(mux, q)
|
||||
registerTokenAPI(mux, q)
|
||||
registerChannelAPI(mux, q)
|
||||
if !f.DisableSwagger {
|
||||
registerSwaggerRoutes(mux)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// returned forever (even if the implementation moves around internally). The
|
||||
// client uses them as "is this feature here or not?", not "what version is
|
||||
// this feature at?". Versioning a feature is done by shipping a new tag
|
||||
// (e.g. "channels_v2" alongside "channels_v1" for a deprecation window).
|
||||
// (e.g. "feed_v3" alongside "feed_v2" for a deprecation window).
|
||||
//
|
||||
// Response shape:
|
||||
//
|
||||
@@ -55,15 +55,17 @@ const ProtocolVersion = 1
|
||||
// a breaking successor (e.g. `channels_v1`, not `channels`).
|
||||
var nodeFeatures = []string{
|
||||
"access_token", // DCHAIN_API_TOKEN gating on writes (+ optional reads)
|
||||
"channels_v1", // /api/channels/:id + /members with X25519 enrichment
|
||||
"chain_id", // /api/network-info returns chain_id
|
||||
"contract_logs", // /api/contract/:id/logs endpoint
|
||||
"fan_out", // client-side per-recipient envelope sealing
|
||||
"feed_v2", // social feed: posts, likes, follows, timeline, trending, for-you
|
||||
"identity_registry", // /api/identity/:pub returns X25519 pub + relay hints
|
||||
"media_scrub", // mandatory EXIF/GPS strip on /feed/publish
|
||||
"native_username_registry", // native:username_registry contract
|
||||
"onboarding_api", // /api/network-info for joiner bootstrap
|
||||
"payment_channels", // off-chain payment channel open/close
|
||||
"relay_mailbox", // /relay/send + /relay/inbox
|
||||
"relay_broadcast", // /relay/broadcast (E2E envelope publish)
|
||||
"relay_mailbox", // /relay/inbox (read), /relay/send (legacy non-E2E)
|
||||
"ws_submit_tx", // WebSocket submit_tx op
|
||||
}
|
||||
|
||||
|
||||
831
node/feed_e2e_test.go
Normal file
831
node/feed_e2e_test.go
Normal file
@@ -0,0 +1,831 @@
|
||||
// End-to-end integration tests for the social feed (v2.0.0).
|
||||
//
|
||||
// These tests exercise the full HTTP surface against a real in-process
|
||||
// setup: a BadgerDB chain, a BadgerDB feed-mailbox, the media scrubber,
|
||||
// and a net/http ServeMux with all feed routes wired. Requests hit the
|
||||
// real handlers (including rate-limiters, auth, and scrubber) so we
|
||||
// catch wire-level regressions that unit tests miss.
|
||||
//
|
||||
// Layout of a typical test:
|
||||
//
|
||||
// h := newFeedHarness(t)
|
||||
// defer h.Close()
|
||||
// author := h.newUser("alice")
|
||||
// h.fund(author, 1_000_000) // give them tokens
|
||||
// resp := h.publish(author, "Hello #world", nil) // POST /feed/publish
|
||||
// h.commitCreatePost(author, resp) // chain tx
|
||||
// got := h.getPost(resp.PostID)
|
||||
// ...
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-blockchain/blockchain"
|
||||
"go-blockchain/identity"
|
||||
"go-blockchain/media"
|
||||
"go-blockchain/relay"
|
||||
)
|
||||
|
||||
// ── Harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
type feedHarness struct {
|
||||
t *testing.T
|
||||
|
||||
chainDir string
|
||||
feedDir string
|
||||
chain *blockchain.Chain
|
||||
mailbox *relay.FeedMailbox
|
||||
scrubber *media.Scrubber
|
||||
server *httptest.Server
|
||||
validator *identity.Identity
|
||||
tip *blockchain.Block
|
||||
}
|
||||
|
||||
func newFeedHarness(t *testing.T) *feedHarness {
|
||||
t.Helper()
|
||||
chainDir, err := os.MkdirTemp("", "dchain-e2e-chain-*")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp chain: %v", err)
|
||||
}
|
||||
feedDir, err := os.MkdirTemp("", "dchain-e2e-feed-*")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp feed: %v", err)
|
||||
}
|
||||
c, err := blockchain.NewChain(chainDir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewChain: %v", err)
|
||||
}
|
||||
fm, err := relay.OpenFeedMailbox(feedDir, 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenFeedMailbox: %v", err)
|
||||
}
|
||||
|
||||
validator, err := identity.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("identity.Generate: %v", err)
|
||||
}
|
||||
// Bootstrap a genesis block so the validator has funds to disburse.
|
||||
genesis := blockchain.GenesisBlock(validator.PubKeyHex(), validator.PrivKey)
|
||||
if err := c.AddBlock(genesis); err != nil {
|
||||
t.Fatalf("AddBlock genesis: %v", err)
|
||||
}
|
||||
|
||||
scrubber := media.NewScrubber(media.SidecarConfig{}) // no sidecar — images only
|
||||
cfg := FeedConfig{
|
||||
Mailbox: fm,
|
||||
HostingRelayPub: validator.PubKeyHex(),
|
||||
Scrubber: scrubber,
|
||||
AllowUnscrubbedVideo: false,
|
||||
GetPost: c.Post,
|
||||
LikeCount: c.LikeCount,
|
||||
HasLiked: c.HasLiked,
|
||||
PostsByAuthor: c.PostsByAuthor,
|
||||
Following: c.Following,
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
RegisterFeedRoutes(mux, cfg)
|
||||
srv := httptest.NewServer(mux)
|
||||
|
||||
h := &feedHarness{
|
||||
t: t, chainDir: chainDir, feedDir: feedDir,
|
||||
chain: c, mailbox: fm, scrubber: scrubber,
|
||||
server: srv, validator: validator, tip: genesis,
|
||||
}
|
||||
t.Cleanup(h.Close)
|
||||
return h
|
||||
}
|
||||
|
||||
// Close releases all handles and removes the temp directories. Safe to
|
||||
// call multiple times.
|
||||
func (h *feedHarness) Close() {
|
||||
if h.server != nil {
|
||||
h.server.Close()
|
||||
h.server = nil
|
||||
}
|
||||
if h.mailbox != nil {
|
||||
_ = h.mailbox.Close()
|
||||
h.mailbox = nil
|
||||
}
|
||||
if h.chain != nil {
|
||||
_ = h.chain.Close()
|
||||
h.chain = nil
|
||||
}
|
||||
// Retry because Windows holds mmap files briefly after Close.
|
||||
for _, dir := range []string{h.chainDir, h.feedDir} {
|
||||
for i := 0; i < 20; i++ {
|
||||
if err := os.RemoveAll(dir); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newUser generates a fresh identity. Not funded — call fund() separately.
|
||||
func (h *feedHarness) newUser(label string) *identity.Identity {
|
||||
h.t.Helper()
|
||||
id, err := identity.Generate()
|
||||
if err != nil {
|
||||
h.t.Fatalf("%s identity: %v", label, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// fund sends `amount` µT from the genesis validator to `target`, committing
|
||||
// the transfer in its own block.
|
||||
func (h *feedHarness) fund(target *identity.Identity, amount uint64) {
|
||||
h.t.Helper()
|
||||
tx := &blockchain.Transaction{
|
||||
ID: h.nextTxID(h.validator.PubKeyHex(), blockchain.EventTransfer),
|
||||
Type: blockchain.EventTransfer,
|
||||
From: h.validator.PubKeyHex(),
|
||||
To: target.PubKeyHex(),
|
||||
Amount: amount,
|
||||
Fee: blockchain.MinFee,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
h.commit(tx)
|
||||
}
|
||||
|
||||
// commit wraps one or more txs into a block, signs, and appends.
|
||||
func (h *feedHarness) commit(txs ...*blockchain.Transaction) {
|
||||
h.t.Helper()
|
||||
// Small sleep to guarantee distinct tx IDs across calls.
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
var totalFees uint64
|
||||
for _, tx := range txs {
|
||||
totalFees += tx.Fee
|
||||
}
|
||||
b := &blockchain.Block{
|
||||
Index: h.tip.Index + 1,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Transactions: txs,
|
||||
PrevHash: h.tip.Hash,
|
||||
Validator: h.validator.PubKeyHex(),
|
||||
TotalFees: totalFees,
|
||||
}
|
||||
b.ComputeHash()
|
||||
b.Sign(h.validator.PrivKey)
|
||||
if err := h.chain.AddBlock(b); err != nil {
|
||||
h.t.Fatalf("AddBlock: %v", err)
|
||||
}
|
||||
h.tip = b
|
||||
}
|
||||
|
||||
func (h *feedHarness) nextTxID(from string, typ blockchain.EventType) string {
|
||||
// Hash (from, type, now_nanos) for uniqueness.
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", from, typ, time.Now().UnixNano())))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// publish POSTs /feed/publish as `author` with signed request body. On
|
||||
// success returns the server's response so the caller can commit the
|
||||
// on-chain CREATE_POST with matching metadata.
|
||||
func (h *feedHarness) publish(author *identity.Identity, content string, attachment []byte) feedPublishResponse {
|
||||
h.t.Helper()
|
||||
attachB64 := ""
|
||||
attachMIME := ""
|
||||
if len(attachment) > 0 {
|
||||
attachB64 = base64.StdEncoding.EncodeToString(attachment)
|
||||
attachMIME = "image/jpeg"
|
||||
}
|
||||
// Client-side hash matches the server's canonical bytes rule:
|
||||
// publish:<post_id>:<sha256(content||attachment) hex>:<ts>
|
||||
// The client knows its own attachment before any server-side scrub,
|
||||
// so this is the hash over the "raw upload". The server recomputes
|
||||
// over SCRUBBED bytes and returns that as content_hash — the client
|
||||
// then uses server's number for CREATE_POST.
|
||||
idHash := sha256.Sum256([]byte(fmt.Sprintf("%s-%d-%s",
|
||||
author.PubKeyHex(), time.Now().UnixNano(), content)))
|
||||
postID := hex.EncodeToString(idHash[:16])
|
||||
// Build signature over CLIENT-side hash.
|
||||
h256 := sha256.New()
|
||||
h256.Write([]byte(content))
|
||||
h256.Write(attachment)
|
||||
clientHash := hex.EncodeToString(h256.Sum(nil))
|
||||
ts := time.Now().Unix()
|
||||
sigBytes := author.Sign([]byte(fmt.Sprintf("publish:%s:%s:%d", postID, clientHash, ts)))
|
||||
|
||||
req := feedPublishRequest{
|
||||
PostID: postID,
|
||||
Author: author.PubKeyHex(),
|
||||
Content: content,
|
||||
AttachmentB64: attachB64,
|
||||
AttachmentMIME: attachMIME,
|
||||
Sig: base64.StdEncoding.EncodeToString(sigBytes),
|
||||
Ts: ts,
|
||||
}
|
||||
var resp feedPublishResponse
|
||||
h.postJSON("/feed/publish", req, &resp)
|
||||
return resp
|
||||
}
|
||||
|
||||
// commitCreatePost sends the on-chain CREATE_POST tx that pays the
|
||||
// hosting relay (this node's validator in the harness). Must be called
|
||||
// after publish() so the two agree on the content hash and size.
|
||||
func (h *feedHarness) commitCreatePost(author *identity.Identity, pub feedPublishResponse) {
|
||||
h.t.Helper()
|
||||
contentHash, err := hex.DecodeString(pub.ContentHash)
|
||||
if err != nil {
|
||||
h.t.Fatalf("decode content hash: %v", err)
|
||||
}
|
||||
payload := blockchain.CreatePostPayload{
|
||||
PostID: pub.PostID,
|
||||
ContentHash: contentHash,
|
||||
Size: pub.Size,
|
||||
HostingRelay: pub.HostingRelay,
|
||||
}
|
||||
pbytes, _ := json.Marshal(payload)
|
||||
tx := &blockchain.Transaction{
|
||||
ID: h.nextTxID(author.PubKeyHex(), blockchain.EventCreatePost),
|
||||
Type: blockchain.EventCreatePost,
|
||||
From: author.PubKeyHex(),
|
||||
Amount: 0,
|
||||
Fee: pub.EstimatedFeeUT,
|
||||
Payload: pbytes,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
h.commit(tx)
|
||||
}
|
||||
|
||||
// like / unlike / follow / unfollow helpers — all just small tx builders.
|
||||
|
||||
func (h *feedHarness) like(liker *identity.Identity, postID string) {
|
||||
payload, _ := json.Marshal(blockchain.LikePostPayload{PostID: postID})
|
||||
tx := &blockchain.Transaction{
|
||||
ID: h.nextTxID(liker.PubKeyHex(), blockchain.EventLikePost),
|
||||
Type: blockchain.EventLikePost,
|
||||
From: liker.PubKeyHex(),
|
||||
Fee: blockchain.MinFee,
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
h.commit(tx)
|
||||
}
|
||||
|
||||
func (h *feedHarness) follow(follower *identity.Identity, target string) {
|
||||
tx := &blockchain.Transaction{
|
||||
ID: h.nextTxID(follower.PubKeyHex(), blockchain.EventFollow),
|
||||
Type: blockchain.EventFollow,
|
||||
From: follower.PubKeyHex(),
|
||||
To: target,
|
||||
Fee: blockchain.MinFee,
|
||||
Payload: []byte(`{}`),
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
h.commit(tx)
|
||||
}
|
||||
|
||||
// deletePost commits an on-chain EventDeletePost for the given post,
|
||||
// signed by the author.
|
||||
func (h *feedHarness) deletePost(author *identity.Identity, postID string) {
|
||||
payload, _ := json.Marshal(blockchain.DeletePostPayload{PostID: postID})
|
||||
tx := &blockchain.Transaction{
|
||||
ID: h.nextTxID(author.PubKeyHex(), blockchain.EventDeletePost),
|
||||
Type: blockchain.EventDeletePost,
|
||||
From: author.PubKeyHex(),
|
||||
Fee: blockchain.MinFee,
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
h.commit(tx)
|
||||
}
|
||||
|
||||
// ── HTTP helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
func (h *feedHarness) postJSON(path string, req any, out any) {
|
||||
h.t.Helper()
|
||||
body, _ := json.Marshal(req)
|
||||
resp, err := http.Post(h.server.URL+path, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
h.t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
h.t.Fatalf("POST %s → %d: %s", path, resp.StatusCode, string(raw))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
h.t.Fatalf("decode %s response: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *feedHarness) postJSONExpectStatus(path string, req any, want int) string {
|
||||
h.t.Helper()
|
||||
body, _ := json.Marshal(req)
|
||||
resp, err := http.Post(h.server.URL+path, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
h.t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != want {
|
||||
h.t.Fatalf("POST %s → %d, want %d: %s", path, resp.StatusCode, want, string(raw))
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func (h *feedHarness) getJSON(path string, out any) {
|
||||
h.t.Helper()
|
||||
resp, err := http.Get(h.server.URL + path)
|
||||
if err != nil {
|
||||
h.t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
h.t.Fatalf("GET %s → %d: %s", path, resp.StatusCode, string(raw))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
h.t.Fatalf("decode %s response: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getStatus fetches path and returns status + body; doesn't fail on non-2xx.
|
||||
func (h *feedHarness) getStatus(path string) (int, string) {
|
||||
resp, err := http.Get(h.server.URL + path)
|
||||
if err != nil {
|
||||
h.t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(raw)
|
||||
}
|
||||
|
||||
// postRaw is for endpoints like /feed/post/{id}/view that take no body.
|
||||
func (h *feedHarness) postRaw(path string, out any) {
|
||||
h.t.Helper()
|
||||
resp, err := http.Post(h.server.URL+path, "application/json", nil)
|
||||
if err != nil {
|
||||
h.t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
h.t.Fatalf("POST %s → %d: %s", path, resp.StatusCode, string(raw))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
h.t.Fatalf("decode %s response: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestE2EFullFlow runs the whole publish → commit → read cycle end-to-end.
|
||||
//
|
||||
// Covers: /feed/publish signature, /feed/post/{id} body fetch, /feed/post/{id}/stats,
|
||||
// /feed/post/{id}/view counter, CREATE_POST fee debit to author + credit to
|
||||
// hosting relay, PostsByAuthor enrichment, DELETE soft-delete → 410.
|
||||
func TestE2EFullFlow(t *testing.T) {
|
||||
h := newFeedHarness(t)
|
||||
|
||||
alice := h.newUser("alice")
|
||||
h.fund(alice, 10*blockchain.Token)
|
||||
|
||||
hostBalBefore, _ := h.chain.Balance(h.validator.PubKeyHex())
|
||||
|
||||
// 1. PUBLISH → body lands in feed mailbox.
|
||||
pub := h.publish(alice, "Hello from the feed #dchain #intro", nil)
|
||||
if pub.PostID == "" || pub.ContentHash == "" {
|
||||
t.Fatalf("publish response missing required fields: %+v", pub)
|
||||
}
|
||||
if pub.HostingRelay != h.validator.PubKeyHex() {
|
||||
t.Errorf("hosting_relay: got %s, want %s", pub.HostingRelay, h.validator.PubKeyHex())
|
||||
}
|
||||
wantTags := []string{"dchain", "intro"}
|
||||
if len(pub.Hashtags) != len(wantTags) {
|
||||
t.Errorf("hashtags: got %v, want %v", pub.Hashtags, wantTags)
|
||||
}
|
||||
|
||||
// Before the CREATE_POST tx lands the body is available but /stats
|
||||
// says 0 likes. That's the expected "just published, not committed" state.
|
||||
|
||||
// 2. COMMIT on-chain CREATE_POST tx.
|
||||
h.commitCreatePost(alice, pub)
|
||||
|
||||
// Hosting relay should have been credited tx.Fee.
|
||||
hostBalAfter, _ := h.chain.Balance(h.validator.PubKeyHex())
|
||||
if hostBalAfter <= hostBalBefore {
|
||||
t.Errorf("hosting relay balance did not increase after CREATE_POST: %d → %d",
|
||||
hostBalBefore, hostBalAfter)
|
||||
}
|
||||
|
||||
// 3. READ via HTTP — body comes back.
|
||||
var got struct {
|
||||
PostID string `json:"post_id"`
|
||||
Author string `json:"author"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
h.getJSON("/feed/post/"+pub.PostID, &got)
|
||||
if got.Content != "Hello from the feed #dchain #intro" {
|
||||
t.Errorf("content: got %q, want original", got.Content)
|
||||
}
|
||||
if got.Author != alice.PubKeyHex() {
|
||||
t.Errorf("author: got %s, want %s", got.Author, alice.PubKeyHex())
|
||||
}
|
||||
|
||||
// 4. VIEW COUNTER increments.
|
||||
var viewResp struct {
|
||||
Views uint64 `json:"views"`
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
h.postRaw("/feed/post/"+pub.PostID+"/view", &viewResp)
|
||||
if viewResp.Views != uint64(i) {
|
||||
t.Errorf("views #%d: got %d, want %d", i, viewResp.Views, i)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. STATS aggregate is correct.
|
||||
var stats postStatsResponse
|
||||
h.getJSON("/feed/post/"+pub.PostID+"/stats", &stats)
|
||||
if stats.Views != 3 {
|
||||
t.Errorf("stats.views: got %d, want 3", stats.Views)
|
||||
}
|
||||
if stats.Likes != 0 {
|
||||
t.Errorf("stats.likes: got %d, want 0", stats.Likes)
|
||||
}
|
||||
|
||||
// 6. AUTHOR listing merges chain record + body + stats.
|
||||
var authorResp struct {
|
||||
Count int `json:"count"`
|
||||
Posts []feedAuthorItem `json:"posts"`
|
||||
}
|
||||
h.getJSON("/feed/author/"+alice.PubKeyHex(), &authorResp)
|
||||
if authorResp.Count != 1 {
|
||||
t.Fatalf("author count: got %d, want 1", authorResp.Count)
|
||||
}
|
||||
if authorResp.Posts[0].Views != 3 {
|
||||
t.Errorf("author post views: got %d, want 3", authorResp.Posts[0].Views)
|
||||
}
|
||||
if len(authorResp.Posts[0].Hashtags) != 2 {
|
||||
t.Errorf("author post hashtags: got %v, want 2", authorResp.Posts[0].Hashtags)
|
||||
}
|
||||
|
||||
// 7. DELETE → body stays in mailbox but chain marks deleted → 410 on fetch.
|
||||
h.deletePost(alice, pub.PostID)
|
||||
status, body := h.getStatus("/feed/post/" + pub.PostID)
|
||||
if status != http.StatusGone {
|
||||
t.Errorf("GET deleted post: got status %d, want 410; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2ELikeUnlikeAffectsStats: on-chain LIKE_POST updates /stats.
|
||||
func TestE2ELikeUnlikeAffectsStats(t *testing.T) {
|
||||
h := newFeedHarness(t)
|
||||
alice := h.newUser("alice")
|
||||
bob := h.newUser("bob")
|
||||
h.fund(alice, 10*blockchain.Token)
|
||||
h.fund(bob, 10*blockchain.Token)
|
||||
|
||||
pub := h.publish(alice, "likeable", nil)
|
||||
h.commitCreatePost(alice, pub)
|
||||
|
||||
// Bob likes alice's post.
|
||||
h.like(bob, pub.PostID)
|
||||
|
||||
var stats postStatsResponse
|
||||
h.getJSON("/feed/post/"+pub.PostID+"/stats?me="+bob.PubKeyHex(), &stats)
|
||||
if stats.Likes != 1 {
|
||||
t.Errorf("likes after like: got %d, want 1", stats.Likes)
|
||||
}
|
||||
if stats.LikedByMe == nil || !*stats.LikedByMe {
|
||||
t.Errorf("liked_by_me: got %v, want true", stats.LikedByMe)
|
||||
}
|
||||
|
||||
// And a non-liker sees liked_by_me=false.
|
||||
carol := h.newUser("carol")
|
||||
h.getJSON("/feed/post/"+pub.PostID+"/stats?me="+carol.PubKeyHex(), &stats)
|
||||
if stats.LikedByMe == nil || *stats.LikedByMe {
|
||||
t.Errorf("liked_by_me for carol: got %v, want false", stats.LikedByMe)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2ETimeline: follow graph merges posts newest-first.
|
||||
func TestE2ETimeline(t *testing.T) {
|
||||
h := newFeedHarness(t)
|
||||
alice := h.newUser("alice")
|
||||
bob := h.newUser("bob")
|
||||
carol := h.newUser("carol")
|
||||
// Fund everyone.
|
||||
for _, u := range []*identity.Identity{alice, bob, carol} {
|
||||
h.fund(u, 10*blockchain.Token)
|
||||
}
|
||||
|
||||
// Alice follows bob + carol.
|
||||
h.follow(alice, bob.PubKeyHex())
|
||||
h.follow(alice, carol.PubKeyHex())
|
||||
|
||||
// Bob + carol each publish a post. Sleep 1.1s between so the tx
|
||||
// timestamps land in distinct unix seconds — the chain chrono index
|
||||
// is second-resolution, not millisecond.
|
||||
pubBob := h.publish(bob, "post from bob", nil)
|
||||
h.commitCreatePost(bob, pubBob)
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
pubCarol := h.publish(carol, "post from carol", nil)
|
||||
h.commitCreatePost(carol, pubCarol)
|
||||
|
||||
var tl struct {
|
||||
Count int `json:"count"`
|
||||
Posts []feedAuthorItem `json:"posts"`
|
||||
}
|
||||
h.getJSON("/feed/timeline?follower="+alice.PubKeyHex(), &tl)
|
||||
if tl.Count != 2 {
|
||||
t.Fatalf("timeline count: got %d, want 2", tl.Count)
|
||||
}
|
||||
// Newest first — carol was published last, so her post should be [0].
|
||||
if tl.Posts[0].PostID != pubCarol.PostID {
|
||||
t.Errorf("timeline[0]: got %s, want carol's post %s", tl.Posts[0].PostID, pubCarol.PostID)
|
||||
}
|
||||
if tl.Posts[1].PostID != pubBob.PostID {
|
||||
t.Errorf("timeline[1]: got %s, want bob's post %s", tl.Posts[1].PostID, pubBob.PostID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2ETrendingRanking: post with more engagement floats to the top.
|
||||
func TestE2ETrendingRanking(t *testing.T) {
|
||||
h := newFeedHarness(t)
|
||||
alice := h.newUser("alice")
|
||||
bob := h.newUser("bob")
|
||||
carol := h.newUser("carol")
|
||||
for _, u := range []*identity.Identity{alice, bob, carol} {
|
||||
h.fund(u, 10*blockchain.Token)
|
||||
}
|
||||
|
||||
lowPost := h.publish(alice, "low-engagement post", nil)
|
||||
h.commitCreatePost(alice, lowPost)
|
||||
hotPost := h.publish(alice, "hot post", nil)
|
||||
h.commitCreatePost(alice, hotPost)
|
||||
|
||||
// Hot post gets 2 likes + 5 views; low post stays at 0.
|
||||
h.like(bob, hotPost.PostID)
|
||||
h.like(carol, hotPost.PostID)
|
||||
var viewResp struct{ Views uint64 }
|
||||
for i := 0; i < 5; i++ {
|
||||
h.postRaw("/feed/post/"+hotPost.PostID+"/view", &viewResp)
|
||||
}
|
||||
|
||||
var tr struct {
|
||||
Count int `json:"count"`
|
||||
Posts []feedAuthorItem `json:"posts"`
|
||||
}
|
||||
h.getJSON("/feed/trending?limit=10", &tr)
|
||||
if tr.Count < 2 {
|
||||
t.Fatalf("trending: got %d posts, want ≥2", tr.Count)
|
||||
}
|
||||
// Hot post MUST be first (likes × 3 + views = 11 vs 0).
|
||||
if tr.Posts[0].PostID != hotPost.PostID {
|
||||
t.Errorf("trending[0]: got %s, want hot post %s", tr.Posts[0].PostID, hotPost.PostID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2EForYouFilters: recommendations exclude followed authors,
|
||||
// already-liked posts, and the user's own posts.
|
||||
func TestE2EForYouFilters(t *testing.T) {
|
||||
h := newFeedHarness(t)
|
||||
alice := h.newUser("alice") // asking for recs
|
||||
bob := h.newUser("bob") // alice follows bob → bob's posts excluded
|
||||
carol := h.newUser("carol") // stranger → should surface
|
||||
dave := h.newUser("dave") // post liked by alice → excluded
|
||||
|
||||
for _, u := range []*identity.Identity{alice, bob, carol, dave} {
|
||||
h.fund(u, 10*blockchain.Token)
|
||||
}
|
||||
|
||||
// Alice follows bob.
|
||||
h.follow(alice, bob.PubKeyHex())
|
||||
|
||||
// Each non-alice user publishes a post, plus alice herself.
|
||||
postOwn := h.publish(alice, "my own post", nil)
|
||||
h.commitCreatePost(alice, postOwn)
|
||||
postBob := h.publish(bob, "from bob (followed)", nil)
|
||||
h.commitCreatePost(bob, postBob)
|
||||
postCarol := h.publish(carol, "from carol (stranger)", nil)
|
||||
h.commitCreatePost(carol, postCarol)
|
||||
postDave := h.publish(dave, "from dave", nil)
|
||||
h.commitCreatePost(dave, postDave)
|
||||
|
||||
// Alice likes dave's post — so it should NOT appear in her ForYou.
|
||||
h.like(alice, postDave.PostID)
|
||||
|
||||
var fy struct {
|
||||
Count int `json:"count"`
|
||||
Posts []feedAuthorItem `json:"posts"`
|
||||
}
|
||||
h.getJSON("/feed/foryou?pub="+alice.PubKeyHex()+"&limit=20", &fy)
|
||||
|
||||
// Expected: only carol's post. The others are excluded.
|
||||
seen := map[string]bool{}
|
||||
for _, p := range fy.Posts {
|
||||
seen[p.PostID] = true
|
||||
}
|
||||
if seen[postOwn.PostID] {
|
||||
t.Errorf("ForYou included alice's own post %s", postOwn.PostID)
|
||||
}
|
||||
if seen[postBob.PostID] {
|
||||
t.Errorf("ForYou included followed author bob's post %s", postBob.PostID)
|
||||
}
|
||||
if seen[postDave.PostID] {
|
||||
t.Errorf("ForYou included already-liked post from dave %s", postDave.PostID)
|
||||
}
|
||||
if !seen[postCarol.PostID] {
|
||||
t.Errorf("ForYou missing carol's post %s (should surface)", postCarol.PostID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2EHashtagSearch: a tag returns only posts that used it.
|
||||
func TestE2EHashtagSearch(t *testing.T) {
|
||||
h := newFeedHarness(t)
|
||||
alice := h.newUser("alice")
|
||||
h.fund(alice, 10*blockchain.Token)
|
||||
|
||||
goPost := h.publish(alice, "learning #golang today", nil)
|
||||
h.commitCreatePost(alice, goPost)
|
||||
rustPost := h.publish(alice, "later — #rust", nil)
|
||||
h.commitCreatePost(alice, rustPost)
|
||||
untagged := h.publish(alice, "no tags", nil)
|
||||
h.commitCreatePost(alice, untagged)
|
||||
|
||||
var tag struct {
|
||||
Tag string `json:"tag"`
|
||||
Count int `json:"count"`
|
||||
Posts []feedAuthorItem `json:"posts"`
|
||||
}
|
||||
h.getJSON("/feed/hashtag/golang", &tag)
|
||||
if tag.Count != 1 || tag.Posts[0].PostID != goPost.PostID {
|
||||
t.Errorf("hashtag(golang): got %+v, want [%s]", tag, goPost.PostID)
|
||||
}
|
||||
h.getJSON("/feed/hashtag/rust", &tag)
|
||||
if tag.Count != 1 || tag.Posts[0].PostID != rustPost.PostID {
|
||||
t.Errorf("hashtag(rust): got %+v, want [%s]", tag, rustPost.PostID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2EScrubberStripsEXIF: uploaded image with EXIF canary comes back
|
||||
// without the canary in the stored body. Proves server-side scrub is
|
||||
// mandatory and working at the HTTP boundary.
|
||||
func TestE2EScrubberStripsEXIF(t *testing.T) {
|
||||
h := newFeedHarness(t)
|
||||
alice := h.newUser("alice")
|
||||
h.fund(alice, 1*blockchain.Token)
|
||||
|
||||
// Build a JPEG with an injected EXIF segment containing a canary.
|
||||
var jpegBuf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 16, 16))
|
||||
for y := 0; y < 16; y++ {
|
||||
for x := 0; x < 16; x++ {
|
||||
img.Set(x, y, color.RGBA{uint8(x * 16), uint8(y * 16), 100, 255})
|
||||
}
|
||||
}
|
||||
if err := jpeg.Encode(&jpegBuf, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||
t.Fatalf("jpeg encode: %v", err)
|
||||
}
|
||||
withEXIF := injectEXIFSegment(t, jpegBuf.Bytes(),
|
||||
"SUPERSECRETGPS-51.5N-0.1W-iPhone-Serial-A1B2C3")
|
||||
|
||||
// Pre-flight: the upload bytes DO contain the canary.
|
||||
if !bytes.Contains(withEXIF, []byte("SUPERSECRETGPS")) {
|
||||
t.Fatalf("test setup: canary not injected")
|
||||
}
|
||||
|
||||
pub := h.publish(alice, "look at this photo", withEXIF)
|
||||
if pub.PostID == "" {
|
||||
t.Fatalf("publish failed")
|
||||
}
|
||||
h.commitCreatePost(alice, pub)
|
||||
|
||||
// Fetch the stored body back. The attachment field is the cleaned bytes.
|
||||
var fetched struct {
|
||||
Attachment string `json:"attachment"` // base64
|
||||
}
|
||||
h.getJSON("/feed/post/"+pub.PostID, &fetched)
|
||||
if fetched.Attachment == "" {
|
||||
t.Fatalf("attachment not returned")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(fetched.Attachment)
|
||||
if err != nil {
|
||||
t.Fatalf("decode attachment: %v", err)
|
||||
}
|
||||
if bytes.Contains(decoded, []byte("SUPERSECRETGPS")) {
|
||||
t.Errorf("CRITICAL: EXIF canary survived server-side scrub — metadata leaked")
|
||||
}
|
||||
// Sanity: still a valid JPEG after scrub.
|
||||
if _, err := jpeg.Decode(bytes.NewReader(decoded)); err != nil {
|
||||
t.Errorf("scrubbed attachment is not a valid JPEG: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2ERejectsMIMEMismatch: claimed MIME vs magic bytes.
|
||||
func TestE2ERejectsMIMEMismatch(t *testing.T) {
|
||||
h := newFeedHarness(t)
|
||||
alice := h.newUser("alice")
|
||||
h.fund(alice, 1*blockchain.Token)
|
||||
|
||||
// Build a PNG but claim it's a JPEG.
|
||||
fake := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n',
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
ts := time.Now().Unix()
|
||||
postID := "mimecheck"
|
||||
hash := sha256.Sum256(append([]byte("mislabel"), fake...))
|
||||
sig := alice.Sign([]byte(fmt.Sprintf("publish:%s:%s:%d",
|
||||
postID, hex.EncodeToString(hash[:]), ts)))
|
||||
req := feedPublishRequest{
|
||||
PostID: postID,
|
||||
Author: alice.PubKeyHex(),
|
||||
Content: "mislabel",
|
||||
AttachmentB64: base64.StdEncoding.EncodeToString(fake),
|
||||
AttachmentMIME: "image/jpeg", // LIE — it's PNG magic
|
||||
Sig: base64.StdEncoding.EncodeToString(sig),
|
||||
Ts: ts,
|
||||
}
|
||||
h.postJSONExpectStatus("/feed/publish", req, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// TestE2ERejectsBadSignature: wrong signer cannot publish.
|
||||
func TestE2ERejectsBadSignature(t *testing.T) {
|
||||
h := newFeedHarness(t)
|
||||
alice := h.newUser("alice")
|
||||
eve := h.newUser("eve")
|
||||
h.fund(alice, 1*blockchain.Token)
|
||||
h.fund(eve, 1*blockchain.Token)
|
||||
|
||||
ts := time.Now().Unix()
|
||||
postID := "forgery"
|
||||
hash := sha256.Sum256([]byte("evil"))
|
||||
// Eve signs over data but claims to be alice.
|
||||
sig := eve.Sign([]byte(fmt.Sprintf("publish:%s:%s:%d",
|
||||
postID, hex.EncodeToString(hash[:]), ts)))
|
||||
req := feedPublishRequest{
|
||||
PostID: postID,
|
||||
Author: alice.PubKeyHex(), // claim alice
|
||||
Content: "evil",
|
||||
Sig: base64.StdEncoding.EncodeToString(sig),
|
||||
Ts: ts,
|
||||
}
|
||||
h.postJSONExpectStatus("/feed/publish", req, http.StatusForbidden)
|
||||
}
|
||||
|
||||
// TestE2ERejectsStaleTimestamp: publish with ts way in the past must be rejected.
|
||||
func TestE2ERejectsStaleTimestamp(t *testing.T) {
|
||||
h := newFeedHarness(t)
|
||||
alice := h.newUser("alice")
|
||||
h.fund(alice, 1*blockchain.Token)
|
||||
|
||||
ts := time.Now().Add(-1 * time.Hour).Unix() // 1 hour stale
|
||||
postID := "stale"
|
||||
hash := sha256.Sum256([]byte("old"))
|
||||
sig := alice.Sign([]byte(fmt.Sprintf("publish:%s:%s:%d",
|
||||
postID, hex.EncodeToString(hash[:]), ts)))
|
||||
req := feedPublishRequest{
|
||||
PostID: postID,
|
||||
Author: alice.PubKeyHex(),
|
||||
Content: "old",
|
||||
Sig: base64.StdEncoding.EncodeToString(sig),
|
||||
Ts: ts,
|
||||
}
|
||||
h.postJSONExpectStatus("/feed/publish", req, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// injectEXIFSegment splices an APP1 EXIF segment with the given canary
|
||||
// string into a JPEG. Mirrors media/scrub_test.go but local to keep the
|
||||
// integration test self-contained.
|
||||
func injectEXIFSegment(t *testing.T, src []byte, canary string) []byte {
|
||||
t.Helper()
|
||||
if len(src) < 2 || src[0] != 0xFF || src[1] != 0xD8 {
|
||||
t.Fatalf("not a JPEG")
|
||||
}
|
||||
payload := []byte("Exif\x00\x00" + canary)
|
||||
segLen := len(payload) + 2
|
||||
out := make([]byte, 0, len(src)+segLen+4)
|
||||
out = append(out, src[0], src[1]) // SOI
|
||||
out = append(out, 0xFF, 0xE1, byte(segLen>>8), byte(segLen&0xff))
|
||||
out = append(out, payload...)
|
||||
out = append(out, src[2:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// Silence unused-import lint if strings gets trimmed by refactor.
|
||||
var _ = strings.TrimSpace
|
||||
var _ = context.TODO
|
||||
504
node/feed_twonode_test.go
Normal file
504
node/feed_twonode_test.go
Normal file
@@ -0,0 +1,504 @@
|
||||
// Two-node simulation: verifies that a post published on Node A is
|
||||
// discoverable and fetchable from Node B after block propagation.
|
||||
//
|
||||
// The real network uses libp2p gossipsub for blocks + an HTTP pull
|
||||
// fallback. For tests we simulate gossip by manually calling chain.AddBlock
|
||||
// on both nodes with the same block — identical to what each node does
|
||||
// after receiving a peer's gossiped block in production.
|
||||
//
|
||||
// Body ownership: only the HOSTING relay has the post body in its
|
||||
// feed mailbox. Readers on OTHER nodes see the on-chain record
|
||||
// (hosting_relay pubkey, content hash, size, author) and fetch the
|
||||
// body directly from the hosting node over HTTP. That's the design —
|
||||
// storage costs don't get amortised across the whole network, the
|
||||
// author pays one node to host, and the public reads from that one
|
||||
// node (or from replicas if/when we add post pinning in v3.0.0).
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-blockchain/blockchain"
|
||||
"go-blockchain/identity"
|
||||
"go-blockchain/media"
|
||||
"go-blockchain/relay"
|
||||
)
|
||||
|
||||
// twoNodeHarness wires two independent chain+feed instances sharing a
|
||||
// single block history (simulated gossip). Node A is the "hosting"
|
||||
// relay; Node B is the reader.
|
||||
type twoNodeHarness struct {
|
||||
t *testing.T
|
||||
|
||||
aChainDir, aFeedDir string
|
||||
bChainDir, bFeedDir string
|
||||
|
||||
aChain, bChain *blockchain.Chain
|
||||
aMailbox, bMailbox *relay.FeedMailbox
|
||||
aServer, bServer *httptest.Server
|
||||
aHostPub string
|
||||
bHostPub string
|
||||
validator *identity.Identity
|
||||
tipIndex uint64
|
||||
tipHash []byte
|
||||
}
|
||||
|
||||
func newTwoNodeHarness(t *testing.T) *twoNodeHarness {
|
||||
t.Helper()
|
||||
mkdir := func(prefix string) string {
|
||||
d, err := os.MkdirTemp("", prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
h := &twoNodeHarness{
|
||||
t: t,
|
||||
aChainDir: mkdir("dchain-2n-chainA-*"),
|
||||
aFeedDir: mkdir("dchain-2n-feedA-*"),
|
||||
bChainDir: mkdir("dchain-2n-chainB-*"),
|
||||
bFeedDir: mkdir("dchain-2n-feedB-*"),
|
||||
}
|
||||
|
||||
var err error
|
||||
h.aChain, err = blockchain.NewChain(h.aChainDir)
|
||||
if err != nil {
|
||||
t.Fatalf("chain A: %v", err)
|
||||
}
|
||||
h.bChain, err = blockchain.NewChain(h.bChainDir)
|
||||
if err != nil {
|
||||
t.Fatalf("chain B: %v", err)
|
||||
}
|
||||
h.aMailbox, err = relay.OpenFeedMailbox(h.aFeedDir, 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("feed A: %v", err)
|
||||
}
|
||||
h.bMailbox, err = relay.OpenFeedMailbox(h.bFeedDir, 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("feed B: %v", err)
|
||||
}
|
||||
|
||||
h.validator, err = identity.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("validator: %v", err)
|
||||
}
|
||||
// Both nodes start from the same genesis — the single bootstrap
|
||||
// validator allocates the initial supply. In production this is
|
||||
// hardcoded; in tests we just generate and use it to sign blocks
|
||||
// on both chains.
|
||||
genesis := blockchain.GenesisBlock(h.validator.PubKeyHex(), h.validator.PrivKey)
|
||||
if err := h.aChain.AddBlock(genesis); err != nil {
|
||||
t.Fatalf("A genesis: %v", err)
|
||||
}
|
||||
if err := h.bChain.AddBlock(genesis); err != nil {
|
||||
t.Fatalf("B genesis: %v", err)
|
||||
}
|
||||
h.tipIndex = genesis.Index
|
||||
h.tipHash = genesis.Hash
|
||||
|
||||
// Node A hosts; Node B is a pure reader (no host_pub of its own that
|
||||
// anyone publishes to). They share a single validator because this
|
||||
// test isn't about consensus — it's about chain state propagation.
|
||||
h.aHostPub = h.validator.PubKeyHex()
|
||||
|
||||
// Node B uses a separate identity purely for its hosting_relay field
|
||||
// (never actually hosts anything in this scenario). Distinguishes A
|
||||
// from B in balance assertions.
|
||||
idB, _ := identity.Generate()
|
||||
h.bHostPub = idB.PubKeyHex()
|
||||
|
||||
scrubber := media.NewScrubber(media.SidecarConfig{})
|
||||
|
||||
aCfg := FeedConfig{
|
||||
Mailbox: h.aMailbox,
|
||||
HostingRelayPub: h.aHostPub,
|
||||
Scrubber: scrubber,
|
||||
GetPost: h.aChain.Post,
|
||||
LikeCount: h.aChain.LikeCount,
|
||||
HasLiked: h.aChain.HasLiked,
|
||||
PostsByAuthor: h.aChain.PostsByAuthor,
|
||||
Following: h.aChain.Following,
|
||||
}
|
||||
bCfg := FeedConfig{
|
||||
Mailbox: h.bMailbox,
|
||||
HostingRelayPub: h.bHostPub,
|
||||
Scrubber: scrubber,
|
||||
GetPost: h.bChain.Post,
|
||||
LikeCount: h.bChain.LikeCount,
|
||||
HasLiked: h.bChain.HasLiked,
|
||||
PostsByAuthor: h.bChain.PostsByAuthor,
|
||||
Following: h.bChain.Following,
|
||||
}
|
||||
muxA := http.NewServeMux()
|
||||
RegisterFeedRoutes(muxA, aCfg)
|
||||
h.aServer = httptest.NewServer(muxA)
|
||||
muxB := http.NewServeMux()
|
||||
RegisterFeedRoutes(muxB, bCfg)
|
||||
h.bServer = httptest.NewServer(muxB)
|
||||
|
||||
t.Cleanup(h.Close)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *twoNodeHarness) Close() {
|
||||
if h.aServer != nil {
|
||||
h.aServer.Close()
|
||||
}
|
||||
if h.bServer != nil {
|
||||
h.bServer.Close()
|
||||
}
|
||||
if h.aMailbox != nil {
|
||||
_ = h.aMailbox.Close()
|
||||
}
|
||||
if h.bMailbox != nil {
|
||||
_ = h.bMailbox.Close()
|
||||
}
|
||||
if h.aChain != nil {
|
||||
_ = h.aChain.Close()
|
||||
}
|
||||
if h.bChain != nil {
|
||||
_ = h.bChain.Close()
|
||||
}
|
||||
for _, dir := range []string{h.aChainDir, h.aFeedDir, h.bChainDir, h.bFeedDir} {
|
||||
for i := 0; i < 20; i++ {
|
||||
if err := os.RemoveAll(dir); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gossipBlock simulates libp2p block propagation: same block applied to
|
||||
// both chains. In production, AddBlock is called on each peer after the
|
||||
// gossipsub message arrives — no chain-level difference from the direct
|
||||
// call here.
|
||||
func (h *twoNodeHarness) gossipBlock(txs ...*blockchain.Transaction) {
|
||||
h.t.Helper()
|
||||
time.Sleep(2 * time.Millisecond) // distinct tx IDs
|
||||
var totalFees uint64
|
||||
for _, tx := range txs {
|
||||
totalFees += tx.Fee
|
||||
}
|
||||
b := &blockchain.Block{
|
||||
Index: h.tipIndex + 1,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Transactions: txs,
|
||||
PrevHash: h.tipHash,
|
||||
Validator: h.validator.PubKeyHex(),
|
||||
TotalFees: totalFees,
|
||||
}
|
||||
b.ComputeHash()
|
||||
b.Sign(h.validator.PrivKey)
|
||||
|
||||
if err := h.aChain.AddBlock(b); err != nil {
|
||||
h.t.Fatalf("A AddBlock: %v", err)
|
||||
}
|
||||
if err := h.bChain.AddBlock(b); err != nil {
|
||||
h.t.Fatalf("B AddBlock: %v", err)
|
||||
}
|
||||
h.tipIndex = b.Index
|
||||
h.tipHash = b.Hash
|
||||
}
|
||||
|
||||
func (h *twoNodeHarness) nextTxID(from string, typ blockchain.EventType) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", from, typ, time.Now().UnixNano())))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// fundAB transfers from validator → target, propagated to both chains.
|
||||
func (h *twoNodeHarness) fundAB(target *identity.Identity, amount uint64) {
|
||||
tx := &blockchain.Transaction{
|
||||
ID: h.nextTxID(h.validator.PubKeyHex(), blockchain.EventTransfer),
|
||||
Type: blockchain.EventTransfer,
|
||||
From: h.validator.PubKeyHex(),
|
||||
To: target.PubKeyHex(),
|
||||
Amount: amount,
|
||||
Fee: blockchain.MinFee,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
h.gossipBlock(tx)
|
||||
}
|
||||
|
||||
// publishOnA uploads body to A's feed mailbox (only A gets the body) and
|
||||
// gossips the CREATE_POST tx to both chains (both see the metadata).
|
||||
func (h *twoNodeHarness) publishOnA(author *identity.Identity, content string) feedPublishResponse {
|
||||
h.t.Helper()
|
||||
idHash := sha256.Sum256([]byte(fmt.Sprintf("%s-%d-%s",
|
||||
author.PubKeyHex(), time.Now().UnixNano(), content)))
|
||||
postID := hex.EncodeToString(idHash[:16])
|
||||
clientHasher := sha256.New()
|
||||
clientHasher.Write([]byte(content))
|
||||
clientHash := hex.EncodeToString(clientHasher.Sum(nil))
|
||||
ts := time.Now().Unix()
|
||||
sig := author.Sign([]byte(fmt.Sprintf("publish:%s:%s:%d", postID, clientHash, ts)))
|
||||
|
||||
req := feedPublishRequest{
|
||||
PostID: postID,
|
||||
Author: author.PubKeyHex(),
|
||||
Content: content,
|
||||
Sig: base64.StdEncoding.EncodeToString(sig),
|
||||
Ts: ts,
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
resp, err := http.Post(h.aServer.URL+"/feed/publish", "application/json", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
h.t.Fatalf("publish on A: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
h.t.Fatalf("publish on A → %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var out feedPublishResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
h.t.Fatalf("decode publish: %v", err)
|
||||
}
|
||||
|
||||
// Now the ON-CHAIN CREATE_POST tx — gossiped to both nodes.
|
||||
contentHash, _ := hex.DecodeString(out.ContentHash)
|
||||
payload, _ := json.Marshal(blockchain.CreatePostPayload{
|
||||
PostID: out.PostID,
|
||||
ContentHash: contentHash,
|
||||
Size: out.Size,
|
||||
HostingRelay: out.HostingRelay,
|
||||
})
|
||||
tx := &blockchain.Transaction{
|
||||
ID: h.nextTxID(author.PubKeyHex(), blockchain.EventCreatePost),
|
||||
Type: blockchain.EventCreatePost,
|
||||
From: author.PubKeyHex(),
|
||||
Fee: out.EstimatedFeeUT,
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
h.gossipBlock(tx)
|
||||
return out
|
||||
}
|
||||
|
||||
// likeOnB submits a LIKE_POST tx originating on Node B (simulates a
|
||||
// follower using their own node). Both chains receive the block.
|
||||
func (h *twoNodeHarness) likeOnB(liker *identity.Identity, postID string) {
|
||||
payload, _ := json.Marshal(blockchain.LikePostPayload{PostID: postID})
|
||||
tx := &blockchain.Transaction{
|
||||
ID: h.nextTxID(liker.PubKeyHex(), blockchain.EventLikePost),
|
||||
Type: blockchain.EventLikePost,
|
||||
From: liker.PubKeyHex(),
|
||||
Fee: blockchain.MinFee,
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
h.gossipBlock(tx)
|
||||
}
|
||||
|
||||
// getBodyFromA fetches /feed/post/{id} from Node A's HTTP server.
|
||||
func (h *twoNodeHarness) getBodyFromA(postID string) (int, []byte) {
|
||||
h.t.Helper()
|
||||
resp, err := http.Get(h.aServer.URL + "/feed/post/" + postID)
|
||||
if err != nil {
|
||||
h.t.Fatalf("GET A: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
|
||||
// getBodyFromB same for Node B.
|
||||
func (h *twoNodeHarness) getBodyFromB(postID string) (int, []byte) {
|
||||
h.t.Helper()
|
||||
resp, err := http.Get(h.bServer.URL + "/feed/post/" + postID)
|
||||
if err != nil {
|
||||
h.t.Fatalf("GET B: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestTwoNodePostPropagation: Alice publishes on Node A. After block
|
||||
// propagation, both chains have the record. Node B can read the
|
||||
// on-chain metadata directly, and can fetch the body from Node A (the
|
||||
// hosting relay) — which is what the client does in production.
|
||||
func TestTwoNodePostPropagation(t *testing.T) {
|
||||
h := newTwoNodeHarness(t)
|
||||
alice, _ := identity.Generate()
|
||||
h.fundAB(alice, 10*blockchain.Token)
|
||||
|
||||
pub := h.publishOnA(alice, "hello from node A")
|
||||
|
||||
// Node A chain has the record.
|
||||
recA, err := h.aChain.Post(pub.PostID)
|
||||
if err != nil || recA == nil {
|
||||
t.Fatalf("A chain.Post: %v (rec=%v)", err, recA)
|
||||
}
|
||||
// Node B chain also has the record — propagation successful.
|
||||
recB, err := h.bChain.Post(pub.PostID)
|
||||
if err != nil || recB == nil {
|
||||
t.Fatalf("B chain.Post: %v (rec=%v)", err, recB)
|
||||
}
|
||||
if recA.PostID != recB.PostID || recA.Author != recB.Author {
|
||||
t.Errorf("chains disagree: A=%+v B=%+v", recA, recB)
|
||||
}
|
||||
if recB.HostingRelay != h.aHostPub {
|
||||
t.Errorf("B sees hosting_relay=%s, want A's pub=%s", recB.HostingRelay, h.aHostPub)
|
||||
}
|
||||
|
||||
// Node A HTTP serves the body.
|
||||
statusA, _ := h.getBodyFromA(pub.PostID)
|
||||
if statusA != http.StatusOK {
|
||||
t.Errorf("A GET: status %d, want 200", statusA)
|
||||
}
|
||||
|
||||
// Node B HTTP does NOT have the body — body only lives on the hosting
|
||||
// relay. This is by design: the reader client on Node B would read
|
||||
// chain.Post(id).HostingRelay, look up its URL via /api/relays, and
|
||||
// fetch directly from Node A. Tested by the next assertion.
|
||||
statusB, _ := h.getBodyFromB(pub.PostID)
|
||||
if statusB != http.StatusNotFound {
|
||||
t.Errorf("B GET: status %d, want 404 (body lives only on hosting relay)", statusB)
|
||||
}
|
||||
|
||||
// Simulate the client routing step: use chain record from B to find
|
||||
// hosting relay, then fetch from A.
|
||||
hosting := recB.HostingRelay
|
||||
if hosting != h.aHostPub {
|
||||
t.Fatalf("hosting not A: %s", hosting)
|
||||
}
|
||||
// In production: look up hosting's URL via /api/relays. Here we
|
||||
// already know it = h.aServer.URL. Just verify the fetch works.
|
||||
statusCross, bodyCross := h.getBodyFromA(pub.PostID)
|
||||
if statusCross != http.StatusOK {
|
||||
t.Fatalf("cross-node fetch: status %d", statusCross)
|
||||
}
|
||||
var fetched struct {
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyCross, &fetched); err != nil {
|
||||
t.Fatalf("decode cross-node body: %v", err)
|
||||
}
|
||||
if fetched.Content != "hello from node A" {
|
||||
t.Errorf("cross-node content: got %q", fetched.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoNodeLikeCounterSharedAcrossNodes: a like submitted with tx
|
||||
// origin on Node B bumps the on-chain counter — which Node A's HTTP
|
||||
// /stats then reflects. Demonstrates that engagement aggregates are
|
||||
// consistent across the mesh because they live on the chain, not in
|
||||
// any single relay's memory.
|
||||
func TestTwoNodeLikeCounterSharedAcrossNodes(t *testing.T) {
|
||||
h := newTwoNodeHarness(t)
|
||||
alice, _ := identity.Generate()
|
||||
bob, _ := identity.Generate()
|
||||
h.fundAB(alice, 10*blockchain.Token)
|
||||
h.fundAB(bob, 10*blockchain.Token)
|
||||
|
||||
pub := h.publishOnA(alice, "content for engagement test")
|
||||
h.likeOnB(bob, pub.PostID)
|
||||
|
||||
// A's HTTP stats (backed by its chain.LikeCount) should see the like.
|
||||
resp, err := http.Get(h.aServer.URL + "/feed/post/" + pub.PostID + "/stats")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var stats postStatsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Likes != 1 {
|
||||
t.Errorf("A /stats: got %d likes, want 1", stats.Likes)
|
||||
}
|
||||
|
||||
// Same for B.
|
||||
resp, err = http.Get(h.bServer.URL + "/feed/post/" + pub.PostID + "/stats")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Likes != 1 {
|
||||
t.Errorf("B /stats: got %d likes, want 1", stats.Likes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoNodeFollowGraphReplicates: FOLLOW tx on any node propagates to
|
||||
// both chains; B's /feed/timeline returns A-hosted posts correctly.
|
||||
func TestTwoNodeFollowGraphReplicates(t *testing.T) {
|
||||
h := newTwoNodeHarness(t)
|
||||
alice, _ := identity.Generate() // will follow bob
|
||||
bob, _ := identity.Generate() // author
|
||||
h.fundAB(alice, 10*blockchain.Token)
|
||||
h.fundAB(bob, 10*blockchain.Token)
|
||||
|
||||
// Alice follows Bob (tx gossiped to both nodes).
|
||||
followTx := &blockchain.Transaction{
|
||||
ID: h.nextTxID(alice.PubKeyHex(), blockchain.EventFollow),
|
||||
Type: blockchain.EventFollow,
|
||||
From: alice.PubKeyHex(),
|
||||
To: bob.PubKeyHex(),
|
||||
Fee: blockchain.MinFee,
|
||||
Payload: []byte(`{}`),
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
h.gossipBlock(followTx)
|
||||
|
||||
// Bob publishes on A. Alice queries timeline on B.
|
||||
bobPost := h.publishOnA(bob, "bob speaks")
|
||||
|
||||
// Alice's timeline on Node B should include Bob's post (metadata
|
||||
// lives on chain). Body fetch would go to A, but /timeline only
|
||||
// returns the enriched record — which DOES include body content
|
||||
// because B's feed_mailbox doesn't have it... hmm.
|
||||
//
|
||||
// Actually this reveals a limitation: /feed/timeline on B merges
|
||||
// chain records (available) with local mailbox bodies (missing).
|
||||
// The body parts of the response will be empty. For the e2e test we
|
||||
// just verify the metadata is there — the client is expected to
|
||||
// resolve bodies separately via the hosting_relay URL.
|
||||
resp, err := http.Get(h.bServer.URL + "/feed/timeline?follower=" + alice.PubKeyHex())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var tl struct {
|
||||
Count int `json:"count"`
|
||||
Posts []feedAuthorItem `json:"posts"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tl.Count != 1 {
|
||||
t.Fatalf("B timeline count: got %d, want 1", tl.Count)
|
||||
}
|
||||
if tl.Posts[0].PostID != bobPost.PostID {
|
||||
t.Errorf("B timeline[0]: got %s, want %s", tl.Posts[0].PostID, bobPost.PostID)
|
||||
}
|
||||
// Metadata must be correct even if body is empty on B.
|
||||
if tl.Posts[0].Author != bob.PubKeyHex() {
|
||||
t.Errorf("B timeline[0].author: got %s, want %s", tl.Posts[0].Author, bob.PubKeyHex())
|
||||
}
|
||||
if tl.Posts[0].HostingRelay != h.aHostPub {
|
||||
t.Errorf("B timeline[0].hosting_relay: got %s, want A (%s)", tl.Posts[0].HostingRelay, h.aHostPub)
|
||||
}
|
||||
// Body is intentionally empty on B (A hosts it). Verify.
|
||||
if tl.Posts[0].Content != "" {
|
||||
t.Errorf("B timeline[0].content: got %q, want empty (body lives on A)", tl.Posts[0].Content)
|
||||
}
|
||||
}
|
||||
29
node/ws.go
29
node/ws.go
@@ -521,13 +521,17 @@ func (h *WSHub) authorizeSubscribe(c *wsClient, topic string) error {
|
||||
if authed == "" {
|
||||
return fmt.Errorf("inbox:* requires auth")
|
||||
}
|
||||
// If we have an x25519 mapping, enforce it; otherwise accept
|
||||
// (best-effort — identity may not be registered yet).
|
||||
if authX != "" {
|
||||
want := strings.TrimPrefix(topic, "inbox:")
|
||||
if want != authX {
|
||||
return fmt.Errorf("inbox:* only for your own x25519")
|
||||
}
|
||||
// Hard-require a registered X25519 identity — otherwise an
|
||||
// Ed25519-only identity could subscribe to ANY inbox topic by
|
||||
// design (authX == "" skipped the equality check). Fixed: we
|
||||
// now refuse the subscription until the client publishes an
|
||||
// X25519 key via REGISTER_KEY.
|
||||
if authX == "" {
|
||||
return fmt.Errorf("inbox:* requires a registered X25519 identity (send REGISTER_KEY first)")
|
||||
}
|
||||
want := strings.TrimPrefix(topic, "inbox:")
|
||||
if want != authX {
|
||||
return fmt.Errorf("inbox:* only for your own x25519")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -536,11 +540,12 @@ func (h *WSHub) authorizeSubscribe(c *wsClient, topic string) error {
|
||||
if authed == "" {
|
||||
return fmt.Errorf("typing:* requires auth")
|
||||
}
|
||||
if authX != "" {
|
||||
want := strings.TrimPrefix(topic, "typing:")
|
||||
if want != authX {
|
||||
return fmt.Errorf("typing:* only for your own x25519")
|
||||
}
|
||||
if authX == "" {
|
||||
return fmt.Errorf("typing:* requires a registered X25519 identity")
|
||||
}
|
||||
want := strings.TrimPrefix(topic, "typing:")
|
||||
if want != authX {
|
||||
return fmt.Errorf("typing:* only for your own x25519")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user