feat(client): Twitter-style social feed UI (Phase C of v2.0.0)
Ships the client side of the v2.0.0 feed feature. Folds client-app/
into the monorepo (was previously .gitignored as "tracked separately"
but no separate repo ever existed — for v2.0.0 the client is
first-class).
Feed screens
app/(app)/feed.tsx — Feed tab
- Three-way tab strip: Подписки / Для вас / В тренде backed by
/feed/timeline, /feed/foryou, /feed/trending respectively
- Default landing tab is "Для вас" — surfaces discovery without
requiring the user to follow anyone first
- FlatList with pull-to-refresh + viewability-driven view counter
bump (posts visible ≥ 60% for ≥ 1s trigger POST /feed/post/…/view)
- Floating blue compose button → /compose
- Per-post liked_by_me fetched in batches of 6 after list load
app/(app)/compose.tsx — post composer modal
- Fullscreen, Twitter-like header (✕ left, Опубликовать right)
- Auto-focused multiline TextInput, 4000 char cap
- Hashtag preview chips that auto-update as you type
- expo-image-picker + expo-image-manipulator pipeline: resize to
1080px max-dim, JPEG Q=50 (client-side first-pass compression
before the mandatory server-side scrub)
- Live fee estimate + balance guard with a confirmation modal
("Опубликовать пост? Цена: 0.00X T · Размер: N KB")
- Exif: false passed to ImagePicker as an extra privacy layer
app/(app)/feed/[id].tsx — post detail
- Full PostCard rendering + detailed info panel (views, likes,
size, fee, hosting relay, hashtags as tappable chips)
- Triggers bumpView on mount
- 410 (on-chain soft-delete) routes back to the feed
app/(app)/feed/tag/[tag].tsx — hashtag feed
app/(app)/profile/[address].tsx — rebuilt
- Twitter-ish profile: avatar, name, address short-form, post count
- Posts | Инфо tab strip
- Follow / Unfollow button for non-self profiles (optimistic UI)
- Edit button on self profile → settings
- Secondary actions (chat, copy address) when viewing a known contact
Supporting library
lib/feed.ts — HTTP wrappers + tx builders for every /feed/* endpoint:
- publishPost (POST /feed/publish, signed)
- publishAndCommit (publish → on-chain CREATE_POST)
- fetchPost / fetchStats / bumpView
- fetchAuthorPosts / fetchTimeline / fetchForYou / fetchTrending /
fetchHashtag
- buildCreatePostTx / buildDeletePostTx
- buildFollowTx / buildUnfollowTx
- buildLikePostTx / buildUnlikePostTx
- likePost / unlikePost / followUser / unfollowUser / deletePost
(high-level helpers that bundle build + submitTx)
- formatFee, formatRelativeTime, formatCount — Twitter-like display
helpers
components/feed/PostCard.tsx — core card component
- Memoised for performance (N-row re-render on every like elsewhere
would cost a lot otherwise)
- Optimistic like toggle with heart-bounce spring animation
- Hashtag highlighting in body text (tappable → hashtag feed)
- Long-press context menu (Delete, owner-only)
- Views / likes / share-link / reply icons in footer row
Navigation cleanup
- NavBar: removed the SOON pill on the Feed tab (it's shipped now)
- (app)/_layout: hide NavBar on /compose and /feed/* sub-routes
- AnimatedSlot: treat /feed/<id>, /feed/tag/<t>, /compose as
sub-routes so back-swipe-right closes them
Channel removal (client side)
- lib/types.ts: ContactKind stripped to 'direct' | 'group'; legacy
'channel' flag removed. `kind` field kept for backward compat with
existing AsyncStorage records.
- lib/devSeed.ts: dropped the 5 channel seed contacts.
- components/ChatTile.tsx: removed channel kindIcon branch.
Dependencies
- expo-image-manipulator added for client-side image compression.
- expo-file-system/legacy used for readAsStringAsync (SDK 54 moved
that API to the legacy sub-path; the new streaming API isn't yet
stable).
Type check
- npx tsc --noEmit — clean, 0 errors.
Next (not in this commit)
- Direct attachment-bytes endpoint on the server so post-detail can
actually render the image (currently shows placeholder with URL)
- Cross-relay body fetch via /api/relays + hosting_relay pubkey
- Mentions (@username) with notifications
- Full-text search
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -60,4 +60,7 @@ Thumbs.db
|
|||||||
# Not part of the release bundle — tracked separately
|
# Not part of the release bundle — tracked separately
|
||||||
CONTEXT.md
|
CONTEXT.md
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
client-app/
|
|
||||||
|
# Client app sources are tracked from v2.0.0 onwards (feed feature made
|
||||||
|
# the client a first-class part of the release). Local state (node_modules,
|
||||||
|
# build artifacts, Expo cache) is ignored via client-app/.gitignore.
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
512
client-app/app/(app)/chats/[id].tsx
Normal file
512
client-app/app/(app)/chats/[id].tsx
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
/**
|
||||||
|
* 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}
|
||||||
|
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' : undefined}
|
||||||
|
>
|
||||||
|
{/* 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;
|
||||||
|
}
|
||||||
320
client-app/app/(app)/feed.tsx
Normal file
320
client-app/app/(app)/feed.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* 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 } from '@/components/feed/PostCard';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import {
|
||||||
|
fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView,
|
||||||
|
type FeedPostItem,
|
||||||
|
} from '@/lib/feed';
|
||||||
|
|
||||||
|
type TabKey = 'following' | 'foryou' | 'trending';
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<TabKey, string> = {
|
||||||
|
following: 'Подписки',
|
||||||
|
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 [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
const seq = ++requestRef.current;
|
||||||
|
try {
|
||||||
|
let items: FeedPostItem[] = [];
|
||||||
|
switch (tab) {
|
||||||
|
case 'following':
|
||||||
|
items = await fetchTimeline(keyFile.pub_key, 40);
|
||||||
|
break;
|
||||||
|
case 'foryou':
|
||||||
|
items = await fetchForYou(keyFile.pub_key, 40);
|
||||||
|
break;
|
||||||
|
case 'trending':
|
||||||
|
items = await fetchTrending(24, 40);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (seq !== requestRef.current) return; // stale response
|
||||||
|
setPosts(items);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
// Silence benign network/404 — just show empty state.
|
||||||
|
if (/Network request failed|→\s*404/.test(msg)) {
|
||||||
|
setPosts([]);
|
||||||
|
} else {
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (seq !== requestRef.current) return;
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [keyFile, 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 */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#141414',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(Object.keys(TAB_LABELS) as TabKey[]).map(key => (
|
||||||
|
<Pressable
|
||||||
|
key={key}
|
||||||
|
onPress={() => setTab(key)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 14,
|
||||||
|
backgroundColor: pressed ? '#0a0a0a' : 'transparent',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: tab === key ? '#ffffff' : '#6a6a6a',
|
||||||
|
fontWeight: tab === key ? '700' : '500',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TAB_LABELS[key]}
|
||||||
|
</Text>
|
||||||
|
{tab === key && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
width: 48,
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
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 } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating compose button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push('/(app)/compose' as never)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
position: 'absolute',
|
||||||
|
right: 18,
|
||||||
|
bottom: Math.max(insets.bottom, 12) + 70, // clear the NavBar
|
||||||
|
width: 56, height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
shadowColor: '#1d9bf0',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="create-outline" size={24} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
242
client-app/app/(app)/feed/[id].tsx
Normal file
242
client-app/app/(app)/feed/[id].tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* 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, Image,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
|
||||||
|
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 { 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' }}>
|
||||||
|
<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>
|
||||||
|
<PostCard
|
||||||
|
post={{ ...post, likes: stats?.likes ?? post.likes, views: stats?.views ?? post.views }}
|
||||||
|
likedByMe={stats?.liked_by_me ?? false}
|
||||||
|
onStatsChanged={onStatsChanged}
|
||||||
|
onDeleted={onDeleted}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Attachment preview (if any). For MVP we try loading from the
|
||||||
|
CURRENT node — works when you're connected to the hosting
|
||||||
|
relay. Cross-relay discovery (look up hosting_relay URL via
|
||||||
|
/api/relays) is future work. */}
|
||||||
|
{post.has_attachment && (
|
||||||
|
<AttachmentPreview postID={post.post_id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 AttachmentPreview({ postID }: { postID: string }) {
|
||||||
|
// For MVP we hit the local node URL; if the body is hosted elsewhere
|
||||||
|
// the image load will fail and the placeholder stays visible.
|
||||||
|
const { getNodeUrl } = require('@/lib/api');
|
||||||
|
const url = `${getNodeUrl()}/feed/post/${postID}`;
|
||||||
|
// The body is a JSON object, not raw image bytes. For now we just
|
||||||
|
// show a placeholder — decoding base64 attachment → data-uri is a
|
||||||
|
// Phase D improvement once we add /feed/post/{id}/attachment raw bytes.
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
margin: 14,
|
||||||
|
paddingVertical: 32,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="image-outline" size={32} color="#5a5a5a" />
|
||||||
|
<Text style={{ color: '#8b8b8b', marginTop: 10, fontSize: 12 }}>
|
||||||
|
Вложение: {url}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#5a5a5a', marginTop: 4, fontSize: 10 }}>
|
||||||
|
Прямой просмотр вложений — в следующем релизе
|
||||||
|
</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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silence Image import when unused (reserved for future attachment preview).
|
||||||
|
void Image;
|
||||||
127
client-app/app/(app)/feed/tag/[tag].tsx
Normal file
127
client-app/app/(app)/feed/tag/[tag].tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Header } from '@/components/Header';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
import { PostCard } from '@/components/feed/PostCard';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed';
|
||||||
|
|
||||||
|
export default function HashtagScreen() {
|
||||||
|
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' }}>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
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 } : undefined}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
441
client-app/app/(app)/profile/[address].tsx
Normal file
441
client-app/app/(app)/profile/[address].tsx
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
/**
|
||||||
|
* Profile screen — shows info about any address (yours or someone else's),
|
||||||
|
* plus their post feed, follow/unfollow button, and basic counters.
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* /(app)/profile/<ed25519-hex>
|
||||||
|
*
|
||||||
|
* Two states:
|
||||||
|
* - Known contact → open chat, show full info
|
||||||
|
* - Unknown address → Twitter-style "discovery" profile: shows just the
|
||||||
|
* address + posts + follow button. Useful when tapping an author from
|
||||||
|
* the feed of someone you don't chat with.
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, Pressable, Alert, FlatList,
|
||||||
|
ActivityIndicator, RefreshControl,
|
||||||
|
} 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 type { Contact } from '@/lib/types';
|
||||||
|
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
import { PostCard } from '@/components/feed/PostCard';
|
||||||
|
import {
|
||||||
|
fetchAuthorPosts, fetchStats, followUser, unfollowUser,
|
||||||
|
formatCount, type FeedPostItem,
|
||||||
|
} 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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'posts' | 'info';
|
||||||
|
|
||||||
|
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 [tab, setTab] = useState<Tab>('posts');
|
||||||
|
const [posts, setPosts] = useState<FeedPostItem[]>([]);
|
||||||
|
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
|
||||||
|
const [loadingPosts, setLoadingPosts] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Follow state is optimistic + reconciled via on-chain query. For MVP
|
||||||
|
// we keep a local-only flag that toggles immediately on tap; future:
|
||||||
|
// query chain.Following(me) once on mount to seed accurate initial state.
|
||||||
|
const [following, setFollowing] = useState(false);
|
||||||
|
const [followingBusy, setFollowingBusy] = useState(false);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isMe = !!keyFile && keyFile.pub_key === address;
|
||||||
|
const displayName = contact?.username
|
||||||
|
? `@${contact.username}`
|
||||||
|
: contact?.alias ?? (isMe ? 'Вы' : shortAddr(address ?? ''));
|
||||||
|
|
||||||
|
const loadPosts = useCallback(async (isRefresh = false) => {
|
||||||
|
if (!address) return;
|
||||||
|
if (isRefresh) setRefreshing(true); else setLoadingPosts(true);
|
||||||
|
try {
|
||||||
|
const items = await fetchAuthorPosts(address, 40);
|
||||||
|
setPosts(items);
|
||||||
|
if (keyFile) {
|
||||||
|
const liked = new Set<string>();
|
||||||
|
for (const p of items) {
|
||||||
|
const s = await fetchStats(p.post_id, keyFile.pub_key);
|
||||||
|
if (s?.liked_by_me) liked.add(p.post_id);
|
||||||
|
}
|
||||||
|
setLikedSet(liked);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setPosts([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingPosts(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [address, keyFile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'posts') loadPosts(false);
|
||||||
|
}, [tab, loadPosts]);
|
||||||
|
|
||||||
|
const copy = async (value: string, label: string) => {
|
||||||
|
await Clipboard.setStringAsync(value);
|
||||||
|
setCopied(label);
|
||||||
|
setTimeout(() => setCopied(null), 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);
|
||||||
|
Alert.alert('Не удалось', humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setFollowingBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const onDeleted = useCallback((postID: string) => {
|
||||||
|
setPosts(ps => ps.filter(p => p.post_id !== postID));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Hero + follow button block ──────────────────────────────────────
|
||||||
|
|
||||||
|
const Hero = (
|
||||||
|
<View style={{ paddingHorizontal: 14, paddingTop: 16, paddingBottom: 4 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
|
||||||
|
<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: 110,
|
||||||
|
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>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 14 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 22, fontWeight: '800' }}>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
{contact?.username && (
|
||||||
|
<Ionicons name="checkmark-circle" size={18} color="#1d9bf0" style={{ marginLeft: 5 }} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#6a6a6a', fontSize: 12, marginTop: 2 }}>
|
||||||
|
{shortAddr(address ?? '')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Counters row — post count is derived from what we loaded; follower/
|
||||||
|
following counters would require chain.Followers / chain.Following
|
||||||
|
HTTP exposure which isn't wired yet (Phase D). */}
|
||||||
|
<View style={{ flexDirection: 'row', marginTop: 12, gap: 18 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 13 }}>
|
||||||
|
<Text style={{ fontWeight: '700' }}>{formatCount(posts.length)}</Text>
|
||||||
|
<Text style={{ color: '#6a6a6a' }}> постов</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Secondary actions: open chat + copy address */}
|
||||||
|
{!isMe && contact && (
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 14 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={openChat}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingVertical: 10, borderRadius: 999,
|
||||||
|
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
flexDirection: 'row', gap: 6,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="chatbubble-outline" size={14} color="#ffffff" />
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>
|
||||||
|
Чат
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => address && copy(address, 'address')}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
paddingVertical: 10, borderRadius: 999,
|
||||||
|
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>
|
||||||
|
{copied === 'address' ? 'Скопировано' : 'Копировать адрес'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Tab strip ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TabStrip = (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#141414',
|
||||||
|
marginTop: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['posts', 'info'] as Tab[]).map(key => (
|
||||||
|
<Pressable
|
||||||
|
key={key}
|
||||||
|
onPress={() => setTab(key)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: tab === key ? '#ffffff' : '#6a6a6a',
|
||||||
|
fontWeight: tab === key ? '700' : '500',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{key === 'posts' ? 'Посты' : 'Инфо'}
|
||||||
|
</Text>
|
||||||
|
{tab === key && (
|
||||||
|
<View style={{
|
||||||
|
marginTop: 6,
|
||||||
|
width: 48, height: 3, borderRadius: 1.5,
|
||||||
|
backgroundColor: '#1d9bf0',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Content per tab ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (tab === 'posts') {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000' }}>
|
||||||
|
<Header
|
||||||
|
title="Профиль"
|
||||||
|
divider
|
||||||
|
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||||
|
/>
|
||||||
|
<FlatList
|
||||||
|
data={posts}
|
||||||
|
keyExtractor={p => p.post_id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<PostCard
|
||||||
|
post={item}
|
||||||
|
likedByMe={likedSet.has(item.post_id)}
|
||||||
|
onStatsChanged={onStatsChanged}
|
||||||
|
onDeleted={onDeleted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<>
|
||||||
|
{Hero}
|
||||||
|
{TabStrip}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => loadPosts(true)}
|
||||||
|
tintColor="#1d9bf0"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
loadingPosts ? (
|
||||||
|
<View style={{ padding: 40, alignItems: 'center' }}>
|
||||||
|
<ActivityIndicator color="#1d9bf0" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{
|
||||||
|
paddingVertical: 60,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<Ionicons name="newspaper-outline" size={32} color="#6a6a6a" />
|
||||||
|
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
|
||||||
|
Пока нет постов
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 6, textAlign: 'center' }}>
|
||||||
|
{isMe
|
||||||
|
? 'Нажмите на синюю кнопку в ленте, чтобы написать первый.'
|
||||||
|
: 'Этот пользователь ещё ничего не публиковал.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info tab
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000' }}>
|
||||||
|
<Header
|
||||||
|
title="Профиль"
|
||||||
|
divider
|
||||||
|
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||||||
|
/>
|
||||||
|
<ScrollView>
|
||||||
|
{Hero}
|
||||||
|
{TabStrip}
|
||||||
|
|
||||||
|
<View style={{ paddingHorizontal: 14, paddingTop: 14 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InfoRow label="Адрес" value={shortAddr(address ?? '')} mono />
|
||||||
|
{contact && (
|
||||||
|
<>
|
||||||
|
<InfoRow
|
||||||
|
label="Ключ шифрования"
|
||||||
|
value={contact.x25519Pub ? shortAddr(contact.x25519Pub) : 'не опубликован'}
|
||||||
|
mono={!!contact.x25519Pub}
|
||||||
|
danger={!contact.x25519Pub}
|
||||||
|
/>
|
||||||
|
<InfoRow label="Добавлен" value={new Date(contact.addedAt).toLocaleDateString()} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ height: 40 + insets.bottom }} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
label, value, mono, accent, danger,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
accent?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
}) {
|
||||||
|
const color = danger ? '#f0b35a' : accent ? '#1d9bf0' : '#ffffff';
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color, fontSize: 13,
|
||||||
|
fontFamily: mono ? 'monospace' : undefined,
|
||||||
|
fontWeight: '600',
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silence unused-import lint for Contact type used only in helpers.
|
||||||
|
const _contactType: Contact | null = null; void _contactType;
|
||||||
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
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
67
client-app/components/AnimatedSlot.tsx
Normal file
67
client-app/components/AnimatedSlot.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* AnimatedSlot — обёртка над `<Slot>`. Исторически тут была slide-
|
||||||
|
* анимация при смене pathname'а + tab-swipe pan. Обе фичи вызывали
|
||||||
|
* баги:
|
||||||
|
* - tab-swipe конфликтовал с vertical FlatList scroll (чаты пропадали
|
||||||
|
* при flick'е)
|
||||||
|
* - translateX застревал на ±width когда анимация прерывалась
|
||||||
|
* re-render-cascade'ом от useGlobalInbox → UI уезжал за экран
|
||||||
|
*
|
||||||
|
* Решение: убрали обе. Навигация между tab'ами — только через NavBar,
|
||||||
|
* переходы — без slide. Sub-route back-swipe остаётся (он не конфликтует
|
||||||
|
* с FlatList'ом, т.к. на chat detail FlatList inverted и смотрит вверх).
|
||||||
|
*/
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { PanResponder, View } from 'react-native';
|
||||||
|
import { Slot, usePathname, router } from 'expo-router';
|
||||||
|
import { useWindowDimensions } from 'react-native';
|
||||||
|
|
||||||
|
function topSegment(p: string): string {
|
||||||
|
const m = p.match(/^\/[^/]+/);
|
||||||
|
return m ? m[0] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Это sub-route — внутри какого-либо tab'а, но глубже первого сегмента. */
|
||||||
|
function isSubRoute(path: string): boolean {
|
||||||
|
const seg = topSegment(path);
|
||||||
|
if (seg === '/chats') return path !== '/chats' && path !== '/chats/';
|
||||||
|
if (seg === '/feed') return path !== '/feed' && path !== '/feed/';
|
||||||
|
if (seg === '/profile') return true;
|
||||||
|
if (seg === '/settings') return true;
|
||||||
|
if (seg === '/compose') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedSlot() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
|
||||||
|
// Pan-gesture только на sub-route'ах: swipe-right → back. На tab'ах
|
||||||
|
// gesture полностью отключён — исключает конфликт с vertical scroll.
|
||||||
|
const panResponder = useMemo(() => {
|
||||||
|
const sub = isSubRoute(pathname);
|
||||||
|
|
||||||
|
return PanResponder.create({
|
||||||
|
onMoveShouldSetPanResponder: (_e, g) => {
|
||||||
|
if (!sub) return false;
|
||||||
|
if (Math.abs(g.dx) < 40) return false;
|
||||||
|
if (Math.abs(g.dy) > 15) return false;
|
||||||
|
if (g.dx <= 0) return false; // только правое направление
|
||||||
|
return Math.abs(g.dx) > Math.abs(g.dy) * 3;
|
||||||
|
},
|
||||||
|
onMoveShouldSetPanResponderCapture: () => false,
|
||||||
|
|
||||||
|
onPanResponderRelease: (_e, g) => {
|
||||||
|
if (!sub) return;
|
||||||
|
if (Math.abs(g.dy) > 30) return;
|
||||||
|
if (g.dx > width * 0.30) router.back();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [pathname, width]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }} {...panResponder.panHandlers}>
|
||||||
|
<Slot />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
374
client-app/components/chat/MessageBubble.tsx
Normal file
374
client-app/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
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 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.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
370
client-app/components/feed/PostCard.tsx
Normal file
370
client-app/components/feed/PostCard.tsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* 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 type { FeedPostItem } from '@/lib/feed';
|
||||||
|
import {
|
||||||
|
formatRelativeTime, formatCount, likePost, unlikePost, deletePost, fetchStats,
|
||||||
|
} from '@/lib/feed';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Image URL for attachment preview. We hit the hosting relay directly.
|
||||||
|
// For MVP we just show a placeholder — real fetch requires the hosting
|
||||||
|
// relay's URL, not just its pubkey. (Future: /api/relays lookup.)
|
||||||
|
const attachmentIcon = post.has_attachment;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onOpenDetail}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: compact ? 10 : 12,
|
||||||
|
backgroundColor: pressed ? '#080808' : 'transparent',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#141414',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Avatar column */}
|
||||||
|
<Pressable onPress={onOpenAuthor} hitSlop={4}>
|
||||||
|
<Avatar name={displayName} address={post.author} size={44} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Content column */}
|
||||||
|
<View style={{ flex: 1, marginLeft: 10, minWidth: 0 }}>
|
||||||
|
{/* Header: name + time + menu */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<Pressable onPress={onOpenAuthor} hitSlop={4}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ color: '#ffffff', fontWeight: '700', fontSize: 14, letterSpacing: -0.2 }}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={{ color: '#6a6a6a', fontSize: 13, marginHorizontal: 4 }}>·</Text>
|
||||||
|
<Text style={{ color: '#6a6a6a', fontSize: 13 }}>
|
||||||
|
{formatRelativeTime(post.created_at)}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
{mine && (
|
||||||
|
<Pressable onPress={onLongPress} hitSlop={8}>
|
||||||
|
<Ionicons name="ellipsis-horizontal" size={16} color="#6a6a6a" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body text with hashtag highlighting */}
|
||||||
|
{post.content.length > 0 && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderInline(post.content)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachment indicator — real image render requires relay URL */}
|
||||||
|
{attachmentIcon && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
paddingVertical: 24,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f1f1f',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="image-outline" size={18} color="#5a5a5a" />
|
||||||
|
<Text style={{ color: '#5a5a5a', fontSize: 12 }}>
|
||||||
|
Открыть пост, чтобы посмотреть вложение
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action row */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
gap: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActionButton
|
||||||
|
icon="chatbubble-outline"
|
||||||
|
label={formatCount(0) /* replies count — not implemented yet */}
|
||||||
|
onPress={onOpenDetail}
|
||||||
|
/>
|
||||||
|
<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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCount(localLikeCount)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<ActionButton
|
||||||
|
icon="eye-outline"
|
||||||
|
label={formatCount(post.views)}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
<ActionButton
|
||||||
|
icon="share-outline"
|
||||||
|
onPress={() => {
|
||||||
|
// Placeholder — copy postID to clipboard in a future PR.
|
||||||
|
Alert.alert('Ссылка', `dchain://post/${post.post_id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silence image import lint since we reference Image type indirectly.
|
||||||
|
const _imgKeep = Image;
|
||||||
|
|
||||||
|
export const PostCard = React.memo(PostCardInner);
|
||||||
|
|
||||||
|
// ── 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 }}>{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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep Image import in play; expo-image lint sometimes trims it.
|
||||||
|
void _imgKeep;
|
||||||
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]);
|
||||||
|
}
|
||||||
114
client-app/hooks/useGlobalInbox.ts
Normal file
114
client-app/hooks/useGlobalInbox.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* 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 { 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 msg = {
|
||||||
|
id: msgId,
|
||||||
|
from: env.sender_pub,
|
||||||
|
text,
|
||||||
|
timestamp: env.timestamp,
|
||||||
|
mine: false,
|
||||||
|
};
|
||||||
|
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]);
|
||||||
|
}
|
||||||
149
client-app/hooks/useMessages.ts
Normal file
149
client-app/hooks/useMessages.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Dedup id — используем стабильный серверный env.id (hex
|
||||||
|
// sha256(nonce||ct)[:16]). Раньше собирался из env.timestamp,
|
||||||
|
// но клиентский тип не имел sent_at, поэтому timestamp был
|
||||||
|
// undefined и все id коллапсировали на "undefined".
|
||||||
|
const msg = {
|
||||||
|
id: env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`,
|
||||||
|
from: env.sender_pub,
|
||||||
|
text,
|
||||||
|
timestamp: env.timestamp,
|
||||||
|
mine: false,
|
||||||
|
};
|
||||||
|
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
|
||||||
|
}
|
||||||
778
client-app/lib/api.ts
Normal file
778
client-app/lib/api.ts
Normal file
@@ -0,0 +1,778 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
'identity_registry',
|
||||||
|
'onboarding_api',
|
||||||
|
'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]);
|
||||||
|
}
|
||||||
487
client-app/lib/feed.ts
Normal file
487
client-app/lib/feed.ts
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
/**
|
||||||
|
* 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, limit = 30): Promise<FeedPostItem[]> {
|
||||||
|
const resp = await getJSON<TimelineResponse>(`/feed/author/${pub}?limit=${limit}`);
|
||||||
|
return resp.posts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTimeline(followerPub: string, limit = 30): Promise<FeedPostItem[]> {
|
||||||
|
const resp = await getJSON<TimelineResponse>(`/feed/timeline?follower=${followerPub}&limit=${limit}`);
|
||||||
|
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;
|
||||||
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 }),
|
||||||
|
}));
|
||||||
149
client-app/lib/types.ts
Normal file
149
client-app/lib/types.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// ─── 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user