From 5b64ef2560705d779ef59bb38d443a2c48d441ee Mon Sep 17 00:00:00 2001 From: vsecoder Date: Sat, 18 Apr 2026 19:43:55 +0300 Subject: [PATCH] feat(client): Twitter-style social feed UI (Phase C of v2.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/, /feed/tag/, /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) --- .gitignore | 5 +- client-app/.gitignore | 42 + client-app/README.md | 93 + client-app/app.json | 69 + client-app/app/(app)/_layout.tsx | 81 + client-app/app/(app)/chats/[id].tsx | 512 + client-app/app/(app)/chats/_layout.tsx | 28 + client-app/app/(app)/chats/index.tsx | 105 + client-app/app/(app)/compose.tsx | 390 + client-app/app/(app)/feed.tsx | 320 + client-app/app/(app)/feed/[id].tsx | 242 + client-app/app/(app)/feed/tag/[tag].tsx | 127 + client-app/app/(app)/new-contact.tsx | 288 + client-app/app/(app)/profile/[address].tsx | 441 + client-app/app/(app)/requests.tsx | 173 + client-app/app/(app)/settings.tsx | 595 + client-app/app/(app)/wallet.tsx | 652 + client-app/app/(auth)/create.tsx | 139 + client-app/app/(auth)/created.tsx | 196 + client-app/app/(auth)/import.tsx | 230 + client-app/app/_layout.tsx | 59 + client-app/app/index.tsx | 519 + client-app/babel.config.js | 12 + client-app/components/AnimatedSlot.tsx | 67 + client-app/components/Avatar.tsx | 76 + client-app/components/ChatTile.tsx | 174 + client-app/components/Composer.tsx | 329 + client-app/components/Header.tsx | 76 + client-app/components/IconButton.tsx | 61 + client-app/components/NavBar.tsx | 150 + client-app/components/SearchBar.tsx | 88 + client-app/components/TabHeader.tsx | 59 + client-app/components/chat/AttachmentMenu.tsx | 188 + .../components/chat/AttachmentPreview.tsx | 178 + client-app/components/chat/DaySeparator.tsx | 36 + client-app/components/chat/MessageBubble.tsx | 374 + client-app/components/chat/ReplyQuote.tsx | 70 + .../components/chat/VideoCirclePlayer.tsx | 158 + .../components/chat/VideoCircleRecorder.tsx | 217 + client-app/components/chat/VoicePlayer.tsx | 166 + client-app/components/chat/VoiceRecorder.tsx | 183 + client-app/components/chat/rows.ts | 79 + client-app/components/feed/PostCard.tsx | 370 + client-app/eas.json | 22 + client-app/global.css | 3 + client-app/hooks/useBalance.ts | 94 + client-app/hooks/useConnectionStatus.ts | 52 + client-app/hooks/useContacts.ts | 80 + client-app/hooks/useGlobalInbox.ts | 114 + client-app/hooks/useMessages.ts | 149 + client-app/hooks/useNotifications.ts | 144 + client-app/hooks/useWellKnownContracts.ts | 61 + client-app/lib/api.ts | 778 ++ client-app/lib/crypto.ts | 168 + client-app/lib/dates.ts | 67 + client-app/lib/devSeed.ts | 444 + client-app/lib/feed.ts | 487 + client-app/lib/storage.ts | 101 + client-app/lib/store.ts | 128 + client-app/lib/types.ts | 149 + client-app/lib/utils.ts | 35 + client-app/lib/ws.ts | 401 + client-app/metro.config.js | 6 + client-app/nativewind-env.d.ts | 1 + client-app/package-lock.json | 11482 ++++++++++++++++ client-app/package.json | 61 + client-app/tailwind.config.js | 35 + client-app/tsconfig.json | 9 + 68 files changed, 23487 insertions(+), 1 deletion(-) create mode 100644 client-app/.gitignore create mode 100644 client-app/README.md create mode 100644 client-app/app.json create mode 100644 client-app/app/(app)/_layout.tsx create mode 100644 client-app/app/(app)/chats/[id].tsx create mode 100644 client-app/app/(app)/chats/_layout.tsx create mode 100644 client-app/app/(app)/chats/index.tsx create mode 100644 client-app/app/(app)/compose.tsx create mode 100644 client-app/app/(app)/feed.tsx create mode 100644 client-app/app/(app)/feed/[id].tsx create mode 100644 client-app/app/(app)/feed/tag/[tag].tsx create mode 100644 client-app/app/(app)/new-contact.tsx create mode 100644 client-app/app/(app)/profile/[address].tsx create mode 100644 client-app/app/(app)/requests.tsx create mode 100644 client-app/app/(app)/settings.tsx create mode 100644 client-app/app/(app)/wallet.tsx create mode 100644 client-app/app/(auth)/create.tsx create mode 100644 client-app/app/(auth)/created.tsx create mode 100644 client-app/app/(auth)/import.tsx create mode 100644 client-app/app/_layout.tsx create mode 100644 client-app/app/index.tsx create mode 100644 client-app/babel.config.js create mode 100644 client-app/components/AnimatedSlot.tsx create mode 100644 client-app/components/Avatar.tsx create mode 100644 client-app/components/ChatTile.tsx create mode 100644 client-app/components/Composer.tsx create mode 100644 client-app/components/Header.tsx create mode 100644 client-app/components/IconButton.tsx create mode 100644 client-app/components/NavBar.tsx create mode 100644 client-app/components/SearchBar.tsx create mode 100644 client-app/components/TabHeader.tsx create mode 100644 client-app/components/chat/AttachmentMenu.tsx create mode 100644 client-app/components/chat/AttachmentPreview.tsx create mode 100644 client-app/components/chat/DaySeparator.tsx create mode 100644 client-app/components/chat/MessageBubble.tsx create mode 100644 client-app/components/chat/ReplyQuote.tsx create mode 100644 client-app/components/chat/VideoCirclePlayer.tsx create mode 100644 client-app/components/chat/VideoCircleRecorder.tsx create mode 100644 client-app/components/chat/VoicePlayer.tsx create mode 100644 client-app/components/chat/VoiceRecorder.tsx create mode 100644 client-app/components/chat/rows.ts create mode 100644 client-app/components/feed/PostCard.tsx create mode 100644 client-app/eas.json create mode 100644 client-app/global.css create mode 100644 client-app/hooks/useBalance.ts create mode 100644 client-app/hooks/useConnectionStatus.ts create mode 100644 client-app/hooks/useContacts.ts create mode 100644 client-app/hooks/useGlobalInbox.ts create mode 100644 client-app/hooks/useMessages.ts create mode 100644 client-app/hooks/useNotifications.ts create mode 100644 client-app/hooks/useWellKnownContracts.ts create mode 100644 client-app/lib/api.ts create mode 100644 client-app/lib/crypto.ts create mode 100644 client-app/lib/dates.ts create mode 100644 client-app/lib/devSeed.ts create mode 100644 client-app/lib/feed.ts create mode 100644 client-app/lib/storage.ts create mode 100644 client-app/lib/store.ts create mode 100644 client-app/lib/types.ts create mode 100644 client-app/lib/utils.ts create mode 100644 client-app/lib/ws.ts create mode 100644 client-app/metro.config.js create mode 100644 client-app/nativewind-env.d.ts create mode 100644 client-app/package-lock.json create mode 100644 client-app/package.json create mode 100644 client-app/tailwind.config.js create mode 100644 client-app/tsconfig.json diff --git a/.gitignore b/.gitignore index 9d0821e..4191450 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,7 @@ Thumbs.db # Not part of the release bundle — tracked separately CONTEXT.md CHANGELOG.md -client-app/ + +# Client app sources are tracked from v2.0.0 onwards (feed feature made +# the client a first-class part of the release). Local state (node_modules, +# build artifacts, Expo cache) is ignored via client-app/.gitignore. diff --git a/client-app/.gitignore b/client-app/.gitignore new file mode 100644 index 0000000..2e0754d --- /dev/null +++ b/client-app/.gitignore @@ -0,0 +1,42 @@ +# ── Client-app local state ───────────────────────────────────────────── + +# Dependencies (install via npm ci) +node_modules/ + +# Expo / Metro caches +.expo/ +.expo-shared/ + +# Build outputs +dist/ +web-build/ +*.apk +*.aab +*.ipa + +# TypeScript incremental build +*.tsbuildinfo + +# Env files +.env +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ +*.swp + +# Native prebuild output (Expo managed) +/android +/ios diff --git a/client-app/README.md b/client-app/README.md new file mode 100644 index 0000000..856ba71 --- /dev/null +++ b/client-app/README.md @@ -0,0 +1,93 @@ +# DChain Messenger — React Native Client + +E2E-encrypted mobile/desktop messenger built on the DChain blockchain stack. + +**Stack:** React Native · Expo · NativeWind (Tailwind) · TweetNaCl · Zustand + +## Quick Start + +```bash +cd client-app +npm install +npx expo start # opens Expo Dev Tools +# Press 'i' for iOS simulator, 'a' for Android, 'w' for web +``` + +## Requirements + +- Node.js 18+ +- [Expo Go](https://expo.dev/client) on your phone (for Expo tunnel), or iOS/Android emulator +- A running DChain node (see root README for `docker compose up --build -d`) + +## Project Structure + +``` +client-app/ +├── app/ +│ ├── _layout.tsx # Root layout — loads keys, sets up nav +│ ├── index.tsx # Welcome / onboarding +│ ├── (auth)/ +│ │ ├── create.tsx # Generate new Ed25519 + X25519 keys +│ │ ├── created.tsx # Key created — export reminder +│ │ └── import.tsx # Import existing key.json +│ └── (app)/ +│ ├── _layout.tsx # Tab bar — Chats · Wallet · Settings +│ ├── chats/ +│ │ ├── index.tsx # Chat list with contacts +│ │ └── [id].tsx # Individual chat with E2E encryption +│ ├── requests.tsx # Incoming contact requests +│ ├── new-contact.tsx # Add contact by @username or address +│ ├── wallet.tsx # Balance + TX history + send +│ └── settings.tsx # Node URL, key export, profile +├── components/ui/ # shadcn-style components (Button, Card, Input…) +├── hooks/ +│ ├── useMessages.ts # Poll relay inbox, decrypt messages +│ ├── useBalance.ts # Poll token balance +│ └── useContacts.ts # Load contacts + poll contact requests +└── lib/ + ├── api.ts # REST client for all DChain endpoints + ├── crypto.ts # NaCl box encrypt/decrypt, Ed25519 sign + ├── storage.ts # SecureStore (keys) + AsyncStorage (data) + ├── store.ts # Zustand global state + ├── types.ts # TypeScript interfaces + └── utils.ts # cn(), formatAmount(), relativeTime() +``` + +## Cryptography + +| Operation | Algorithm | Library | +|-----------|-----------|---------| +| Transaction signing | Ed25519 | TweetNaCl `sign` | +| Key exchange | X25519 (Curve25519) | TweetNaCl `box` | +| Message encryption | NaCl box (XSalsa20-Poly1305) | TweetNaCl `box` | +| Key storage | Device secure enclave | expo-secure-store | + +Messages are encrypted as: +``` +Envelope { + sender_pub: // sender's public key + recipient_pub: // recipient's public key + nonce: <24-byte hex> // random per message + ciphertext: // NaCl box(plaintext, nonce, sender_priv, recipient_pub) +} +``` + +## Connect to your node + +1. Start the DChain node: `docker compose up --build -d` +2. Open the app → Settings → Node URL → `http://YOUR_IP:8081` +3. If using Expo Go on physical device: your PC and phone must be on the same network, or use `npx expo start --tunnel` + +## Key File Format + +The `key.json` exported/imported by the app: +```json +{ + "pub_key": "26018d40...", // Ed25519 public key (64 hex chars) + "priv_key": "...", // Ed25519 private key (128 hex chars) + "x25519_pub": "...", // X25519 public key (64 hex chars) + "x25519_priv": "..." // X25519 private key (64 hex chars) +} +``` + +This is the same format as the Go node's `--key` flag. diff --git a/client-app/app.json b/client-app/app.json new file mode 100644 index 0000000..7419e5f --- /dev/null +++ b/client-app/app.json @@ -0,0 +1,69 @@ +{ + "expo": { + "name": "DChain Messenger", + "slug": "dchain-messenger", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "dark", + "backgroundColor": "#000000", + "ios": { + "supportsTablet": false, + "bundleIdentifier": "com.dchain.messenger", + "infoPlist": { + "NSMicrophoneUsageDescription": "Allow DChain to record voice messages and video.", + "NSCameraUsageDescription": "Allow DChain to record video messages and scan QR codes.", + "NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library." + } + }, + "android": { + "package": "com.dchain.messenger", + "softwareKeyboardLayoutMode": "pan", + "permissions": [ + "android.permission.RECORD_AUDIO", + "android.permission.CAMERA", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE", + "android.permission.MODIFY_AUDIO_SETTINGS" + ] + }, + "web": { + "bundler": "metro", + "output": "static" + }, + "plugins": [ + "expo-router", + "expo-secure-store", + [ + "expo-camera", + { + "cameraPermission": "Allow DChain to record video messages and scan QR codes.", + "microphonePermission": "Allow DChain to record audio with video." + } + ], + [ + "expo-image-picker", + { + "photosPermission": "Allow DChain to attach photos and videos.", + "cameraPermission": "Allow DChain to take photos." + } + ], + [ + "expo-audio", + { + "microphonePermission": "Allow DChain to record voice messages." + } + ], + "expo-video" + ], + "experiments": { + "typedRoutes": false + }, + "scheme": "dchain", + "extra": { + "router": {}, + "eas": { + "projectId": "28d7743e-6745-460f-8ce5-c971c5c297b6" + } + } + } +} diff --git a/client-app/app/(app)/_layout.tsx b/client-app/app/(app)/_layout.tsx new file mode 100644 index 0000000..5260729 --- /dev/null +++ b/client-app/app/(app)/_layout.tsx @@ -0,0 +1,81 @@ +/** + * Main app layout — кастомный `` + ``. + * + * AnimatedSlot — обёртка над Slot'ом, анимирующая переход при смене + * pathname'а. Направление анимации вычисляется по TAB_ORDER: если + * целевой tab "справа" — слайд из правой стороны, "слева" — из левой. + * + * Intra-tab навигация (chats/index → chats/[id]) обслуживается вложенным + * Stack'ом в chats/_layout.tsx — там остаётся нативная slide-from-right + * анимация, чтобы chat detail "выезжал" поверх списка. + * + * Side-effects (balance, contacts, WS auth, dev seed) — монтируются здесь + * один раз; переходы между tab'ами их не перезапускают. + */ +import React, { useEffect } from 'react'; +import { View } from 'react-native'; +import { router, usePathname } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; +import { useBalance } from '@/hooks/useBalance'; +import { useContacts } from '@/hooks/useContacts'; +import { useWellKnownContracts } from '@/hooks/useWellKnownContracts'; +import { useNotifications } from '@/hooks/useNotifications'; +import { useGlobalInbox } from '@/hooks/useGlobalInbox'; +import { getWSClient } from '@/lib/ws'; +import { useDevSeed } from '@/lib/devSeed'; +import { NavBar } from '@/components/NavBar'; +import { AnimatedSlot } from '@/components/AnimatedSlot'; + +export default function AppLayout() { + const keyFile = useStore(s => s.keyFile); + const requests = useStore(s => s.requests); + const insets = useSafeAreaInsets(); + const pathname = usePathname(); + + // NavBar прячется на full-screen экранах: + // - chat detail + // - compose (new post modal) + // - feed sub-routes (post detail, hashtag search) + const hideNav = + /^\/chats\/[^/]+/.test(pathname) || + pathname === '/compose' || + /^\/feed\/.+/.test(pathname); + + useBalance(); + useContacts(); + useWellKnownContracts(); + useDevSeed(); + useNotifications(); // permission + tap-handler + useGlobalInbox(); // global inbox listener → notifications on new peer msg + + useEffect(() => { + const ws = getWSClient(); + if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key }); + else ws.setAuthCreds(null); + }, [keyFile]); + + useEffect(() => { + if (keyFile === null) { + const t = setTimeout(() => { + if (!useStore.getState().keyFile) router.replace('/'); + }, 300); + return () => clearTimeout(t); + } + }, [keyFile]); + + return ( + + + + + {!hideNav && ( + + )} + + ); +} diff --git a/client-app/app/(app)/chats/[id].tsx b/client-app/app/(app)/chats/[id].tsx new file mode 100644 index 0000000..e7d2aa0 --- /dev/null +++ b/client-app/app/(app)/chats/[id].tsx @@ -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(null); + + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + const [peerTyping, setPeerTyping] = useState(false); + const [composeMode, setComposeMode] = useState({ kind: 'new' }); + const [pendingAttach, setPendingAttach] = useState(null); + const [attachMenuOpen, setAttachMenuOpen] = useState(false); + const [videoCircleOpen, setVideoCircleOpen] = useState(false); + /** + * ID сообщения, которое сейчас подсвечено (после jump-to-reply). На + * ~2 секунды backgroundColor bubble'а мерцает accent-цветом. + * `null` — ничего не подсвечено. + */ + const [highlightedId, setHighlightedId] = useState(null); + const highlightClearTimer = useRef | null>(null); + + // ── Selection mode ─────────────────────────────────────────────────── + // Активируется первым long-press'ом на bubble'е. Header меняется на + // toolbar с Forward/Delete/Cancel. Tap по bubble'у в selection mode + // toggle'ит принадлежность к выборке. Cancel сбрасывает всё. + const [selectedIds, setSelectedIds] = useState>(new Set()); + const selectionMode = selectedIds.size > 0; + + useMessages(contact?.x25519Pub ?? ''); + + // ── Typing indicator от peer'а ───────────────────────────────────────── + useEffect(() => { + if (!keyFile?.x25519_pub) return; + const ws = getWSClient(); + let timer: ReturnType | null = null; + const off = ws.subscribe('typing:' + keyFile.x25519_pub, (frame) => { + if (frame.event !== 'typing') return; + const d = frame.data as { from?: string } | undefined; + if (!contact?.x25519Pub || d?.from !== contact.x25519Pub) return; + setPeerTyping(true); + if (timer) clearTimeout(timer); + timer = setTimeout(() => setPeerTyping(false), 3_000); + }); + return () => { off(); if (timer) clearTimeout(timer); }; + }, [keyFile?.x25519_pub, contact?.x25519Pub]); + + // Throttled типinginisi-ping собеседнику. + const lastTypingSent = useRef(0); + const onChange = useCallback((t: string) => { + setText(t); + if (!contact?.x25519Pub || !t.trim()) return; + const now = Date.now(); + if (now - lastTypingSent.current < 2_000) return; + lastTypingSent.current = now; + getWSClient().sendTyping(contact.x25519Pub); + }, [contact?.x25519Pub]); + + // Восстановить сообщения из persistent-storage при первом заходе в чат. + // + // Важно: НЕ перезаписываем store пустым массивом — это стёрло бы + // содержимое, которое уже лежит в zustand (например, из devSeed или + // только что полученные по WS сообщения пока монтировались). Если + // в кэше что-то есть — мержим: берём max(cached, in-store) по id. + useEffect(() => { + if (!contactAddress) return; + loadMessages(contactAddress).then(cached => { + if (!cached || cached.length === 0) return; // кэш пуст → оставляем store + const existing = useStore.getState().messages[contactAddress] ?? []; + const byId = new Map(); + for (const m of cached as Message[]) byId.set(m.id, m); + for (const m of existing) byId.set(m.id, m); // store-версия свежее + const merged = Array.from(byId.values()).sort((a, b) => a.timestamp - b.timestamp); + setMsgs(contactAddress, merged); + }); + }, [contactAddress, setMsgs]); + + const name = contact?.username + ? `@${contact.username}` + : contact?.alias ?? shortAddr(contactAddress ?? ''); + + // ── Compose actions ──────────────────────────────────────────────────── + const cancelCompose = useCallback(() => { + setComposeMode({ kind: 'new' }); + setText(''); + setPendingAttach(null); + }, []); + + // buildRows выдаёт chronological [old → new]. FlatList работает + // inverted, поэтому reverse'им: newest = data[0] = снизу экрана. + // Определено тут (не позже) чтобы handlers типа onJumpToReply могли + // искать индексы по id без forward-declaration. + const rows = useMemo(() => { + const chrono = buildRows(chatMsgs); + return [...chrono].reverse(); + }, [chatMsgs]); + + /** + * Core send logic. Принимает явные text + attachment чтобы избегать + * race'а со state updates при моментальной отправке голоса/видео. + * Если передано null/undefined — берём из текущего state. + */ + const sendCore = useCallback(async ( + textArg: string | null = null, + attachArg: Attachment | null | undefined = undefined, + ) => { + if (!keyFile || !contact) return; + const actualText = textArg !== null ? textArg : text; + const actualAttach = attachArg !== undefined ? attachArg : pendingAttach; + const hasText = !!actualText.trim(); + const hasAttach = !!actualAttach; + if (!hasText && !hasAttach) return; + if (!contact.x25519Pub) { + Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.'); + return; + } + + if (composeMode.kind === 'edit') { + const target = chatMsgs.find(m => m.text === composeMode.text && m.mine); + if (!target) { cancelCompose(); return; } + const updated: Message = { ...target, text: actualText.trim(), edited: true }; + setMsgs(contact.address, chatMsgs.map(m => m.id === target.id ? updated : m)); + cancelCompose(); + return; + } + + setSending(true); + try { + if (hasText) { + const { nonce, ciphertext } = encryptMessage( + actualText.trim(), keyFile.x25519_priv, contact.x25519Pub, + ); + await sendEnvelope({ + senderPub: keyFile.x25519_pub, + recipientPub: contact.x25519Pub, + senderEd25519Pub: keyFile.pub_key, + nonce, ciphertext, + }); + } + + const msg: Message = { + id: randomId(), + from: keyFile.x25519_pub, + text: actualText.trim(), + timestamp: Math.floor(Date.now() / 1000), + mine: true, + read: false, + edited: false, + attachment: actualAttach ?? undefined, + replyTo: composeMode.kind === 'reply' + ? { id: composeMode.msgId, text: composeMode.preview, author: composeMode.author } + : undefined, + }; + appendMsg(contact.address, msg); + await appendMessage(contact.address, msg); + setText(''); + setPendingAttach(null); + setComposeMode({ kind: 'new' }); + } catch (e: any) { + Alert.alert('Send failed', e?.message ?? 'Unknown error'); + } finally { + setSending(false); + } + }, [ + text, keyFile, contact, composeMode, chatMsgs, + setMsgs, cancelCompose, appendMsg, pendingAttach, + ]); + + // UI send button + const send = useCallback(() => sendCore(), [sendCore]); + + // ── Selection handlers ─────────────────────────────────────────────── + // Long-press — входим в selection mode и сразу отмечаем это сообщение. + const onMessageLongPress = useCallback((m: Message) => { + setSelectedIds(prev => { + const next = new Set(prev); + next.add(m.id); + return next; + }); + }, []); + + // Tap в selection mode — toggle принадлежности. + const onMessageTap = useCallback((m: Message) => { + if (!selectionMode) return; + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(m.id)) next.delete(m.id); else next.add(m.id); + return next; + }); + }, [selectionMode]); + + const cancelSelection = useCallback(() => setSelectedIds(new Set()), []); + + // ── Swipe-to-reply ────────────────────────────────────────────────── + const onMessageReply = useCallback((m: Message) => { + if (selectionMode) return; + setComposeMode({ + kind: 'reply', + msgId: m.id, + author: m.mine ? 'You' : name, + preview: m.text || (m.attachment ? `(${m.attachment.kind})` : ''), + }); + }, [name, selectionMode]); + + // ── Profile navigation (tap на аватарке / имени peer'а) ────────────── + const onOpenPeerProfile = useCallback(() => { + if (!contactAddress) return; + router.push(`/(app)/profile/${contactAddress}` as never); + }, [contactAddress]); + + // ── Jump to reply: tap по quoted-блоку в bubble'е ──────────────────── + // Скроллим FlatList к оригинальному сообщению и зажигаем highlight + // на ~2 секунды (highlightedId state + useEffect-driven анимация в + // MessageBubble.highlightAnim). + const onJumpToReply = useCallback((originalId: string) => { + const idx = rows.findIndex(r => r.kind === 'msg' && r.msg.id === originalId); + if (idx < 0) { + // Сообщение не найдено (возможно удалено или ушло за пагинацию). + // Silently no-op. + return; + } + try { + listRef.current?.scrollToIndex({ + index: idx, + animated: true, + viewPosition: 0.3, // оригинал — чуть выше середины экрана, не прямо в центре + }); + } catch { + // scrollToIndex может throw'нуть если индекс за пределами рендера; + // fallback: scrollToOffset на приблизительную позицию. + } + setHighlightedId(originalId); + if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current); + highlightClearTimer.current = setTimeout(() => { + setHighlightedId(null); + highlightClearTimer.current = null; + }, 2000); + }, [rows]); + + useEffect(() => { + return () => { + if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current); + }; + }, []); + + // ── Selection actions ──────────────────────────────────────────────── + const deleteSelected = useCallback(() => { + if (selectedIds.size === 0 || !contact) return; + Alert.alert( + `Delete ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}?`, + 'This removes them from your device. Other participants keep their copies.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + setMsgs(contact.address, chatMsgs.filter(m => !selectedIds.has(m.id))); + setSelectedIds(new Set()); + }, + }, + ], + ); + }, [selectedIds, contact, chatMsgs, setMsgs]); + + const forwardSelected = useCallback(() => { + // Forward UI ещё не реализован — показываем stub. Пример потока: + // 1. открыть "Forward to…" screen со списком контактов + // 2. для каждого выбранного контакта — sendEnvelope с оригинальным + // текстом, timestamp=now + Alert.alert( + `Forward ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}`, + 'Contact-picker screen is coming in the next iteration. For now, copy the text and paste.', + [{ text: 'OK' }], + ); + }, [selectedIds]); + + // Copy доступен только когда выделено ровно одно сообщение. + const copySelected = useCallback(async () => { + if (selectedIds.size !== 1) return; + const id = [...selectedIds][0]; + const msg = chatMsgs.find(m => m.id === id); + if (!msg) return; + await Clipboard.setStringAsync(msg.text); + setSelectedIds(new Set()); + }, [selectedIds, chatMsgs]); + + // В group-чатах над peer-сообщениями рисуется имя отправителя и его + // аватар (group = несколько участников). В DM (direct) и каналах + // отправитель ровно один, поэтому имя/аватар не нужны — убираем. + const withSenderMeta = contact?.kind === 'group'; + + const renderRow = ({ item }: { item: Row }) => { + if (item.kind === 'sep') return ; + return ( + + ); + }; + + return ( + + {/* Header — использует общий компонент
, чтобы соблюдать + правила шапки приложения (left slot / centered title / right slot). */} + + {selectionMode ? ( +
} + title={`${selectedIds.size} selected`} + right={ + <> + {selectedIds.size === 1 && ( + + )} + + + + } + /> + ) : ( +
router.back()} />} + title={ + + + + + {name} + + {peerTyping && ( + + typing… + + )} + {!peerTyping && !contact?.x25519Pub && ( + + waiting for key + + )} + + + } + right={} + /> + )} + + + {/* Messages — inverted: data[0] рендерится снизу, последующее — + выше. Это стандартный chat-паттерн: FlatList сразу монтируется + с "scroll position at bottom" без ручного scrollToEnd, и новые + сообщения (добавляемые в начало reversed-массива) появляются + внизу естественно. Никаких jerk'ов при открытии. */} + r.kind === 'sep' ? r.id : r.msg.id} + renderItem={renderRow} + contentContainerStyle={{ paddingVertical: 10 }} + showsVerticalScrollIndicator={false} + ListEmptyComponent={() => ( + + + + Say hi to {name} + + + Your messages are end-to-end encrypted. + + + )} + /> + + {/* Composer — floating, прибит к низу. */} + + setAttachMenuOpen(true)} + attachment={pendingAttach} + onClearAttach={() => setPendingAttach(null)} + onFinishVoice={(att) => { + // Voice отправляется сразу — sendCore получает attachment + // явным аргументом, минуя state-задержку. + sendCore('', att); + }} + onStartVideoCircle={() => setVideoCircleOpen(true)} + /> + + + setAttachMenuOpen(false)} + onPick={(att) => setPendingAttach(att)} + /> + + setVideoCircleOpen(false)} + onFinish={(att) => { + // Video-circle тоже отправляется сразу. + sendCore('', att); + }} + /> + + ); +} diff --git a/client-app/app/(app)/chats/_layout.tsx b/client-app/app/(app)/chats/_layout.tsx new file mode 100644 index 0000000..b64a699 --- /dev/null +++ b/client-app/app/(app)/chats/_layout.tsx @@ -0,0 +1,28 @@ +/** + * chats/_layout — вложенный Stack для chats/index и chats/[id]. + * + * animation: 'none' — переходы между index и [id] анимирует родительский + * AnimatedSlot (140ms, Easing.out cubic), обеспечивая единую скорость и + * кривую между: + * - chat open/close (index ↔ [id]) + * - tab switches (chats ↔ wallet и т.д.) + * - sub-route open/close (settings, profile) + * + * gestureEnabled: true оставлен на случай если пользователь использует + * нативный iOS edge-swipe — он вызовет router.back(), анимация пройдёт + * через AnimatedSlot. + */ +import { Stack } from 'expo-router'; + +export default function ChatsLayout() { + return ( + + ); +} diff --git a/client-app/app/(app)/chats/index.tsx b/client-app/app/(app)/chats/index.tsx new file mode 100644 index 0000000..272c5c0 --- /dev/null +++ b/client-app/app/(app)/chats/index.tsx @@ -0,0 +1,105 @@ +/** + * Messages screen — список чатов в стиле референса. + * + * ┌ safe-area top + * │ TabHeader (title зависит от connection state) + * │ ─ FlatList (chat tiles) ─ + * └ NavBar (external) + * + * Фильтры и search убраны — лист один поток; requests доступны через + * NavBar → notifications tab. FAB composer'а тоже убран (чат-лист + * просто отражает существующие беседы, создание новых — через tab + * "New chat" в NavBar'е). + */ +import React, { useMemo } from 'react'; +import { View, Text, FlatList } from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useStore } from '@/lib/store'; +import { useConnectionStatus } from '@/hooks/useConnectionStatus'; +import type { Contact, Message } from '@/lib/types'; + +import { TabHeader } from '@/components/TabHeader'; +import { ChatTile } from '@/components/ChatTile'; + +export default function ChatsScreen() { + const insets = useSafeAreaInsets(); + const contacts = useStore(s => s.contacts); + const messages = useStore(s => s.messages); + + // Статус подключения: online / connecting / offline. + // Название шапки и цвет pip'а на аватаре зависят от него. + const connStatus = useConnectionStatus(); + + const headerTitle = + connStatus === 'online' ? 'Messages' : + connStatus === 'connecting' ? 'Connecting…' : + 'Waiting for internet'; + + const dotColor = + connStatus === 'online' ? '#3ba55d' : // green + connStatus === 'connecting' ? '#f0b35a' : // amber + '#f4212e'; // red + + const lastOf = (c: Contact): Message | null => { + const msgs = messages[c.address]; + return msgs && msgs.length ? msgs[msgs.length - 1] : null; + }; + + // Сортировка по последней активности. + const sorted = useMemo(() => { + return [...contacts] + .map(c => ({ c, last: lastOf(c) })) + .sort((a, b) => { + const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000; + const kb = b.last ? b.last.timestamp : b.c.addedAt / 1000; + return kb - ka; + }) + .map(x => x.c); + }, [contacts, messages]); + + return ( + + + + + c.address} + renderItem={({ item }) => ( + router.push(`/(app)/chats/${item.address}` as never)} + /> + )} + contentContainerStyle={{ paddingBottom: 40, flexGrow: 1 }} + showsVerticalScrollIndicator={false} + /> + + {sorted.length === 0 && ( + + + + No chats yet + + + Use the search tab in the navbar to add your first contact. + + + )} + + + ); +} diff --git a/client-app/app/(app)/compose.tsx b/client-app/app/(app)/compose.tsx new file mode 100644 index 0000000..a24b143 --- /dev/null +++ b/client-app/app/(app)/compose.tsx @@ -0,0 +1,390 @@ +/** + * Post composer — full-screen modal for writing a new post. + * + * Twitter-style layout: + * Header: [✕] (draft-ish) [Опубликовать button] + * Body: [avatar] [multiline TextInput autogrow] + * [hashtags preview chips] + * [attachment preview + remove button] + * Footer: [📷 attach] ··· [] [~fee estimate] + * + * The flow: + * 1. User types content; hashtags auto-parse for preview + * 2. (Optional) pick image — client-side compression (expo-image-manipulator) + * → resize to 1080px max, JPEG quality 50 + * 3. Tap "Опубликовать" → confirmation modal with fee + * 4. Confirm → publishAndCommit() → navigate to post detail + * + * Failure modes: + * - Size overflow (>256 KiB): blocked client-side with hint to compress + * further or drop attachment + * - Insufficient balance: show humanised error from submitTx + * - Network down: toast "нет связи, попробуйте снова" + */ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + View, Text, TextInput, Pressable, Alert, Image, KeyboardAvoidingView, + Platform, ActivityIndicator, ScrollView, Linking, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import * as ImagePicker from 'expo-image-picker'; +import * as ImageManipulator from 'expo-image-manipulator'; +import * as FileSystem from 'expo-file-system/legacy'; + +import { useStore } from '@/lib/store'; +import { Avatar } from '@/components/Avatar'; +import { publishAndCommit, formatFee } from '@/lib/feed'; +import { humanizeTxError, getBalance } from '@/lib/api'; + +const MAX_CONTENT_LENGTH = 4000; +const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize +const IMAGE_MAX_DIM = 1080; +const IMAGE_QUALITY = 0.5; // JPEG Q=50 — small, still readable + +interface Attachment { + uri: string; + mime: string; + size: number; + bytes: Uint8Array; + width?: number; + height?: number; +} + +export default function ComposeScreen() { + const insets = useSafeAreaInsets(); + const keyFile = useStore(s => s.keyFile); + const username = useStore(s => s.username); + + const [content, setContent] = useState(''); + const [attach, setAttach] = useState(null); + const [busy, setBusy] = useState(false); + const [picking, setPicking] = useState(false); + const [balance, setBalance] = useState(null); + + // Fetch balance once so we can warn before publishing. + useEffect(() => { + if (!keyFile) return; + getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null)); + }, [keyFile]); + + // Estimated fee mirrors server's formula exactly. Displayed to the user + // so they aren't surprised by a debit. + const estimatedFee = useMemo(() => { + const size = (new TextEncoder().encode(content)).length + (attach?.size ?? 0) + 128; + return 1000 + size; // base 1000 + 1 µT/byte (matches blockchain constants) + }, [content, attach]); + + const totalBytes = useMemo(() => { + return (new TextEncoder().encode(content)).length + (attach?.size ?? 0) + 128; + }, [content, attach]); + + const hashtags = useMemo(() => { + const matches = content.match(/#[A-Za-z0-9_\u0400-\u04FF]{1,40}/g) || []; + const seen = new Set(); + return matches + .map(m => m.slice(1).toLowerCase()) + .filter(t => !seen.has(t) && seen.add(t)); + }, [content]); + + const canPublish = !busy && (content.trim().length > 0 || attach !== null) + && totalBytes <= MAX_POST_BYTES; + + const onPickImage = async () => { + if (picking) return; + setPicking(true); + try { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) { + Alert.alert( + 'Нужен доступ к фото', + 'Откройте настройки и разрешите доступ к галерее.', + [ + { text: 'Отмена' }, + { text: 'Настройки', onPress: () => Linking.openSettings() }, + ], + ); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 1, + exif: false, // privacy: ask picker not to return EXIF + }); + if (result.canceled || !result.assets[0]) return; + + const asset = result.assets[0]; + + // Client-side compression: resize + re-encode. This is the FIRST + // scrub pass — server will do another one (mandatory) before storing. + const manipulated = await ImageManipulator.manipulateAsync( + asset.uri, + [{ resize: { width: IMAGE_MAX_DIM } }], + { compress: IMAGE_QUALITY, format: ImageManipulator.SaveFormat.JPEG }, + ); + + // Read the compressed bytes. + const b64 = await FileSystem.readAsStringAsync(manipulated.uri, { + encoding: FileSystem.EncodingType.Base64, + }); + const bytes = base64ToBytes(b64); + + if (bytes.length > MAX_POST_BYTES - 512) { + Alert.alert( + 'Слишком большое', + `Картинка ${Math.round(bytes.length / 1024)} KB — лимит ${MAX_POST_BYTES / 1024} KB. Попробуйте выбрать поменьше.`, + ); + return; + } + + setAttach({ + uri: manipulated.uri, + mime: 'image/jpeg', + size: bytes.length, + bytes, + width: manipulated.width, + height: manipulated.height, + }); + } catch (e: any) { + Alert.alert('Не удалось', String(e?.message ?? e)); + } finally { + setPicking(false); + } + }; + + const onPublish = async () => { + if (!keyFile || !canPublish) return; + + // Balance guard. + if (balance !== null && balance < estimatedFee) { + Alert.alert( + 'Недостаточно средств', + `Нужно ${formatFee(estimatedFee)}, на балансе ${formatFee(balance)}.`, + ); + return; + } + + Alert.alert( + 'Опубликовать пост?', + `Цена: ${formatFee(estimatedFee)}\nРазмер: ${Math.round(totalBytes / 1024 * 10) / 10} KB`, + [ + { text: 'Отмена', style: 'cancel' }, + { + text: 'Опубликовать', + onPress: async () => { + setBusy(true); + try { + const postID = await publishAndCommit({ + author: keyFile.pub_key, + privKey: keyFile.priv_key, + content: content.trim(), + attachment: attach?.bytes, + attachmentMIME: attach?.mime, + }); + // Close composer and open the new post. + router.replace(`/(app)/feed/${postID}` as never); + } catch (e: any) { + Alert.alert('Не удалось опубликовать', humanizeTxError(e)); + } finally { + setBusy(false); + } + }, + }, + ], + ); + }; + + return ( + + {/* Header */} + + router.back()} hitSlop={8}> + + + + ({ + paddingHorizontal: 18, paddingVertical: 9, + borderRadius: 999, + backgroundColor: canPublish ? (pressed ? '#1a8cd8' : '#1d9bf0') : '#1f1f1f', + })} + > + {busy ? ( + + ) : ( + + Опубликовать + + )} + + + + + {/* Avatar + TextInput row */} + + + + + + {/* Hashtag preview */} + {hashtags.length > 0 && ( + + {hashtags.map(tag => ( + + + #{tag} + + + ))} + + )} + + {/* Attachment preview */} + {attach && ( + + + + setAttach(null)} + hitSlop={8} + style={({ pressed }) => ({ + position: 'absolute', + top: 8, right: 8, + width: 28, height: 28, borderRadius: 14, + backgroundColor: pressed ? 'rgba(0,0,0,0.9)' : 'rgba(0,0,0,0.75)', + alignItems: 'center', justifyContent: 'center', + })} + > + + + + + {Math.round(attach.size / 1024)} KB · метаданные удалят на сервере + + + )} + + + {/* Footer: attach / counter / fee */} + + ({ + opacity: pressed || picking || attach ? 0.5 : 1, + })} + > + {picking + ? + : } + + + MAX_POST_BYTES ? '#f4212e' + : totalBytes > MAX_POST_BYTES * 0.85 ? '#f0b35a' + : '#6a6a6a', + fontSize: 12, + fontWeight: '600', + }} + > + {Math.round(totalBytes / 1024 * 10) / 10} / {MAX_POST_BYTES / 1024} KB + + + + ≈ {formatFee(estimatedFee)} + + + + ); +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64.replace(/-/g, '+').replace(/_/g, '/')); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} diff --git a/client-app/app/(app)/feed.tsx b/client-app/app/(app)/feed.tsx new file mode 100644 index 0000000..b935fd7 --- /dev/null +++ b/client-app/app/(app)/feed.tsx @@ -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 = { + following: 'Подписки', + foryou: 'Для вас', + trending: 'В тренде', +}; + +export default function FeedScreen() { + const insets = useSafeAreaInsets(); + const keyFile = useStore(s => s.keyFile); + + const [tab, setTab] = useState('foryou'); // default: discovery + const [posts, setPosts] = useState([]); + const [likedSet, setLikedSet] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(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(); + 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>(new Set()); + const onViewableItemsChanged = useRef(({ viewableItems }: { viewableItems: Array<{ item: FeedPostItem; isViewable: boolean }> }) => { + for (const { item, isViewable } of viewableItems) { + if (isViewable && !viewedRef.current.has(item.post_id)) { + viewedRef.current.add(item.post_id); + bumpView(item.post_id); + } + } + }).current; + + const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 60, minimumViewTime: 1000 }).current; + + const emptyHint = useMemo(() => { + switch (tab) { + case 'following': return 'Подпишитесь на кого-нибудь, чтобы увидеть их посты здесь.'; + case 'foryou': return 'Пока нет рекомендаций — возвращайтесь позже.'; + case 'trending': return 'В этой ленте пока тихо.'; + } + }, [tab]); + + return ( + + + + {/* Tab strip */} + + {(Object.keys(TAB_LABELS) as TabKey[]).map(key => ( + setTab(key)} + style={({ pressed }) => ({ + flex: 1, + alignItems: 'center', + paddingVertical: 14, + backgroundColor: pressed ? '#0a0a0a' : 'transparent', + })} + > + + {TAB_LABELS[key]} + + {tab === key && ( + + )} + + ))} + + + {/* Feed list */} + p.post_id} + renderItem={({ item }) => ( + + )} + refreshControl={ + loadPosts(true)} + tintColor="#1d9bf0" + /> + } + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={viewabilityConfig} + ListEmptyComponent={ + loading ? ( + + + + ) : error ? ( + loadPosts(false)} + /> + ) : ( + + ) + } + contentContainerStyle={posts.length === 0 ? { flexGrow: 1 } : undefined} + /> + + {/* Floating compose button */} + 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, + })} + > + + + + ); +} + +// ── Empty state ───────────────────────────────────────────────────────── + +function EmptyState({ + icon, title, subtitle, onRetry, +}: { + icon: React.ComponentProps['name']; + title: string; + subtitle?: string; + onRetry?: () => void; +}) { + return ( + + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + {onRetry && ( + ({ + marginTop: 16, + paddingHorizontal: 20, paddingVertical: 10, + borderRadius: 999, + backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + + Попробовать снова + + + )} + + ); +} + +function chunk(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} diff --git a/client-app/app/(app)/feed/[id].tsx b/client-app/app/(app)/feed/[id].tsx new file mode 100644 index 0000000..090aa12 --- /dev/null +++ b/client-app/app/(app)/feed/[id].tsx @@ -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(null); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!postID) return; + setLoading(true); + setError(null); + try { + const [p, s] = await Promise.all([ + fetchPost(postID), + fetchStats(postID, keyFile?.pub_key), + ]); + setPost(p); + setStats(s); + if (p) bumpView(postID); // fire-and-forget + } catch (e: any) { + setError(String(e?.message ?? e)); + } finally { + setLoading(false); + } + }, [postID, keyFile]); + + useEffect(() => { load(); }, [load]); + + const onStatsChanged = useCallback(async () => { + if (!postID) return; + const s = await fetchStats(postID, keyFile?.pub_key); + if (s) setStats(s); + }, [postID, keyFile]); + + const onDeleted = useCallback(() => { + // Go back to feed — the post is gone. + router.back(); + }, []); + + return ( + +
router.back()} />} + title="Пост" + /> + + {loading ? ( + + + + ) : error ? ( + + {error} + + ) : !post ? ( + + + + Пост удалён или больше недоступен + + + ) : ( + + + + {/* 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 && ( + + )} + + {/* Detailed stats block */} + + + Информация о посте + + + + + + + + + {post.hashtags && post.hashtags.length > 0 && ( + <> + + + Хештеги + + + {post.hashtags.map(tag => ( + router.push(`/(app)/feed/tag/${encodeURIComponent(tag)}` as never)} + style={{ + color: '#1d9bf0', + fontSize: 13, + paddingHorizontal: 8, + paddingVertical: 3, + backgroundColor: '#081a2a', + borderRadius: 999, + }} + > + #{tag} + + ))} + + + )} + + + + + )} + + ); +} + +function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +function 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 ( + + + + Вложение: {url} + + + Прямой просмотр вложений — в следующем релизе + + + ); +} + +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; diff --git a/client-app/app/(app)/feed/tag/[tag].tsx b/client-app/app/(app)/feed/tag/[tag].tsx new file mode 100644 index 0000000..e9c7d78 --- /dev/null +++ b/client-app/app/(app)/feed/tag/[tag].tsx @@ -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([]); + const [likedSet, setLikedSet] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const seq = useRef(0); + + const load = useCallback(async (isRefresh = false) => { + if (!tag) return; + if (isRefresh) setRefreshing(true); + else setLoading(true); + + const id = ++seq.current; + try { + const items = await fetchHashtag(tag, 60); + if (id !== seq.current) return; + setPosts(items); + + const liked = new Set(); + if (keyFile) { + for (const p of items) { + const s = await fetchStats(p.post_id, keyFile.pub_key); + if (s?.liked_by_me) liked.add(p.post_id); + } + } + if (id !== seq.current) return; + setLikedSet(liked); + } catch { + if (id !== seq.current) return; + setPosts([]); + } finally { + if (id !== seq.current) return; + setLoading(false); + setRefreshing(false); + } + }, [tag, keyFile]); + + useEffect(() => { load(false); }, [load]); + + const onStatsChanged = useCallback(async (postID: string) => { + if (!keyFile) return; + const s = await fetchStats(postID, keyFile.pub_key); + if (!s) return; + setPosts(ps => ps.map(p => p.post_id === postID + ? { ...p, likes: s.likes, views: s.views } : p)); + setLikedSet(set => { + const next = new Set(set); + if (s.liked_by_me) next.add(postID); else next.delete(postID); + return next; + }); + }, [keyFile]); + + return ( + +
router.back()} />} + title={`#${tag}`} + /> + + p.post_id} + renderItem={({ item }) => ( + + )} + refreshControl={ + load(true)} + tintColor="#1d9bf0" + /> + } + ListEmptyComponent={ + loading ? ( + + + + ) : ( + + + + Пока нет постов с этим тегом + + + Будьте первым — напишите пост с #{tag} + + + ) + } + contentContainerStyle={posts.length === 0 ? { flexGrow: 1 } : undefined} + /> + + ); +} diff --git a/client-app/app/(app)/new-contact.tsx b/client-app/app/(app)/new-contact.tsx new file mode 100644 index 0000000..e2636eb --- /dev/null +++ b/client-app/app/(app)/new-contact.tsx @@ -0,0 +1,288 @@ +/** + * Add new contact — dark minimalist, inspired by the reference. + * + * Flow: + * 1. Пользователь вводит @username или hex pubkey / DC-address. + * 2. Жмёт Search → resolveUsername → getIdentity. + * 3. Показываем preview (avatar + имя + address + наличие x25519). + * 4. Выбирает fee (chip-selector) + вводит intro. + * 5. Submit → CONTACT_REQUEST tx. + */ +import React, { useState } from 'react'; +import { + View, Text, ScrollView, Alert, Pressable, TextInput, ActivityIndicator, +} from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; +import { getIdentity, buildContactRequestTx, submitTx, resolveUsername, humanizeTxError } from '@/lib/api'; +import { shortAddr } from '@/lib/crypto'; +import { formatAmount } from '@/lib/utils'; + +import { Avatar } from '@/components/Avatar'; +import { Header } from '@/components/Header'; +import { IconButton } from '@/components/IconButton'; +import { SearchBar } from '@/components/SearchBar'; + +const MIN_CONTACT_FEE = 5000; +const FEE_TIERS = [ + { value: 5_000, label: 'Min' }, + { value: 10_000, label: 'Standard' }, + { value: 50_000, label: 'Priority' }, +]; + +interface Resolved { + address: string; + nickname?: string; + x25519?: string; +} + +export default function NewContactScreen() { + const insets = useSafeAreaInsets(); + const keyFile = useStore(s => s.keyFile); + const settings = useStore(s => s.settings); + const balance = useStore(s => s.balance); + + const [query, setQuery] = useState(''); + const [intro, setIntro] = useState(''); + const [fee, setFee] = useState(MIN_CONTACT_FEE); + const [resolved, setResolved] = useState(null); + const [searching, setSearching] = useState(false); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + + async function search() { + const q = query.trim(); + if (!q) return; + setSearching(true); setResolved(null); setError(null); + try { + let address = q; + if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) { + const name = q.replace('@', ''); + const addr = await resolveUsername(settings.contractId, name); + if (!addr) { setError(`@${name} is not registered on this chain`); return; } + address = addr; + } + const identity = await getIdentity(address); + setResolved({ + address: identity?.pub_key ?? address, + nickname: identity?.nickname || undefined, + x25519: identity?.x25519_pub || undefined, + }); + } catch (e: any) { + setError(e?.message ?? 'Lookup failed'); + } finally { + setSearching(false); + } + } + + async function sendRequest() { + if (!resolved || !keyFile) return; + if (balance < fee + 1000) { + Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (fee + network).`); + return; + } + setSending(true); setError(null); + try { + const tx = buildContactRequestTx({ + from: keyFile.pub_key, + to: resolved.address, + contactFee: fee, + intro: intro.trim() || undefined, + privKey: keyFile.priv_key, + }); + await submitTx(tx); + Alert.alert( + 'Request sent', + `A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`, + [{ text: 'OK', onPress: () => router.back() }], + ); + } catch (e: any) { + setError(humanizeTxError(e)); + } finally { + setSending(false); + } + } + + const displayName = resolved + ? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address)) + : ''; + + return ( + +
router.back()} />} + /> + + + Enter a @username, a + hex pubkey or a DC… address. + + + + + ({ + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + paddingVertical: 11, borderRadius: 999, marginTop: 12, + backgroundColor: !query.trim() || searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + {searching ? ( + + ) : ( + Search + )} + + + {error && ( + + {error} + + )} + + {/* Resolved profile card */} + {resolved && ( + <> + + + + + + {displayName} + + + {shortAddr(resolved.address, 10)} + + + + + {resolved.x25519 ? 'E2E-ready' : 'Key not published yet'} + + + + + + + {/* Intro */} + + Intro (optional, plaintext on-chain) + + + + {intro.length}/140 + + + {/* Fee tier */} + + Anti-spam fee (goes to recipient) + + + {FEE_TIERS.map(t => { + const active = fee === t.value; + return ( + setFee(t.value)} + style={({ pressed }) => ({ + flex: 1, + alignItems: 'center', + paddingVertical: 10, + borderRadius: 10, + backgroundColor: active ? '#ffffff' : pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: active ? '#ffffff' : '#1f1f1f', + })} + > + + {t.label} + + + {formatAmount(t.value)} + + + ); + })} + + + {/* Submit */} + ({ + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + paddingVertical: 13, borderRadius: 999, marginTop: 20, + backgroundColor: sending ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + {sending ? ( + + ) : ( + + Send request · {formatAmount(fee + 1000)} + + )} + + + )} + + + ); +} diff --git a/client-app/app/(app)/profile/[address].tsx b/client-app/app/(app)/profile/[address].tsx new file mode 100644 index 0000000..2688954 --- /dev/null +++ b/client-app/app/(app)/profile/[address].tsx @@ -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/ + * + * 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('posts'); + const [posts, setPosts] = useState([]); + const [likedSet, setLikedSet] = useState>(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(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(); + 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 = ( + + + + + {!isMe ? ( + ({ + 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 ? ( + + ) : ( + + {following ? 'Вы подписаны' : 'Подписаться'} + + )} + + ) : ( + router.push('/(app)/settings' as never)} + style={({ pressed }) => ({ + paddingHorizontal: 18, paddingVertical: 9, + borderRadius: 999, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + })} + > + + Редактировать + + + )} + + + + + {displayName} + + {contact?.username && ( + + )} + + + {shortAddr(address ?? '')} + + + {/* 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). */} + + + {formatCount(posts.length)} + постов + + + + {/* Secondary actions: open chat + copy address */} + {!isMe && contact && ( + + ({ + flex: 1, + alignItems: 'center', justifyContent: 'center', + paddingVertical: 10, borderRadius: 999, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + flexDirection: 'row', gap: 6, + })} + > + + + Чат + + + address && copy(address, 'address')} + style={({ pressed }) => ({ + flex: 1, + alignItems: 'center', justifyContent: 'center', + paddingVertical: 10, borderRadius: 999, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + })} + > + + {copied === 'address' ? 'Скопировано' : 'Копировать адрес'} + + + + )} + + ); + + // ── Tab strip ──────────────────────────────────────────────────────── + + const TabStrip = ( + + {(['posts', 'info'] as Tab[]).map(key => ( + setTab(key)} + style={{ + flex: 1, + alignItems: 'center', + paddingVertical: 12, + }} + > + + {key === 'posts' ? 'Посты' : 'Инфо'} + + {tab === key && ( + + )} + + ))} + + ); + + // ── Content per tab ───────────────────────────────────────────────── + + if (tab === 'posts') { + return ( + +
router.back()} />} + /> + p.post_id} + renderItem={({ item }) => ( + + )} + ListHeaderComponent={ + <> + {Hero} + {TabStrip} + + } + refreshControl={ + loadPosts(true)} + tintColor="#1d9bf0" + /> + } + ListEmptyComponent={ + loadingPosts ? ( + + + + ) : ( + + + + Пока нет постов + + + {isMe + ? 'Нажмите на синюю кнопку в ленте, чтобы написать первый.' + : 'Этот пользователь ещё ничего не публиковал.'} + + + ) + } + /> + + ); + } + + // Info tab + return ( + +
router.back()} />} + /> + + {Hero} + {TabStrip} + + + + + {contact && ( + <> + + + + )} + + + + + + ); +} + +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 ( + + {label} + + {value} + + + ); +} + +// Silence unused-import lint for Contact type used only in helpers. +const _contactType: Contact | null = null; void _contactType; diff --git a/client-app/app/(app)/requests.tsx b/client-app/app/(app)/requests.tsx new file mode 100644 index 0000000..b3f9f59 --- /dev/null +++ b/client-app/app/(app)/requests.tsx @@ -0,0 +1,173 @@ +/** + * Contact requests / notifications — dark minimalist. + * + * В референсе нижний таб «notifications» ведёт сюда. Пока это только + * incoming CONTACT_REQUEST'ы; позже сюда же придут другие системные + * уведомления (slash, ADD_VALIDATOR со-sig-ing, и т.д.). + */ +import React, { useState } from 'react'; +import { View, Text, FlatList, Alert, Pressable, ActivityIndicator } from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; +import { + buildAcceptContactTx, submitTx, getIdentity, humanizeTxError, +} from '@/lib/api'; +import { saveContact } from '@/lib/storage'; +import { shortAddr } from '@/lib/crypto'; +import { relativeTime } from '@/lib/utils'; +import type { ContactRequest } from '@/lib/types'; + +import { Avatar } from '@/components/Avatar'; +import { TabHeader } from '@/components/TabHeader'; +import { IconButton } from '@/components/IconButton'; + +export default function RequestsScreen() { + const insets = useSafeAreaInsets(); + const keyFile = useStore(s => s.keyFile); + const requests = useStore(s => s.requests); + const setRequests = useStore(s => s.setRequests); + const upsertContact = useStore(s => s.upsertContact); + + const [accepting, setAccepting] = useState(null); + + async function accept(req: ContactRequest) { + if (!keyFile) return; + setAccepting(req.txHash); + try { + const identity = await getIdentity(req.from); + const x25519Pub = identity?.x25519_pub ?? ''; + + const tx = buildAcceptContactTx({ + from: keyFile.pub_key, to: req.from, privKey: keyFile.priv_key, + }); + await submitTx(tx); + + const contact = { address: req.from, x25519Pub, username: req.username, addedAt: Date.now() }; + upsertContact(contact); + await saveContact(contact); + + setRequests(requests.filter(r => r.txHash !== req.txHash)); + router.replace(`/(app)/chats/${req.from}` as never); + } catch (e: any) { + Alert.alert('Accept failed', humanizeTxError(e)); + } finally { + setAccepting(null); + } + } + + function decline(req: ContactRequest) { + Alert.alert( + 'Decline request', + `Decline request from ${req.username ? '@' + req.username : shortAddr(req.from)}?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Decline', + style: 'destructive', + onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)), + }, + ], + ); + } + + const renderItem = ({ item: req }: { item: ContactRequest }) => { + const name = req.username ? `@${req.username}` : shortAddr(req.from); + const isAccepting = accepting === req.txHash; + return ( + + + + + {name} + + + wants to message you · {relativeTime(req.timestamp)} + + {req.intro ? ( + + {req.intro} + + ) : null} + + + accept(req)} + disabled={isAccepting} + style={({ pressed }) => ({ + flex: 1, + alignItems: 'center', justifyContent: 'center', + paddingVertical: 9, borderRadius: 999, + backgroundColor: isAccepting ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + {isAccepting ? ( + + ) : ( + Accept + )} + + decline(req)} + disabled={isAccepting} + style={({ pressed }) => ({ + flex: 1, + alignItems: 'center', justifyContent: 'center', + paddingVertical: 9, borderRadius: 999, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + })} + > + Decline + + + + + ); + }; + + return ( + + + + {requests.length === 0 ? ( + + + + All caught up + + + Contact requests and network events will appear here. + + + ) : ( + r.txHash} + renderItem={renderItem} + contentContainerStyle={{ paddingBottom: 120 }} + showsVerticalScrollIndicator={false} + /> + )} + + ); +} diff --git a/client-app/app/(app)/settings.tsx b/client-app/app/(app)/settings.tsx new file mode 100644 index 0000000..854fb2d --- /dev/null +++ b/client-app/app/(app)/settings.tsx @@ -0,0 +1,595 @@ +/** + * Settings screen — sub-route, открывается по tap'у на profile-avatar в + * TabHeader. Использует обычный `
` с back-кнопкой. + * + * Секции: + * 1. Профиль — avatar, @username, short-address, Copy row. + * 2. Username — регистрация в native:username_registry (если не куплено). + * 3. Node — URL + contract ID + Save + Status. + * 4. Account — Export key, Delete account. + * + * Весь Pressable'овый layout живёт на ВНЕШНЕМ View с static style — + * Pressable handle-ит только background change (через вложенный View + * в ({pressed}) callback'е), никаких layout props в callback-style. + * Это лечит web-баг, где Pressable style-функция не применяет + * percentage/padding layout надёжно. + */ +import React, { useState, useEffect } from 'react'; +import { + View, Text, ScrollView, TextInput, Alert, Pressable, ActivityIndicator, Share, +} from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useStore } from '@/lib/store'; +import { saveSettings, deleteKeyFile } from '@/lib/storage'; +import { + setNodeUrl, getNetStats, resolveUsername, reverseResolve, + buildCallContractTx, submitTx, + USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH, + humanizeTxError, +} from '@/lib/api'; +import { shortAddr } from '@/lib/crypto'; +import { formatAmount } from '@/lib/utils'; + +import { Avatar } from '@/components/Avatar'; +import { Header } from '@/components/Header'; +import { IconButton } from '@/components/IconButton'; + +type NodeStatus = 'idle' | 'checking' | 'ok' | 'error'; +type IoniconName = React.ComponentProps['name']; + +// ─── Shared layout primitives ───────────────────────────────────── + +function SectionLabel({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function Card({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** + * Row — clickable / non-clickable list item внутри Card'а. + * + * Layout живёт на ВНЕШНЕМ контейнере (View если read-only, Pressable + * если tappable). Для pressed-стейта используется вложенный `` + * с background-color, чтобы не полагаться на style-функцию Pressable'а + * (web-баг). + */ +function Row({ + icon, label, value, onPress, right, danger, first, +}: { + icon: IoniconName; + label: string; + value?: string; + onPress?: () => void; + right?: React.ReactNode; + danger?: boolean; + first?: boolean; +}) { + const body = (pressed: boolean) => ( + + + + + + + {label} + + {value !== undefined && ( + + {value} + + )} + + {right} + {onPress && !right && ( + + )} + + ); + + if (!onPress) return {body(false)}; + return ( + + {({ pressed }) => body(pressed)} + + ); +} + +// ─── Screen ─────────────────────────────────────────────────────── + +export default function SettingsScreen() { + const insets = useSafeAreaInsets(); + const keyFile = useStore(s => s.keyFile); + const setKeyFile = useStore(s => s.setKeyFile); + const settings = useStore(s => s.settings); + const setSettings = useStore(s => s.setSettings); + const username = useStore(s => s.username); + const setUsername = useStore(s => s.setUsername); + const balance = useStore(s => s.balance); + + const [nodeUrl, setNodeUrlInput] = useState(settings.nodeUrl); + const [contractId, setContractId] = useState(settings.contractId); + const [nodeStatus, setNodeStatus] = useState('idle'); + const [peerCount, setPeerCount] = useState(null); + const [blockCount, setBlockCount] = useState(null); + const [copied, setCopied] = useState(false); + const [savingNode, setSavingNode] = useState(false); + + // Username registration state + const [nameInput, setNameInput] = useState(''); + const [nameError, setNameError] = useState(null); + const [registering, setRegistering] = useState(false); + + useEffect(() => { checkNode(); }, []); + useEffect(() => { setContractId(settings.contractId); }, [settings.contractId]); + useEffect(() => { + if (!settings.contractId || !keyFile) { setUsername(null); return; } + (async () => { + const name = await reverseResolve(settings.contractId, keyFile.pub_key); + setUsername(name); + })(); + }, [settings.contractId, keyFile, setUsername]); + + async function checkNode() { + setNodeStatus('checking'); + try { + const stats = await getNetStats(); + setNodeStatus('ok'); + setPeerCount(stats.peer_count); + setBlockCount(stats.total_blocks); + } catch { + setNodeStatus('error'); + } + } + + async function saveNode() { + setSavingNode(true); + const url = nodeUrl.trim().replace(/\/$/, ''); + setNodeUrl(url); + const next = { nodeUrl: url, contractId: contractId.trim() }; + setSettings(next); + await saveSettings(next); + await checkNode(); + setSavingNode(false); + Alert.alert('Saved', 'Node settings updated.'); + } + + async function copyAddress() { + if (!keyFile) return; + await Clipboard.setStringAsync(keyFile.pub_key); + setCopied(true); + setTimeout(() => setCopied(false), 1800); + } + + async function exportKey() { + if (!keyFile) return; + try { + await Share.share({ + message: JSON.stringify(keyFile, null, 2), + title: 'DChain key file', + }); + } catch (e: any) { + Alert.alert('Export failed', e?.message ?? 'Unknown error'); + } + } + + function logout() { + Alert.alert( + 'Delete account', + 'Your key will be removed from this device. Make sure you have a backup!', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + await deleteKeyFile(); + setKeyFile(null); + router.replace('/'); + }, + }, + ], + ); + } + + const onNameChange = (v: string) => { + const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH); + setNameInput(cleaned); + setNameError(null); + }; + const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput); + + async function registerUsername() { + if (!keyFile) return; + const name = nameInput.trim(); + if (!nameIsValid) { + setNameError(`Min ${MIN_USERNAME_LENGTH} chars, starts with a-z`); + return; + } + if (!settings.contractId) { + setNameError('No registry contract in node settings'); + return; + } + const total = USERNAME_REGISTRATION_FEE + 1000 + 2000; + if (balance < total) { + setNameError(`Need ${formatAmount(total)}, have ${formatAmount(balance)}`); + return; + } + try { + const existing = await resolveUsername(settings.contractId, name); + if (existing) { setNameError(`@${name} already taken`); return; } + } catch { /* ignore */ } + + Alert.alert( + `Buy @${name}?`, + `Cost: ${formatAmount(USERNAME_REGISTRATION_FEE)} + fee ${formatAmount(1000)}.\nBinds to your address until released.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Buy', + onPress: async () => { + setRegistering(true); + setNameError(null); + try { + const tx = buildCallContractTx({ + from: keyFile.pub_key, + contractId: settings.contractId, + method: 'register', + args: [name], + amount: USERNAME_REGISTRATION_FEE, + privKey: keyFile.priv_key, + }); + await submitTx(tx); + setNameInput(''); + Alert.alert('Submitted', 'Registration tx accepted. Name appears in a few seconds.'); + let attempts = 0; + const iv = setInterval(async () => { + attempts++; + const got = keyFile + ? await reverseResolve(settings.contractId, keyFile.pub_key) + : null; + if (got) { setUsername(got); clearInterval(iv); } + else if (attempts >= 10) clearInterval(iv); + }, 2000); + } catch (e: any) { + setNameError(humanizeTxError(e)); + } finally { + setRegistering(false); + } + }, + }, + ], + ); + } + + const statusColor = + nodeStatus === 'ok' ? '#3ba55d' : + nodeStatus === 'error' ? '#f4212e' : + '#f0b35a'; + const statusLabel = + nodeStatus === 'ok' ? 'Connected' : + nodeStatus === 'error' ? 'Unreachable' : + 'Checking…'; + + return ( + +
router.back()} />} + /> + + {/* ── Profile ── */} + Profile + + + + + {username ? ( + + + @{username} + + + + ) : ( + No username yet + )} + + {keyFile ? shortAddr(keyFile.pub_key, 10) : '—'} + + + + } + /> + + + {/* ── Username (только если ещё нет) ── */} + {!username && ( + <> + Username + + + + Buy a username + + + Flat {formatAmount(USERNAME_REGISTRATION_FEE)} fee + {formatAmount(1000)} network. + Only a-z, 0-9, _, -. Starts with a letter. + + + + @ + + + {nameError && ( + + {nameError} + + )} + + + + + + )} + + {/* ── Node ── */} + Node + + + + + + + + + } + /> + + + {/* ── Account ── */} + Account + + + + + + + ); +} + +// ─── Form primitives ────────────────────────────────────────────── + +function LabeledInput({ + label, value, onChangeText, placeholder, monospace, +}: { + label: string; + value: string; + onChangeText: (v: string) => void; + placeholder?: string; + monospace?: boolean; +}) { + return ( + + {label} + + + ); +} + +function PrimaryButton({ + label, onPress, disabled, loading, style, +}: { + label: string; + onPress: () => void; + disabled?: boolean; + loading?: boolean; + style?: object; +}) { + return ( + + {({ pressed }) => ( + + {loading ? ( + + ) : ( + + {label} + + )} + + )} + + ); +} diff --git a/client-app/app/(app)/wallet.tsx b/client-app/app/(app)/wallet.tsx new file mode 100644 index 0000000..33c5393 --- /dev/null +++ b/client-app/app/(app)/wallet.tsx @@ -0,0 +1,652 @@ +/** + * Wallet screen — dark minimalist. + * + * Сетка: + * [TabHeader: profile-avatar | Wallet | refresh] + * [Balance hero card — gradient-ish dark card, big number, address chip, action row] + * [SectionLabel: Recent transactions] + * [TX list card — tiles per tx, in/out coloring, relative time] + * [Send modal: slide-up sheet с полями recipient/amount/fee + total preview] + * + * Все кнопки и инпуты — те же плоские стили, что на других экранах. + * Никаких style-функций у Pressable'ов с layout-пропсами (избегаем web + * layout-баги, которые мы уже ловили на ChatTile/MessageBubble). + */ +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { + View, Text, ScrollView, Modal, Alert, RefreshControl, Pressable, TextInput, ActivityIndicator, +} from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useStore } from '@/lib/store'; +import { useBalance } from '@/hooks/useBalance'; +import { buildTransferTx, submitTx, getTxHistory, getBalance, humanizeTxError } from '@/lib/api'; +import { shortAddr } from '@/lib/crypto'; +import { formatAmount, relativeTime } from '@/lib/utils'; +import type { TxRecord } from '@/lib/types'; + +import { TabHeader } from '@/components/TabHeader'; +import { IconButton } from '@/components/IconButton'; + +// ─── TX meta (icon + label + tone) ───────────────────────────────── + +type IoniconName = React.ComponentProps['name']; + +interface TxMeta { + label: string; + icon: IoniconName; + tone: 'in' | 'out' | 'neutral'; +} + +const TX_META: Record = { + TRANSFER: { label: 'Transfer', icon: 'swap-horizontal-outline', tone: 'neutral' }, + CONTACT_REQUEST: { label: 'Contact request', icon: 'person-add-outline', tone: 'out' }, + ACCEPT_CONTACT: { label: 'Contact accepted', icon: 'person-outline', tone: 'in' }, + BLOCK_CONTACT: { label: 'Block', icon: 'ban-outline', tone: 'out' }, + DEPLOY_CONTRACT: { label: 'Deploy', icon: 'document-text-outline', tone: 'out' }, + CALL_CONTRACT: { label: 'Call contract', icon: 'flash-outline', tone: 'out' }, + STAKE: { label: 'Stake', icon: 'lock-closed-outline', tone: 'out' }, + UNSTAKE: { label: 'Unstake', icon: 'lock-open-outline', tone: 'in' }, + REGISTER_KEY: { label: 'Register key', icon: 'key-outline', tone: 'neutral' }, + BLOCK_REWARD: { label: 'Block reward', icon: 'diamond-outline', tone: 'in' }, +}; + +function txMeta(type: string): TxMeta { + return TX_META[type] ?? { label: type.replace(/_/g, ' '), icon: 'ellipse-outline', tone: 'neutral' }; +} + +const toneColor = (tone: TxMeta['tone']): string => + tone === 'in' ? '#3ba55d' : tone === 'out' ? '#f4212e' : '#e7e7e7'; + +// ─── Main ────────────────────────────────────────────────────────── + +export default function WalletScreen() { + const insets = useSafeAreaInsets(); + const keyFile = useStore(s => s.keyFile); + const balance = useStore(s => s.balance); + const setBalance = useStore(s => s.setBalance); + + useBalance(); + + const [txHistory, setTxHistory] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const [copied, setCopied] = useState(false); + const [showSend, setShowSend] = useState(false); + + const load = useCallback(async () => { + if (!keyFile) return; + setRefreshing(true); + try { + const [hist, bal] = await Promise.all([ + getTxHistory(keyFile.pub_key), + getBalance(keyFile.pub_key), + ]); + setTxHistory(hist); + setBalance(bal); + } catch { /* ignore — WS/HTTP retries sample */ } + setRefreshing(false); + }, [keyFile, setBalance]); + + useEffect(() => { load(); }, [load]); + + const copyAddress = async () => { + if (!keyFile) return; + await Clipboard.setStringAsync(keyFile.pub_key); + setCopied(true); + setTimeout(() => setCopied(false), 1800); + }; + + const mine = keyFile?.pub_key ?? ''; + + return ( + + } + /> + + } + contentContainerStyle={{ paddingBottom: 120 }} + showsVerticalScrollIndicator={false} + > + setShowSend(true)} + /> + + Recent transactions + + {txHistory.length === 0 ? ( + + ) : ( + + {txHistory.map((tx, i) => ( + + ))} + + )} + + + setShowSend(false)} + balance={balance} + keyFile={keyFile} + onSent={() => { + setShowSend(false); + setTimeout(load, 1200); + }} + /> + + ); +} + +// ─── Hero card ───────────────────────────────────────────────────── + +function BalanceHero({ + balance, address, copied, onCopy, onSend, +}: { + balance: number; + address: string; + copied: boolean; + onCopy: () => void; + onSend: () => void; +}) { + return ( + + + Balance + + + {formatAmount(balance)} + + + {/* Address chip */} + + + + + {copied ? 'Copied!' : shortAddr(address, 10)} + + + + + {/* Actions */} + + + + + + ); +} + +function HeroButton({ + icon, label, primary, onPress, +}: { + icon: IoniconName; + label: string; + primary?: boolean; + onPress: () => void; +}) { + const base = { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 11, + borderRadius: 999, + gap: 6, + } as const; + return ( + + {({ pressed }) => ( + + + + {label} + + + )} + + ); +} + +// ─── Section label ──────────────────────────────────────────────── + +function SectionLabel({ children }: { children: string }) { + return ( + + {children} + + ); +} + +// ─── Empty state ────────────────────────────────────────────────── + +function EmptyTx() { + return ( + + + + No transactions yet + + + Pull to refresh + + + ); +} + +// ─── TX tile ────────────────────────────────────────────────────── +// +// Pressable с ВНЕШНИМ плоским style (background через static object), +// внутренняя View handles row-layout. Избегаем web-баг со style-функциями +// Pressable'а. + +function TxTile({ + tx, first, mine, +}: { + tx: TxRecord; + first: boolean; + mine: string; +}) { + const m = txMeta(tx.type); + const isMineTx = tx.from === mine; + const amt = tx.amount ?? 0; + const sign = m.tone === 'in' ? '+' : m.tone === 'out' ? '−' : ''; + const color = toneColor(m.tone); + + return ( + + + + + + + + {m.label} + + + {tx.type === 'TRANSFER' + ? (isMineTx ? `→ ${shortAddr(tx.to ?? '', 5)}` : `← ${shortAddr(tx.from, 5)}`) + : shortAddr(tx.hash, 8)} + {' · '} + {relativeTime(tx.timestamp)} + + + {amt > 0 && ( + + {sign}{formatAmount(amt)} + + )} + + + ); +} + +// ─── Send modal ─────────────────────────────────────────────────── + +function SendModal({ + visible, onClose, balance, keyFile, onSent, +}: { + visible: boolean; + onClose: () => void; + balance: number; + keyFile: { pub_key: string; priv_key: string } | null; + onSent: () => void; +}) { + const insets = useSafeAreaInsets(); + const [to, setTo] = useState(''); + const [amount, setAmount] = useState(''); + const [fee, setFee] = useState('1000'); + const [sending, setSending] = useState(false); + + useEffect(() => { + if (!visible) { + // reset при закрытии + setTo(''); setAmount(''); setFee('1000'); setSending(false); + } + }, [visible]); + + const amt = parseInt(amount || '0', 10) || 0; + const f = parseInt(fee || '0', 10) || 0; + const total = amt + f; + const ok = !!to.trim() && amt > 0 && total <= balance; + + const send = async () => { + if (!keyFile) return; + if (!ok) { + Alert.alert('Check inputs', total > balance + ? `Need ${formatAmount(total)}, have ${formatAmount(balance)}.` + : 'Recipient and amount are required.'); + return; + } + setSending(true); + try { + const tx = buildTransferTx({ + from: keyFile.pub_key, + to: to.trim(), + amount: amt, + fee: f, + privKey: keyFile.priv_key, + }); + await submitTx(tx); + onSent(); + } catch (e: any) { + Alert.alert('Send failed', humanizeTxError(e)); + } finally { + setSending(false); + } + }; + + return ( + + + { /* block bubble-close */ }} + style={{ + backgroundColor: '#0a0a0a', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingTop: 10, + paddingBottom: Math.max(insets.bottom, 14) + 12, + paddingHorizontal: 14, + borderTopWidth: 1, + borderColor: '#1f1f1f', + }} + > + + + Send tokens + + + + + + + + + + + + + + + + + + + + {/* Summary */} + + + + + balance ? '#f4212e' : '#ffffff'} + /> + + + + + {({ pressed }) => ( + + + Cancel + + + )} + + + {({ pressed }) => ( + + {sending ? ( + + ) : ( + + Send + + )} + + )} + + + + + + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + {label} + + {children} + + + ); +} + +function SummaryRow({ + label, value, muted, accent, +}: { + label: string; + value: string; + muted?: boolean; + accent?: string; +}) { + return ( + + {label} + + {value} + + + ); +} diff --git a/client-app/app/(auth)/create.tsx b/client-app/app/(auth)/create.tsx new file mode 100644 index 0000000..21c9d5e --- /dev/null +++ b/client-app/app/(auth)/create.tsx @@ -0,0 +1,139 @@ +/** + * Create Account — dark minimalist. + * Генерирует Ed25519 + X25519 keypair локально, сохраняет в SecureStore. + */ +import React, { useState } from 'react'; +import { View, Text, ScrollView, Alert, Pressable, ActivityIndicator } from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { generateKeyFile } from '@/lib/crypto'; +import { saveKeyFile } from '@/lib/storage'; +import { useStore } from '@/lib/store'; + +import { Header } from '@/components/Header'; +import { IconButton } from '@/components/IconButton'; + +export default function CreateAccountScreen() { + const insets = useSafeAreaInsets(); + const setKeyFile = useStore(s => s.setKeyFile); + const [loading, setLoading] = useState(false); + + async function handleCreate() { + setLoading(true); + try { + const kf = generateKeyFile(); + await saveKeyFile(kf); + setKeyFile(kf); + router.replace('/(auth)/created' as never); + } catch (e: any) { + Alert.alert('Error', e?.message ?? 'Unknown error'); + } finally { + setLoading(false); + } + } + + return ( + +
router.back()} />} + /> + + + A new identity is created locally + + + Your private key never leaves this device. The app encrypts it in the + platform secure store. + + + + + + + + + + + + Important + + + Export and backup your key file right after creation. If you lose + it there is no recovery — blockchain has no password reset. + + + + ({ + alignItems: 'center', justifyContent: 'center', + paddingVertical: 13, borderRadius: 999, marginTop: 20, + backgroundColor: loading ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + {loading ? ( + + ) : ( + + Generate keys & continue + + )} + + + + ); +} + +function InfoRow({ + icon, label, desc, first, +}: { + icon: React.ComponentProps['name']; + label: string; + desc: string; + first?: boolean; +}) { + return ( + + + + + + {label} + {desc} + + + ); +} diff --git a/client-app/app/(auth)/created.tsx b/client-app/app/(auth)/created.tsx new file mode 100644 index 0000000..b01a6bf --- /dev/null +++ b/client-app/app/(auth)/created.tsx @@ -0,0 +1,196 @@ +/** + * Account Created confirmation screen — dark minimalist. + * Показывает адрес + x25519, кнопки copy и export (share) key.json. + */ +import React, { useState } from 'react'; +import { View, Text, ScrollView, Alert, Pressable, Share } from 'react-native'; +import { router } from 'expo-router'; +import * as Clipboard from 'expo-clipboard'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; + +import { Header } from '@/components/Header'; + +export default function AccountCreatedScreen() { + const insets = useSafeAreaInsets(); + const keyFile = useStore(s => s.keyFile); + const [copied, setCopied] = useState(null); + + if (!keyFile) { + router.replace('/'); + return null; + } + + async function copy(value: string, label: string) { + await Clipboard.setStringAsync(value); + setCopied(label); + setTimeout(() => setCopied(null), 1800); + } + + async function exportKey() { + try { + const json = JSON.stringify(keyFile, null, 2); + // Используем плоский Share API — без записи во временный файл. + // Получатель (mail, notes, etc.) получит текст целиком; юзер сам + // сохраняет как .json если нужно. + await Share.share({ + message: json, + title: 'DChain key file', + }); + } catch (e: any) { + Alert.alert('Export failed', e?.message ?? 'Unknown error'); + } + } + + return ( + +
+ + {/* Success badge */} + + + + + + Welcome aboard + + + Keys have been generated and stored securely. + + + + {/* Address */} + copy(keyFile.pub_key, 'address')} + /> + + {/* X25519 */} + + copy(keyFile.x25519_pub, 'x25519')} + /> + + {/* Backup */} + + + + + Backup your key file + + + + Export it now and store somewhere safe — password managers, cold + storage, printed paper. If you lose it, you lose the account. + + ({ + alignItems: 'center', justifyContent: 'center', + paddingVertical: 10, borderRadius: 999, + backgroundColor: pressed ? '#2a1f0f' : '#1a1409', + borderWidth: 1, borderColor: 'rgba(240,179,90,0.35)', + })} + > + + Export key.json + + + + + {/* Continue */} + router.replace('/(app)/chats' as never)} + style={({ pressed }) => ({ + alignItems: 'center', justifyContent: 'center', + paddingVertical: 14, borderRadius: 999, marginTop: 20, + backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + + Open messenger + + + + + ); +} + +function KeyCard({ + title, value, copied, onCopy, +}: { + title: string; + value: string; + copied: boolean; + onCopy: () => void; +}) { + return ( + + + {title} + + + {value} + + ({ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 9, borderRadius: 999, + marginTop: 10, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + })} + > + + + {copied ? 'Copied' : 'Copy'} + + + + ); +} diff --git a/client-app/app/(auth)/import.tsx b/client-app/app/(auth)/import.tsx new file mode 100644 index 0000000..b2208eb --- /dev/null +++ b/client-app/app/(auth)/import.tsx @@ -0,0 +1,230 @@ +/** + * Import existing key — dark minimalist. + * Два пути: + * 1. Paste JSON напрямую в textarea. + * 2. Pick файл .json через DocumentPicker. + */ +import React, { useState } from 'react'; +import { + View, Text, ScrollView, TextInput, Alert, Pressable, ActivityIndicator, +} from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import * as DocumentPicker from 'expo-document-picker'; +import * as Clipboard from 'expo-clipboard'; +import { saveKeyFile } from '@/lib/storage'; +import { useStore } from '@/lib/store'; +import type { KeyFile } from '@/lib/types'; + +import { Header } from '@/components/Header'; +import { IconButton } from '@/components/IconButton'; + +type Tab = 'paste' | 'file'; + +const REQUIRED_FIELDS: (keyof KeyFile)[] = ['pub_key', 'priv_key', 'x25519_pub', 'x25519_priv']; + +function validateKeyFile(raw: string): KeyFile { + let parsed: any; + try { parsed = JSON.parse(raw.trim()); } + catch { throw new Error('Invalid JSON — check that you copied the full key file.'); } + for (const field of REQUIRED_FIELDS) { + if (!parsed[field] || typeof parsed[field] !== 'string') { + throw new Error(`Missing or invalid field: "${field}"`); + } + if (!/^[0-9a-f]+$/i.test(parsed[field])) { + throw new Error(`Field "${field}" must be a hex string.`); + } + } + return parsed as KeyFile; +} + +export default function ImportKeyScreen() { + const insets = useSafeAreaInsets(); + const setKeyFile = useStore(s => s.setKeyFile); + + const [tab, setTab] = useState('paste'); + const [jsonText, setJsonText] = useState(''); + const [fileName, setFileName] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function applyKey(kf: KeyFile) { + setLoading(true); setError(null); + try { + await saveKeyFile(kf); + setKeyFile(kf); + router.replace('/(app)/chats' as never); + } catch (e: any) { + setError(e?.message ?? 'Import failed'); + } finally { + setLoading(false); + } + } + + async function handlePasteImport() { + setError(null); + const text = jsonText.trim(); + if (!text) { + const clip = await Clipboard.getStringAsync(); + if (clip) setJsonText(clip); + return; + } + try { await applyKey(validateKeyFile(text)); } + catch (e: any) { setError(e?.message ?? 'Import failed'); } + } + + async function pickFile() { + setError(null); + try { + const result = await DocumentPicker.getDocumentAsync({ + type: ['application/json', 'text/plain', '*/*'], + copyToCacheDirectory: true, + }); + if (result.canceled) return; + const asset = result.assets[0]; + setFileName(asset.name); + const response = await fetch(asset.uri); + const raw = await response.text(); + await applyKey(validateKeyFile(raw)); + } catch (e: any) { + setError(e?.message ?? 'Import failed'); + } + } + + return ( + +
router.back()} />} + /> + + + Restore your account from a previously exported{' '} + dchain_key.json. + + + {/* Tabs */} + + {(['paste', 'file'] as Tab[]).map(t => ( + setTab(t)} + style={{ + flex: 1, + alignItems: 'center', + paddingVertical: 8, + borderRadius: 999, + backgroundColor: tab === t ? '#1d9bf0' : 'transparent', + }} + > + + {t === 'paste' ? 'Paste JSON' : 'Pick file'} + + + ))} + + + {tab === 'paste' ? ( + <> + + ({ + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + paddingVertical: 12, borderRadius: 999, marginTop: 12, + backgroundColor: loading ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + {loading ? ( + + ) : ( + + {jsonText.trim() ? 'Import key' : 'Paste from clipboard'} + + )} + + + ) : ( + <> + ({ + alignItems: 'center', justifyContent: 'center', + paddingVertical: 40, borderRadius: 14, + backgroundColor: pressed ? '#111111' : '#0a0a0a', + borderWidth: 1, borderStyle: 'dashed', borderColor: '#1f1f1f', + })} + > + + + {fileName ?? 'Tap to pick key.json'} + + + Will auto-import on selection + + + {loading && ( + + + + )} + + )} + + {error && ( + + {error} + + )} + + + ); +} diff --git a/client-app/app/_layout.tsx b/client-app/app/_layout.tsx new file mode 100644 index 0000000..39a8728 --- /dev/null +++ b/client-app/app/_layout.tsx @@ -0,0 +1,59 @@ +import '../global.css'; + +import React, { useEffect } from 'react'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { View } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +// GestureHandlerRootView обязателен для работы gesture-handler'а +// на всех страницах: Pan/LongPress/Tap жестах внутри чатов. +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { loadKeyFile, loadSettings } from '@/lib/storage'; +import { setNodeUrl } from '@/lib/api'; +import { useStore } from '@/lib/store'; + +export default function RootLayout() { + const setKeyFile = useStore(s => s.setKeyFile); + const setSettings = useStore(s => s.setSettings); + const booted = useStore(s => s.booted); + const setBooted = useStore(s => s.setBooted); + + // Bootstrap: load key + settings from storage синхронно до первого + // render'а экранов. Пока `booted=false` мы рендерим чёрный экран — + // это убирает "мелькание" welcome'а при старте, когда ключи уже есть + // в AsyncStorage, но ещё не успели загрузиться в store. + useEffect(() => { + (async () => { + try { + const [kf, settings] = await Promise.all([loadKeyFile(), loadSettings()]); + if (kf) setKeyFile(kf); + setSettings(settings); + setNodeUrl(settings.nodeUrl); + } finally { + setBooted(true); + } + })(); + }, []); + + return ( + + + + + {booted ? ( + + ) : ( + // Пустой чёрный экран пока bootstrap идёт — без flicker'а. + + )} + + + + ); +} diff --git a/client-app/app/index.tsx b/client-app/app/index.tsx new file mode 100644 index 0000000..263e58a --- /dev/null +++ b/client-app/app/index.tsx @@ -0,0 +1,519 @@ +/** + * Onboarding — 3-слайдовый pager перед auth-экранами. + * + * Slide 1 — "Why DChain": value-proposition, 3 пункта с иконками. + * Slide 2 — "How it works": выбор релей-ноды (public paid vs свой node), + * ссылка на Gitea, + node URL input с live ping. + * Slide 3 — "Your keys": кнопки Create / Import. + * + * Если `keyFile` в store уже есть (bootstrap из RootLayout загрузил) — + * делаем в (app), чтобы пользователь не видел вообще никакого + * мелькания onboarding'а. До загрузки `booted === false` root показывает + * чёрный экран. + */ +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { + View, Text, TextInput, Pressable, ScrollView, + Alert, ActivityIndicator, Linking, Dimensions, + useWindowDimensions, +} from 'react-native'; +import { router, Redirect } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; +import { saveSettings } from '@/lib/storage'; +import { setNodeUrl, getNetStats } from '@/lib/api'; + +const { width: SCREEN_W } = Dimensions.get('window'); +const GITEA_URL = 'https://git.vsecoder.vodka/vsecoder/dchain'; + +export default function WelcomeScreen() { + const insets = useSafeAreaInsets(); + const { height: SCREEN_H } = useWindowDimensions(); + const keyFile = useStore(s => s.keyFile); + const booted = useStore(s => s.booted); + const settings = useStore(s => s.settings); + const setSettings = useStore(s => s.setSettings); + + const scrollRef = useRef(null); + const [page, setPage] = useState(0); + const [nodeInput, setNodeInput] = useState(''); + const [scanning, setScanning] = useState(false); + const [checking, setChecking] = useState(false); + const [nodeOk, setNodeOk] = useState(null); + + const [permission, requestPermission] = useCameraPermissions(); + + useEffect(() => { setNodeInput(settings.nodeUrl); }, [settings.nodeUrl]); + + // ВСЕ hooks должны быть объявлены ДО любого early-return, иначе + // React на следующем render'е посчитает разное число hooks и выкинет + // "Rendered fewer hooks than expected". useCallback ниже — тоже hook. + const applyNode = useCallback(async (url: string) => { + const clean = url.trim().replace(/\/$/, ''); + if (!clean) return; + setChecking(true); + setNodeOk(null); + setNodeUrl(clean); + try { + await getNetStats(); + setNodeOk(true); + const next = { ...settings, nodeUrl: clean }; + setSettings(next); + await saveSettings(next); + } catch { + setNodeOk(false); + } finally { + setChecking(false); + } + }, [settings, setSettings]); + + const onQrScanned = useCallback(({ data }: { data: string }) => { + setScanning(false); + let url = data.trim(); + try { const p = JSON.parse(url); if (p.nodeUrl) url = p.nodeUrl; } catch {} + setNodeInput(url); + applyNode(url); + }, [applyNode]); + + // Bootstrap ещё не закончился — ничего не рендерим, RootLayout покажет + // чёрный экран (single source of truth для splash-state'а). + if (!booted) return null; + + // Ключи уже загружены — сразу в main app, без мелькания onboarding'а. + if (keyFile) return ; + + const openScanner = async () => { + if (!permission?.granted) { + const { granted } = await requestPermission(); + if (!granted) { + Alert.alert('Camera permission required', 'Allow camera access to scan QR codes.'); + return; + } + } + setScanning(true); + }; + + const goToPage = (p: number) => { + scrollRef.current?.scrollTo({ x: p * SCREEN_W, animated: true }); + setPage(p); + }; + + if (scanning) { + return ( + + + + + + Point at a DChain node QR code + + + setScanning(false)} + style={{ + position: 'absolute', top: 56, left: 16, + backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, + paddingHorizontal: 16, paddingVertical: 8, + }} + > + ✕ Cancel + + + ); + } + + const statusColor = nodeOk === true ? '#3ba55d' : nodeOk === false ? '#f4212e' : '#8b8b8b'; + + // Высота footer'а (dots + inset) — резервируем под неё снизу каждого + // слайда, чтобы CTA-кнопки оказывались прямо над индикатором страниц, + // а не залезали под него. + const FOOTER_H = Math.max(insets.bottom, 20) + 8 + 12 + 7; // = padBottom + padTop + dot + const PAGE_H = SCREEN_H - FOOTER_H; + + return ( + + { + const p = Math.round(e.nativeEvent.contentOffset.x / SCREEN_W); + setPage(p); + }} + style={{ flex: 1 }} + keyboardShouldPersistTaps="handled" + > + {/* ───────── Slide 1: Why DChain ───────── */} + + + + + + + + DChain + + + A messenger that belongs to you. + + + + + + + + + {/* CTA — прижата к правому нижнему краю. */} + + goToPage(1)} /> + + + + {/* ───────── Slide 2: How it works ───────── */} + + + + Как это работает + + + Сообщения проходят через релей-ноду в зашифрованном виде. + Выбери публичную или подключи свою. + + + + + + + Node URL + + + + + { setNodeInput(t); setNodeOk(null); }} + onEndEditing={() => applyNode(nodeInput)} + onSubmitEditing={() => applyNode(nodeInput)} + placeholder="http://192.168.1.10:8080" + placeholderTextColor="#5a5a5a" + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + returnKeyType="done" + style={{ flex: 1, color: '#ffffff', fontSize: 14, paddingVertical: 12 }} + /> + {checking + ? + : nodeOk === true + ? + : nodeOk === false + ? + : null} + + ({ + width: 48, alignItems: 'center', justifyContent: 'center', + backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a', + borderWidth: 1, borderColor: '#1f1f1f', + borderRadius: 12, + })} + > + + + + {nodeOk === false && ( + + Cannot reach node — check URL and that the node is running + + )} + + + {/* CTA — прижата к правому нижнему краю. */} + + Linking.openURL(GITEA_URL).catch(() => {})} + /> + goToPage(2)} /> + + + + {/* ───────── Slide 3: Your keys ───────── */} + + + + + + + + Твой аккаунт + + + Создай новую пару ключей или импортируй существующую. + Ключи хранятся только на этом устройстве. + + + + + {/* CTA — прижата к правому нижнему краю. */} + + router.push('/(auth)/import' as never)} + /> + router.push('/(auth)/create' as never)} + /> + + + + + {/* Footer: dots-only pager indicator. CTA-кнопки теперь inline + на каждом слайде, чтобы выглядели как полноценные кнопки, а не + мелкий "Далее" в углу. */} + + {[0, 1, 2].map(i => ( + goToPage(i)} + hitSlop={8} + style={{ + width: page === i ? 22 : 7, + height: 7, + borderRadius: 3.5, + backgroundColor: page === i ? '#1d9bf0' : '#2a2a2a', + }} + /> + ))} + + + ); +} + +// ───────── helper components ───────── + +/** + * Primary CTA button — синий pill. Натуральная ширина (hugs content), + * `numberOfLines={1}` на лейбле чтобы текст не переносился. Фон + * применяется через inner View, а не напрямую на Pressable — это + * обходит редкие RN-баги, когда backgroundColor на Pressable не + * рендерится пока кнопка не нажата. + */ +function CTAPrimary({ label, onPress }: { label: string; onPress: () => void }) { + return ( + ({ opacity: pressed ? 0.85 : 1 })}> + + + {label} + + + + ); +} + +/** Secondary CTA — тёмный pill с border'ом, optional icon слева. */ +function CTASecondary({ + label, icon, onPress, +}: { + label: string; + icon?: React.ComponentProps['name']; + onPress: () => void; +}) { + return ( + ({ opacity: pressed ? 0.6 : 1 })}> + + {icon && } + + {label} + + + + ); +} + +function FeatureRow({ + icon, title, text, +}: { icon: React.ComponentProps['name']; title: string; text: string }) { + return ( + + + + + + + {title} + + + {text} + + + + ); +} + +function OptionCard({ + icon, title, text, actionLabel, onAction, +}: { + icon: React.ComponentProps['name']; + title: string; + text: string; + actionLabel?: string; + onAction?: () => void; +}) { + return ( + + + + + {title} + + + + {text} + + {actionLabel && onAction && ( + ({ opacity: pressed ? 0.6 : 1, marginTop: 8 })}> + + {actionLabel} + + + )} + + ); +} diff --git a/client-app/babel.config.js b/client-app/babel.config.js new file mode 100644 index 0000000..d08d04a --- /dev/null +++ b/client-app/babel.config.js @@ -0,0 +1,12 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: [ + ['babel-preset-expo', { jsxImportSource: 'nativewind' }], + 'nativewind/babel', + ], + plugins: [ + 'react-native-reanimated/plugin', // must be last + ], + }; +}; diff --git a/client-app/components/AnimatedSlot.tsx b/client-app/components/AnimatedSlot.tsx new file mode 100644 index 0000000..6673fd8 --- /dev/null +++ b/client-app/components/AnimatedSlot.tsx @@ -0,0 +1,67 @@ +/** + * AnimatedSlot — обёртка над ``. Исторически тут была 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 ( + + + + ); +} diff --git a/client-app/components/Avatar.tsx b/client-app/components/Avatar.tsx new file mode 100644 index 0000000..9186203 --- /dev/null +++ b/client-app/components/Avatar.tsx @@ -0,0 +1,76 @@ +/** + * Avatar — круглая заглушка с инициалом, опционально online-пип. + * Нет зависимостей от асинхронных источников (картинок) — для messenger-тайла + * важнее мгновенный рендер, чем фотография. Если в будущем будут фото, + * расширяем здесь. + */ +import React from 'react'; +import { View, Text } from 'react-native'; + +export interface AvatarProps { + /** Имя / @username — берём первый символ для placeholder. */ + name?: string; + /** Адрес (hex pubkey) — fallback для тех у кого нет имени. */ + address?: string; + /** Общий размер в px. По умолчанию 48 (tile size). */ + size?: number; + /** Цвет пипа справа-снизу. undefined = без пипа. */ + dotColor?: string; + /** Класс для обёртки (position: relative кадр). */ + className?: string; +} + +/** Простое хэширование имени → один из 6 оттенков серого для разнообразия. */ +function pickBg(seed: string): string { + const shades = ['#1a1a1a', '#222222', '#2a2a2a', '#151515', '#1c1c1c', '#1f1f1f']; + let h = 0; + for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) & 0xffff; + return shades[h % shades.length]; +} + +export function Avatar({ name, address, size = 48, dotColor, className }: AvatarProps) { + const seed = (name ?? address ?? '?').replace(/^@/, ''); + const initial = seed.charAt(0).toUpperCase() || '?'; + const bg = pickBg(seed); + + return ( + + + + {initial} + + + {dotColor && ( + + )} + + ); +} diff --git a/client-app/components/ChatTile.tsx b/client-app/components/ChatTile.tsx new file mode 100644 index 0000000..eb74137 --- /dev/null +++ b/client-app/components/ChatTile.tsx @@ -0,0 +1,174 @@ +/** + * ChatTile — одна строка в списке чатов на главной (Messages screen). + * + * Layout: + * [avatar 44] [name (+verified) (+kind-icon)] [time] + * [last-msg preview] [unread pill] + * + * Kind-icon — мегафон для channel, 👥 для group, ничего для direct. + * Verified checkmark — если у контакта есть @username. + * Online-dot на аватарке — только для direct-чатов с x25519 ключом. + */ +import React from 'react'; +import { View, Text, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +import type { Contact, Message } from '@/lib/types'; +import { Avatar } from '@/components/Avatar'; +import { formatWhen } from '@/lib/dates'; +import { useStore } from '@/lib/store'; + +function previewText(s: string, max = 50): string { + return s.length <= max ? s : s.slice(0, max).trimEnd() + '…'; +} + +/** + * Текстовое превью последнего сообщения. Если у сообщения нет текста + * (только вложение) — возвращаем маркер с иконкой названием типа: + * "🖼 Photo" / "🎬 Video" / "🎙 Voice" / "📎 File" + * Если текст есть — он используется; если есть и то и другое, префикс + * добавляется перед текстом. + */ +function lastPreview(m: Message): string { + const emojiByKind = { + image: '🖼', video: '🎬', voice: '🎙', file: '📎', + } as const; + const labelByKind = { + image: 'Photo', video: 'Video', voice: 'Voice message', file: 'File', + } as const; + const text = m.text.trim(); + if (m.attachment) { + const prefix = `${emojiByKind[m.attachment.kind]} ${labelByKind[m.attachment.kind]}`; + return text ? `${prefix} ${previewText(text, 40)}` : prefix; + } + return previewText(text); +} + +function shortAddr(a: string, n = 5): string { + if (!a) return '—'; + return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; +} + +function displayName(c: Contact): string { + return c.username ? `@${c.username}` : c.alias ?? shortAddr(c.address); +} + +export interface ChatTileProps { + contact: Contact; + lastMessage: Message | null; + onPress: () => void; +} + +export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) { + const name = displayName(c); + const last = lastMessage; + + // Визуальный маркер типа чата. + const kindIcon: React.ComponentProps['name'] | null = + c.kind === 'group' ? 'people' : null; + + // Unread берётся из runtime-store'а (инкрементится в useGlobalInbox, + // обнуляется при открытии чата). Fallback на c.unread для legacy seed. + const storeUnread = useStore(s => s.unreadByContact[c.address] ?? 0); + const unreadCount = storeUnread || (c.unread ?? 0); + const unread = unreadCount > 0 ? unreadCount : null; + + return ( + ({ + backgroundColor: pressed ? '#0a0a0a' : 'transparent', + })} + > + + + + + {/* Первая строка: [kind-icon] name [verified] ··· time */} + + {kindIcon && ( + + )} + + {name} + + {c.username && ( + + )} + {last && ( + + {formatWhen(last.timestamp)} + + )} + + + {/* Вторая строка: [✓✓ mine-seen] preview ··· [unread] */} + + {last?.mine && ( + + )} + + {last + ? lastPreview(last) + : c.x25519Pub + ? 'Tap to start encrypted chat' + : 'Waiting for identity…'} + + + {unread !== null && ( + + + {unread > 99 ? '99+' : unread} + + + )} + + + + + ); +} diff --git a/client-app/components/Composer.tsx b/client-app/components/Composer.tsx new file mode 100644 index 0000000..ebc2e3d --- /dev/null +++ b/client-app/components/Composer.tsx @@ -0,0 +1,329 @@ +/** + * Composer — плавающий блок ввода сообщения, прибит к низу. + * + * Композиция: + * 1. Опциональный баннер (edit / reply) сверху. + * 2. Опциональная pending-attachment preview. + * 3. Либо: + * - обычный input-bubble с `[+] [textarea] [↑/🎤/⭕]` + * - inline VoiceRecorder когда идёт запись голосового + * + * Send-action зависит от состояния: + * - есть текст/attachment → ↑ (send) + * - пусто → показываем две иконки: 🎤 (start voice) + ⭕ (open video circle) + * + * API: + * mode, onCancelMode + * text, onChangeText + * onSend, sending + * onAttach — tap на + (AttachmentMenu) + * attachment, onClearAttach + * onFinishVoice — готовая voice-attachment (из VoiceRecorder) + * onStartVideoCircle — tap на ⭕, родитель открывает VideoCircleRecorder + * placeholder + */ +import React, { useRef, useState } from 'react'; +import { View, Text, TextInput, Pressable, ActivityIndicator, Image } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +import type { Attachment } from '@/lib/types'; +import { VoiceRecorder } from '@/components/chat/VoiceRecorder'; + +export type ComposerMode = + | { kind: 'new' } + | { kind: 'edit'; text: string } + | { kind: 'reply'; msgId: string; author: string; preview: string }; + +export interface ComposerProps { + mode: ComposerMode; + onCancelMode?: () => void; + + text: string; + onChangeText: (t: string) => void; + + onSend: () => void; + sending?: boolean; + + onAttach?: () => void; + + attachment?: Attachment | null; + onClearAttach?: () => void; + + /** Voice recording завершена и отправляем сразу (мгновенный flow). */ + onFinishVoice?: (att: Attachment) => void; + /** Tap на "⭕" — родитель открывает VideoCircleRecorder. */ + onStartVideoCircle?: () => void; + + placeholder?: string; +} + +const INPUT_MIN_HEIGHT = 24; +const INPUT_MAX_HEIGHT = 72; + +export function Composer(props: ComposerProps) { + const { + mode, onCancelMode, text, onChangeText, onSend, sending, onAttach, + attachment, onClearAttach, + onFinishVoice, onStartVideoCircle, + placeholder, + } = props; + + const inputRef = useRef(null); + const [recordingVoice, setRecordingVoice] = useState(false); + + const hasContent = !!text.trim() || !!attachment; + const canSend = hasContent && !sending; + const inEdit = mode.kind === 'edit'; + const inReply = mode.kind === 'reply'; + + const focusInput = () => inputRef.current?.focus(); + + return ( + + {/* ── Banner: edit / reply ── */} + {(inEdit || inReply) && !recordingVoice && ( + + + + {inEdit && ( + + Edit message + + )} + {inReply && ( + <> + + Reply to {(mode as { author: string }).author} + + + {(mode as { preview: string }).preview} + + + )} + + ({ opacity: pressed ? 0.5 : 1 })} + > + + + + )} + + {/* ── Pending attachment preview ── */} + {attachment && !recordingVoice && ( + + )} + + {/* ── Voice recording (inline) ИЛИ обычный input ── */} + {recordingVoice ? ( + { + setRecordingVoice(false); + onFinishVoice?.(att); + }} + onCancel={() => setRecordingVoice(false)} + /> + ) : ( + + + {/* + attach — всегда, кроме edit */} + {onAttach && !inEdit && ( + { e.stopPropagation?.(); onAttach(); }} + hitSlop={6} + style={({ pressed }) => ({ + width: 32, height: 32, borderRadius: 16, + alignItems: 'center', justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + })} + > + + + )} + + + + {/* Правая часть: send ИЛИ [mic + video-circle] */} + {canSend ? ( + { e.stopPropagation?.(); onSend(); }} + style={({ pressed }) => ({ + width: 32, height: 32, borderRadius: 16, + backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0', + alignItems: 'center', justifyContent: 'center', + })} + > + {sending ? ( + + ) : ( + + )} + + ) : !inEdit && (onFinishVoice || onStartVideoCircle) ? ( + + {onStartVideoCircle && ( + { e.stopPropagation?.(); onStartVideoCircle(); }} + hitSlop={6} + style={({ pressed }) => ({ + width: 32, height: 32, borderRadius: 16, + alignItems: 'center', justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + })} + > + + + )} + {onFinishVoice && ( + { e.stopPropagation?.(); setRecordingVoice(true); }} + hitSlop={6} + style={({ pressed }) => ({ + width: 32, height: 32, borderRadius: 16, + alignItems: 'center', justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + })} + > + + + )} + + ) : null} + + + )} + + ); +} + +// ─── Attachment chip — preview текущего pending attachment'а ──────── + +function AttachmentChip({ + attachment, onClear, +}: { + attachment: Attachment; + onClear?: () => void; +}) { + const icon: React.ComponentProps['name'] = + attachment.kind === 'image' ? 'image-outline' : + attachment.kind === 'video' ? 'videocam-outline' : + attachment.kind === 'voice' ? 'mic-outline' : + 'document-outline'; + + return ( + + {attachment.kind === 'image' || attachment.kind === 'video' ? ( + + ) : ( + + + + )} + + + + {attachment.name ?? attachmentLabel(attachment)} + + + {attachment.kind.toUpperCase()} + {attachment.circle ? ' · circle' : ''} + {attachment.size ? ` · ${(attachment.size / 1024).toFixed(0)} KB` : ''} + {attachment.duration ? ` · ${attachment.duration}s` : ''} + + + + ({ opacity: pressed ? 0.5 : 1, padding: 4 })} + > + + + + ); +} + +function attachmentLabel(a: Attachment): string { + switch (a.kind) { + case 'image': return 'Photo'; + case 'video': return a.circle ? 'Video message' : 'Video'; + case 'voice': return 'Voice message'; + case 'file': return 'File'; + } +} diff --git a/client-app/components/Header.tsx b/client-app/components/Header.tsx new file mode 100644 index 0000000..10f8d96 --- /dev/null +++ b/client-app/components/Header.tsx @@ -0,0 +1,76 @@ +/** + * Header — единая шапка экрана: [left slot] [title centered] [right slot]. + * + * Правила выравнивания: + * - left/right принимают натуральную ширину контента (обычно 1-2 + * IconButton'а 36px, или pressable-avatar 32px). + * - title (ReactNode, принимает как string, так и compound — аватар + + * имя вместе) всегда центрирован через flex:1 + alignItems:center. + * Абсолютно не позиционируется, т.к. при слишком широком title'е + * лучше ужать его, чем наложить на кнопки. + * + * `title` может быть строкой (тогда рендерится как Text 17px semibold) + * либо произвольным node'ом — используется в chat detail для + * [avatar][name + typing-subtitle] compound-блока. + * + * `divider` (default true) — тонкая 1px линия снизу; в tab-страницах + * обычно выключена (TabHeader всегда ставит divider=false). + */ +import React, { ReactNode } from 'react'; +import { View, Text } from 'react-native'; + +export interface HeaderProps { + title?: ReactNode; + left?: ReactNode; + right?: ReactNode; + /** Показывать нижнюю тонкую линию-разделитель. По умолчанию true. */ + divider?: boolean; +} + +export function Header({ title, left, right, divider = true }: HeaderProps) { + return ( + + + {/* Left slot — натуральная ширина, минимум 44 чтобы title + визуально центрировался для одно-icon-left + одно-icon-right. */} + {left} + + {/* Title centered */} + + {typeof title === 'string' ? ( + + {title} + + ) : title ?? null} + + + {/* Right slot — row, натуральная ширина, минимум 44. gap=4 + чтобы несколько IconButton'ов не слипались в selection-mode. */} + + {right} + + + + ); +} diff --git a/client-app/components/IconButton.tsx b/client-app/components/IconButton.tsx new file mode 100644 index 0000000..df62c53 --- /dev/null +++ b/client-app/components/IconButton.tsx @@ -0,0 +1,61 @@ +/** + * IconButton — круглая touch-target кнопка под Ionicon. + * + * Три варианта: + * - 'ghost' — прозрачная, используется в хедере (шестерёнка, back). + * - 'solid' — акцентный заливной круг, например composer FAB. + * - 'tile' — квадратная заливка 36×36 для небольших action-chip'ов. + * + * Размер управляется props.size (диаметр). Touch-target никогда меньше 40px + * (accessibility), поэтому для size<40 внутренний иконопад растёт. + */ +import React from 'react'; +import { Pressable, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +type IoniconName = React.ComponentProps['name']; + +export interface IconButtonProps { + icon: IoniconName; + onPress?: () => void; + variant?: 'ghost' | 'solid' | 'tile'; + size?: number; // visual diameter; hit slop ensures accessibility + color?: string; // override icon color + disabled?: boolean; + className?: string; +} + +export function IconButton({ + icon, onPress, variant = 'ghost', size = 40, color, disabled, className, +}: IconButtonProps) { + const iconSize = Math.round(size * 0.5); + const bg = + variant === 'solid' ? '#1d9bf0' : + variant === 'tile' ? '#1a1a1a' : + 'transparent'; + const tint = + color ?? + (variant === 'solid' ? '#ffffff' : + disabled ? '#3a3a3a' : + '#e7e7e7'); + + const radius = variant === 'tile' ? 10 : size / 2; + + return ( + ({ + width: size, + height: size, + borderRadius: radius, + backgroundColor: pressed && !disabled ? (variant === 'solid' ? '#1a8cd8' : '#1a1a1a') : bg, + alignItems: 'center', + justifyContent: 'center', + })} + > + + + ); +} diff --git a/client-app/components/NavBar.tsx b/client-app/components/NavBar.tsx new file mode 100644 index 0000000..0e54566 --- /dev/null +++ b/client-app/components/NavBar.tsx @@ -0,0 +1,150 @@ +/** + * NavBar — нижний бар на 5 иконок без подписей. + * + * Активный таб: + * - иконка заполненная (Ionicons variant без `-outline`) + * - вокруг иконки subtle highlight-блок (чуть светлее bg), радиус 14 + * - текст/бейдж остаются как у inactive + * + * Inactive: + * - outline-иконка, цвет #6b6b6b + * - soon-таб дополнительно dimmed и показывает чип SOON + * + * Роутинг через expo-router `router.replace` — без стекa, каждый tab это + * полная страница без "back" концепции. + */ +import React from 'react'; +import { View, Pressable, Text } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter, usePathname } from 'expo-router'; + +type IoniconName = React.ComponentProps['name']; + +interface Item { + key: string; + href: string; + icon: IoniconName; + iconActive: IoniconName; + badge?: number; + soon?: boolean; +} + +export interface NavBarProps { + bottomInset?: number; + requestCount?: number; + notifCount?: number; +} + +export function NavBar({ bottomInset = 0, requestCount = 0, notifCount = 0 }: NavBarProps) { + const router = useRouter(); + const pathname = usePathname(); + + const items: Item[] = [ + { key: 'home', href: '/(app)/chats', icon: 'home-outline', iconActive: 'home', badge: requestCount }, + { key: 'add', href: '/(app)/new-contact', icon: 'search-outline', iconActive: 'search' }, + { key: 'feed', href: '/(app)/feed', icon: 'newspaper-outline', iconActive: 'newspaper' }, + { key: 'notif', href: '/(app)/requests', icon: 'notifications-outline', iconActive: 'notifications', badge: notifCount }, + { key: 'wallet', href: '/(app)/wallet', icon: 'wallet-outline', iconActive: 'wallet' }, + ]; + + // NavBar active-matching: путь может начинаться с "/chats" ИЛИ с href + // напрямую. Вариант `/chats/xyz` тоже считается active для home. + const isActive = (href: string) => { + // Нормализуем /(app)/chats → /chats + const norm = href.replace(/^\/\(app\)/, ''); + return pathname === norm || pathname.startsWith(norm + '/'); + }; + + return ( + + {items.map((it) => { + const active = isActive(it.href); + return ( + { + if (it.soon) return; + router.replace(it.href as never); + }} + hitSlop={6} + style={({ pressed }) => ({ + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 4, + opacity: pressed ? 0.65 : 1, + })} + > + + + {it.badge && it.badge > 0 ? ( + + + {it.badge > 99 ? '99+' : it.badge} + + + ) : null} + {it.soon && ( + + + SOON + + + )} + + + ); + })} + + ); +} diff --git a/client-app/components/SearchBar.tsx b/client-app/components/SearchBar.tsx new file mode 100644 index 0000000..0c63e53 --- /dev/null +++ b/client-app/components/SearchBar.tsx @@ -0,0 +1,88 @@ +/** + * SearchBar — серый блок, в состоянии idle текст с иконкой 🔍 отцентрированы. + * + * Когда пользователь тапает/фокусирует — поле становится input-friendly, но + * визуально рестайл не нужен: при наличии текста placeholder скрыт и + * пользовательский ввод выравнивается влево автоматически (multiline off). + */ +import React, { useState } from 'react'; +import { View, TextInput, Text } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +export interface SearchBarProps { + value: string; + onChangeText: (v: string) => void; + placeholder?: string; + autoFocus?: boolean; + onSubmitEditing?: () => void; +} + +export function SearchBar({ + value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing, +}: SearchBarProps) { + const [focused, setFocused] = useState(false); + + // Placeholder центрируется пока нет фокуса И нет значения. + // Как только юзер фокусируется или начинает печатать — иконка+текст + // прыгают к левому краю, чтобы не мешать вводу. + const centered = !focused && !value; + + return ( + + {centered ? ( + // ── Idle state — только текст+icon, отцентрированы. + // Невидимый TextInput поверх ловит tap, чтобы не дергать focus вручную. + + + {placeholder} + setFocused(true)} + onSubmitEditing={onSubmitEditing} + returnKeyType="search" + style={{ + position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, + color: 'transparent', + // Скрываем cursor в idle-режиме; при focus компонент перерисуется. + }} + /> + + ) : ( + + + setFocused(true)} + onBlur={() => setFocused(false)} + onSubmitEditing={onSubmitEditing} + returnKeyType="search" + style={{ + flex: 1, + color: '#ffffff', + fontSize: 14, + padding: 0, + includeFontPadding: false, + }} + /> + + )} + + ); +} diff --git a/client-app/components/TabHeader.tsx b/client-app/components/TabHeader.tsx new file mode 100644 index 0000000..2022535 --- /dev/null +++ b/client-app/components/TabHeader.tsx @@ -0,0 +1,59 @@ +/** + * TabHeader — общая шапка для всех tab-страниц (home/feed/notifications/wallet). + * + * Структура строго как в референсе Messages-экрана: + * [avatar 32 → /settings] [title] [right slot] + * + * Без нижнего разделителя (divider=false) — тот же уровень, что и фон экрана. + * + * Right-slot по умолчанию — шестерёнка → /settings. Но экраны могут передать + * свой (например, refresh в wallet). Левый avatar — всегда клик-навигация в + * settings, как в референсе. + */ +import React from 'react'; +import { Pressable } from 'react-native'; +import { useRouter } from 'expo-router'; +import { useStore } from '@/lib/store'; +import { Avatar } from '@/components/Avatar'; +import { Header } from '@/components/Header'; +import { IconButton } from '@/components/IconButton'; + +export interface TabHeaderProps { + title: string; + /** Right-slot. Если не передан — выставляется IconButton с settings-outline. */ + right?: React.ReactNode; + /** Dot-color на profile-avatar'е (например, WS live/polling indicator). */ + profileDotColor?: string; +} + +export function TabHeader({ title, right, profileDotColor }: TabHeaderProps) { + const router = useRouter(); + const username = useStore(s => s.username); + const keyFile = useStore(s => s.keyFile); + + return ( +
router.push('/(app)/settings' as never)} hitSlop={8}> + + + } + right={ + right ?? ( + router.push('/(app)/settings' as never)} + /> + ) + } + /> + ); +} diff --git a/client-app/components/chat/AttachmentMenu.tsx b/client-app/components/chat/AttachmentMenu.tsx new file mode 100644 index 0000000..234ebd2 --- /dev/null +++ b/client-app/components/chat/AttachmentMenu.tsx @@ -0,0 +1,188 @@ +/** + * AttachmentMenu — bottom-sheet с вариантами прикрепления. + * + * Выводится при нажатии на `+` в composer'е. Опции: + * - 📷 Photo / video из галереи (expo-image-picker) + * - 📸 Take photo (камера) + * - 📎 File (expo-document-picker) + * - 🎙️ Voice message — stub (запись через expo-av потребует + * permissions runtime + recording UI; сейчас добавляет мок- + * голосовое с duration 4s) + * + * Всё визуально — тёмный overlay + sheet снизу. Закрытие по tap'у на + * overlay или на Cancel. + */ +import React from 'react'; +import { View, Text, Pressable, Alert, Modal } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import * as ImagePicker from 'expo-image-picker'; +import * as DocumentPicker from 'expo-document-picker'; + +import type { Attachment } from '@/lib/types'; + +export interface AttachmentMenuProps { + visible: boolean; + onClose: () => void; + /** Вызывается когда attachment готов для отправки. */ + onPick: (att: Attachment) => void; +} + +export function AttachmentMenu({ visible, onClose, onPick }: AttachmentMenuProps) { + const insets = useSafeAreaInsets(); + + const pickImageOrVideo = async () => { + try { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) { + Alert.alert('Permission needed', 'Grant photos access to attach media.'); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.All, + quality: 0.85, + allowsEditing: false, + }); + if (result.canceled) return; + const asset = result.assets[0]; + onPick({ + kind: asset.type === 'video' ? 'video' : 'image', + uri: asset.uri, + mime: asset.mimeType, + width: asset.width, + height: asset.height, + duration: asset.duration ? Math.round(asset.duration / 1000) : undefined, + }); + onClose(); + } catch (e: any) { + Alert.alert('Pick failed', e?.message ?? 'Unknown error'); + } + }; + + const takePhoto = async () => { + try { + const perm = await ImagePicker.requestCameraPermissionsAsync(); + if (!perm.granted) { + Alert.alert('Permission needed', 'Grant camera access to take a photo.'); + return; + } + const result = await ImagePicker.launchCameraAsync({ quality: 0.85 }); + if (result.canceled) return; + const asset = result.assets[0]; + onPick({ + kind: asset.type === 'video' ? 'video' : 'image', + uri: asset.uri, + mime: asset.mimeType, + width: asset.width, + height: asset.height, + }); + onClose(); + } catch (e: any) { + Alert.alert('Camera failed', e?.message ?? 'Unknown error'); + } + }; + + const pickFile = async () => { + try { + const res = await DocumentPicker.getDocumentAsync({ + type: '*/*', + copyToCacheDirectory: true, + }); + if (res.canceled) return; + const asset = res.assets[0]; + onPick({ + kind: 'file', + uri: asset.uri, + name: asset.name, + mime: asset.mimeType ?? undefined, + size: asset.size, + }); + onClose(); + } catch (e: any) { + Alert.alert('File pick failed', e?.message ?? 'Unknown error'); + } + }; + + // Voice recorder больше не stub — см. inline-кнопку 🎤 в composer'е, + // которая разворачивает VoiceRecorder (expo-av Audio.Recording). Опция + // Voice в этом меню убрана, т.к. дублировала бы UX. + + return ( + + + + {}} + style={{ + backgroundColor: '#111111', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingTop: 8, + paddingBottom: Math.max(insets.bottom, 12) + 10, + paddingHorizontal: 10, + borderTopWidth: 1, borderColor: '#1f1f1f', + }} + > + {/* Drag handle */} + + + Attach + + + + + + + + + ); +} + +function Row({ + icon, label, onPress, +}: { + icon: React.ComponentProps['name']; + label: string; + onPress: () => void; +}) { + return ( + ({ + flexDirection: 'row', + alignItems: 'center', + gap: 14, + paddingHorizontal: 14, + paddingVertical: 14, + borderRadius: 14, + backgroundColor: pressed ? '#1a1a1a' : 'transparent', + })} + > + + + + {label} + + ); +} diff --git a/client-app/components/chat/AttachmentPreview.tsx b/client-app/components/chat/AttachmentPreview.tsx new file mode 100644 index 0000000..468012a --- /dev/null +++ b/client-app/components/chat/AttachmentPreview.tsx @@ -0,0 +1,178 @@ +/** + * AttachmentPreview — рендер `Message.attachment` внутри bubble'а. + * + * Четыре формы: + * - image → Image с object-fit cover, aspect-ratio из width/height + * - video → то же + play-overlay в центре, duration внизу-справа + * - voice → row [play-icon] [waveform stub] [duration] + * - file → row [file-icon] [name + size] + * + * Вложения размещаются ВНУТРИ того же bubble'а что и текст, чуть ниже + * footer'а нет и ширина bubble'а снимает maxWidth-ограничение ради + * изображений (отдельный media-first-bubble case). + */ +import React from 'react'; +import { View, Text, Image } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +import type { Attachment } from '@/lib/types'; +import { VoicePlayer } from '@/components/chat/VoicePlayer'; +import { VideoCirclePlayer } from '@/components/chat/VideoCirclePlayer'; + +export interface AttachmentPreviewProps { + attachment: Attachment; + /** Используется для тонирования footer-элементов. */ + own?: boolean; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +function formatDuration(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${String(s).padStart(2, '0')}`; +} + +export function AttachmentPreview({ attachment, own }: AttachmentPreviewProps) { + switch (attachment.kind) { + case 'image': + return ; + case 'video': + // circle=true — круглое видео-сообщение (Telegram-стиль). + return attachment.circle + ? + : ; + case 'voice': + return ; + case 'file': + return ; + } +} + +// ─── Image ────────────────────────────────────────────────────────── + +function ImageAttachment({ att }: { att: Attachment }) { + // Aspect-ratio из реальных width/height; fallback 4:3. + const aspect = att.width && att.height ? att.width / att.height : 4 / 3; + return ( + + ); +} + +// ─── Video ────────────────────────────────────────────────────────── + +function VideoAttachment({ att }: { att: Attachment }) { + const aspect = att.width && att.height ? att.width / att.height : 16 / 9; + return ( + + + {/* Play overlay по центру */} + + + + {att.duration !== undefined && ( + + + {formatDuration(att.duration)} + + + )} + + ); +} + +// ─── Voice ────────────────────────────────────────────────────────── +// Реальный плеер — см. components/chat/VoicePlayer.tsx (expo-av Sound). + +// ─── File ─────────────────────────────────────────────────────────── + +function FileAttachment({ att, own }: { att: Attachment; own?: boolean }) { + return ( + + + + + + + {att.name ?? 'file'} + + + {att.size !== undefined ? formatSize(att.size) : ''} + {att.size !== undefined && att.mime ? ' · ' : ''} + {att.mime ?? ''} + + + + ); +} diff --git a/client-app/components/chat/DaySeparator.tsx b/client-app/components/chat/DaySeparator.tsx new file mode 100644 index 0000000..f5b18cb --- /dev/null +++ b/client-app/components/chat/DaySeparator.tsx @@ -0,0 +1,36 @@ +/** + * DaySeparator — центральный лейбл "Сегодня" / "Вчера" / "17 июня 2025" + * между группами сообщений. + * + * Стиль: тонкий шрифт серого цвета, маленький размер. В референсе этот + * лейбл не должен перетягивать на себя внимание — он визуальный якорь, + * не заголовок. + */ +import React from 'react'; +import { View, Text, Platform } from 'react-native'; + +export interface DaySeparatorProps { + label: string; +} + +export function DaySeparator({ label }: DaySeparatorProps) { + return ( + + + {label} + + + ); +} diff --git a/client-app/components/chat/MessageBubble.tsx b/client-app/components/chat/MessageBubble.tsx new file mode 100644 index 0000000..d8985ca --- /dev/null +++ b/client-app/components/chat/MessageBubble.tsx @@ -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 ; + if (!props.withSenderMeta) return ; + return ; +} + +type Variant = 'own' | 'peer-compact' | 'group-peer'; + +function RowShell({ + msg, peerName, peerAddress, showName, showAvatar, + onReply, onLongPress, onTap, onOpenProfile, onJumpToReply, + selectionMode, selected, highlighted, variant, +}: MessageBubbleProps & { variant: Variant }) { + const translateX = useRef(new Animated.Value(0)).current; + const startTs = useRef(0); + const moved = useRef(false); + const lpTimer = useRef | null>(null); + + const clearLp = () => { + if (lpTimer.current) { clearTimeout(lpTimer.current); lpTimer.current = null; } + }; + + // Touch start — запускаем long-press timer (НЕ клеймим responder). + const onTouchStart = () => { + startTs.current = Date.now(); + moved.current = false; + clearLp(); + if (onLongPress) { + lpTimer.current = setTimeout(() => { + if (!moved.current) onLongPress(msg); + lpTimer.current = null; + }, LONG_PRESS_MS); + } + }; + + const onTouchMove = (e: { nativeEvent: { pageX: number; pageY: number } }) => { + // Если пользователь двигает палец — отменяем long-press timer. + // Малые движения (< TAP_MAX_MOVEMENT) игнорируем — устраняют + // fale-cancel от дрожания пальца. + // Здесь нет точного dx/dy от gesture-системы, используем primitive + // touch coords отсчитываемые по абсолютным координатам. Проще — + // всегда отменяем на first move (PanResponder ниже отнимет + // responder если leftward). + moved.current = true; + clearLp(); + }; + + const onTouchEnd = () => { + const elapsed = Date.now() - startTs.current; + clearLp(); + // Короткий tap без движения → в selection mode toggle. + if (!moved.current && elapsed < TAP_MAX_ELAPSED && selectionMode) { + onTap?.(msg); + } + }; + + // Swipe-to-reply: PanResponder клеймит ТОЛЬКО leftward-dominant move. + // Для vertical scroll / rightward swipe / start-touch возвращает false, + // FlatList / AnimatedSlot получают gesture. + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_e, g) => { + if (selectionMode) return false; + // Leftward > 6px и горизонталь преобладает. + return g.dx < -6 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5; + }, + onPanResponderGrant: () => { + // Как только мы заклеймили gesture, отменяем long-press + // (пользователь явно свайпает, не удерживает). + clearLp(); + moved.current = true; + }, + onPanResponderMove: (_e, g) => { + translateX.setValue(Math.min(0, g.dx)); + }, + onPanResponderRelease: (_e, g) => { + if (g.dx <= -SWIPE_THRESHOLD) onReply?.(msg); + Animated.spring(translateX, { + toValue: 0, friction: 6, tension: 80, useNativeDriver: true, + }).start(); + }, + onPanResponderTerminate: () => { + Animated.spring(translateX, { + toValue: 0, friction: 6, tension: 80, useNativeDriver: true, + }).start(); + }, + }), + ).current; + + // Highlight fade: при переключении highlighted=true крутим короткую + // анимацию "flash + fade out" через Animated.Value (0→1→0 за ~1.8s). + const highlightAnim = useRef(new Animated.Value(0)).current; + useEffect(() => { + if (!highlighted) return; + highlightAnim.setValue(0); + Animated.sequence([ + Animated.timing(highlightAnim, { toValue: 1, duration: 150, useNativeDriver: false }), + Animated.delay(1400), + Animated.timing(highlightAnim, { toValue: 0, duration: 450, useNativeDriver: false }), + ]).start(); + }, [highlighted, highlightAnim]); + + const highlightBg = highlightAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['rgba(29,155,240,0)', 'rgba(29,155,240,0.22)'], + }); + + const isMine = variant === 'own'; + const hasAttachment = !!msg.attachment; + const hasReply = !!msg.replyTo; + const attachmentOnly = hasAttachment && !msg.text.trim(); + const bubbleStyle = attachmentOnly + ? { ...(isMine ? ownBubble : peerBubble), padding: 4 } + : (isMine ? ownBubble : peerBubble); + + const bubbleNode = ( + + + {msg.replyTo && ( + onJumpToReply?.(msg.replyTo!.id)} + /> + )} + {msg.attachment && ( + + )} + {msg.text.trim() ? ( + {msg.text} + ) : null} + + + + ); + + const contentRow = + variant === 'own' ? ( + + {bubbleNode} + + ) : variant === 'peer-compact' ? ( + + {bubbleNode} + + ) : ( + + {showName && ( + + + {peerName} + + + )} + + + {showAvatar ? ( + + + + ) : null} + + {bubbleNode} + + + ); + + return ( + { clearLp(); moved.current = true; }} + style={{ + paddingHorizontal: 8, + marginBottom: 6, + // Selection & highlight накладываются: highlight flash побеждает + // когда анимация > 0, иначе статичный selection-tint. + backgroundColor: selected ? 'rgba(29,155,240,0.12)' : highlightBg, + position: 'relative', + }} + > + {contentRow} + {selectionMode && ( + onTap?.(msg)} + /> + )} + + ); +} + +// ─── Clickable check-dot ──────────────────────────────────────────── + +function CheckDot({ selected, onPress }: { selected: boolean; onPress: () => void }) { + return ( + + + {selected && } + + + ); +} + +// ─── Footer ───────────────────────────────────────────────────────── + +interface FooterProps { + edited: boolean; + time: string; + own?: boolean; + read?: boolean; +} + +function BubbleFooter({ edited, time, own, read }: FooterProps) { + const textColor = own ? 'rgba(255,255,255,0.78)' : '#8b8b8b'; + const dotColor = own ? 'rgba(255,255,255,0.55)' : '#5a5a5a'; + return ( + + {edited && ( + <> + Edited + · + + )} + {time} + {own && ( + + )} + + ); +} diff --git a/client-app/components/chat/ReplyQuote.tsx b/client-app/components/chat/ReplyQuote.tsx new file mode 100644 index 0000000..a1bba49 --- /dev/null +++ b/client-app/components/chat/ReplyQuote.tsx @@ -0,0 +1,70 @@ +/** + * ReplyQuote — блок "цитаты" внутри bubble'а сообщения-ответа. + * + * Визуал: slim-row с синим бордером слева (accent-bar), author в синем, + * preview text — серым, в одну строку. + * + * Tap на quoted-блок → onJump → ChatScreen скроллит к оригиналу и + * подсвечивает его на пару секунд. Если оригинал не найден в текущем + * списке (удалён / ушёл за пределы пагинации) — onJump может просто + * no-op'нуть. + * + * Цвета зависят от того в чьём bubble'е мы находимся: + * - own (синий bubble) → quote border = белый, текст белый/85% + * - peer (серый bubble) → quote border = accent blue, текст white + */ +import React from 'react'; +import { View, Text, Pressable } from 'react-native'; + +export interface ReplyQuoteProps { + author: string; + preview: string; + own?: boolean; + onJump?: () => void; +} + +export function ReplyQuote({ author, preview, own, onJump }: ReplyQuoteProps) { + const barColor = own ? 'rgba(255,255,255,0.85)' : '#1d9bf0'; + const authorColor = own ? '#ffffff' : '#1d9bf0'; + const previewColor = own ? 'rgba(255,255,255,0.85)' : '#c0c0c0'; + + return ( + ({ + flexDirection: 'row', + backgroundColor: own ? 'rgba(255,255,255,0.10)' : 'rgba(29,155,240,0.10)', + borderRadius: 10, + overflow: 'hidden', + marginBottom: 5, + opacity: pressed ? 0.7 : 1, + })} + > + {/* Accent bar слева */} + + + + {author} + + + {preview || 'attachment'} + + + + ); +} diff --git a/client-app/components/chat/VideoCirclePlayer.tsx b/client-app/components/chat/VideoCirclePlayer.tsx new file mode 100644 index 0000000..9d01cf6 --- /dev/null +++ b/client-app/components/chat/VideoCirclePlayer.tsx @@ -0,0 +1,158 @@ +/** + * VideoCirclePlayer — telegram-style круглое видео-сообщение. + * + * Мигрировано с expo-av `