From 0bb5780a5d2f101ef1fc46ccc27c7e058d53d631 Mon Sep 17 00:00:00 2001 From: vsecoder Date: Sat, 18 Apr 2026 21:26:43 +0300 Subject: [PATCH] feat(feed/chat): VK-style share post to chats + list breathing room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feed list padding FlatList had no inner padding so the first post bumped against the tab strip and the last post against the NavBar. Added paddingTop: 8 / paddingBottom: 24 on contentContainerStyle in both /feed and /feed/tag/[tag] — first card now has a clear top gap, last card doesn't get hidden behind the FAB or NavBar. Share-to-chat flow Replaces the placeholder share button (which showed an Alert with the post URL) with a real "forward to chats" flow modeled on VK's shared-wall-post embed. New modules lib/forwardPost.ts — encodePostRef / tryParsePostRef + forwardPostToContacts(). Serialises a feed post into a tiny JSON payload that rides the same encrypted envelope as any chat message; decode side distinguishes "post_ref" payloads from regular text by trying JSON.parse on decrypted text. Mirrors the sent message into the sender's local history so they see "you shared this" in the chat they forwarded to. components/feed/ShareSheet.tsx — bottom-sheet picker. Multi-select contacts via tick-box, search by username / alias / address prefix. "Send (N)" dispatches N parallel encrypted envelopes. Contacts with no X25519 key are filtered out (can't encrypt for them). components/chat/PostRefCard.tsx — compact embedded-post card for chat bubbles. Ribbon "ПОСТ" label + author + 3-line excerpt + "с фото" indicator. Tap → /(app)/feed/{id} full post detail. Palette switches between blue-bubble-friendly and peer-bubble- friendly depending on bubble side. Message pipeline lib/types.ts — Message.postRef optional field added. text stays "" when the message is a post-ref (nothing to render as plain text). hooks/useMessages.ts + hooks/useGlobalInbox.ts — post decryption of every inbound envelope runs through tryParsePostRef; matching messages get the postRef attached instead of the raw JSON in .text. components/chat/MessageBubble.tsx — renders PostRefCard inside the bubble when msg.postRef is set. Other bubble features (reply quote, attachment preview, text) still work around it. PostCard - share icon now opens ; the full-URL placeholder is gone. ShareSheet is embedded at the PostCard level so each card owns its own sheet state (avoids modal-stacking issues). Co-Authored-By: Claude Opus 4.7 (1M context) --- client-app/app/(app)/feed/index.tsx | 6 +- client-app/app/(app)/feed/tag/[tag].tsx | 6 +- client-app/components/chat/MessageBubble.tsx | 11 + client-app/components/chat/PostRefCard.tsx | 143 +++++++++ client-app/components/feed/PostCard.tsx | 14 +- client-app/components/feed/ShareSheet.tsx | 307 +++++++++++++++++++ client-app/hooks/useGlobalInbox.ts | 12 +- client-app/hooks/useMessages.ts | 20 +- client-app/lib/forwardPost.ts | 145 +++++++++ client-app/lib/types.ts | 16 + 10 files changed, 668 insertions(+), 12 deletions(-) create mode 100644 client-app/components/chat/PostRefCard.tsx create mode 100644 client-app/components/feed/ShareSheet.tsx create mode 100644 client-app/lib/forwardPost.ts diff --git a/client-app/app/(app)/feed/index.tsx b/client-app/app/(app)/feed/index.tsx index f213e16..aa8fcb3 100644 --- a/client-app/app/(app)/feed/index.tsx +++ b/client-app/app/(app)/feed/index.tsx @@ -248,7 +248,11 @@ export default function FeedScreen() { /> ) } - contentContainerStyle={posts.length === 0 ? { flexGrow: 1 } : undefined} + contentContainerStyle={ + posts.length === 0 + ? { flexGrow: 1 } + : { paddingTop: 8, paddingBottom: 24 } + } /> {/* Floating compose button. diff --git a/client-app/app/(app)/feed/tag/[tag].tsx b/client-app/app/(app)/feed/tag/[tag].tsx index a5bafb4..54d6d3f 100644 --- a/client-app/app/(app)/feed/tag/[tag].tsx +++ b/client-app/app/(app)/feed/tag/[tag].tsx @@ -123,7 +123,11 @@ export default function HashtagScreen() { ) } - contentContainerStyle={posts.length === 0 ? { flexGrow: 1 } : undefined} + contentContainerStyle={ + posts.length === 0 + ? { flexGrow: 1 } + : { paddingTop: 8, paddingBottom: 24 } + } /> ); diff --git a/client-app/components/chat/MessageBubble.tsx b/client-app/components/chat/MessageBubble.tsx index d8985ca..33c9eb9 100644 --- a/client-app/components/chat/MessageBubble.tsx +++ b/client-app/components/chat/MessageBubble.tsx @@ -37,6 +37,7 @@ import { relTime } from '@/lib/dates'; import { Avatar } from '@/components/Avatar'; import { AttachmentPreview } from '@/components/chat/AttachmentPreview'; import { ReplyQuote } from '@/components/chat/ReplyQuote'; +import { PostRefCard } from '@/components/chat/PostRefCard'; export const PEER_AVATAR_SLOT = 34; const SWIPE_THRESHOLD = 60; @@ -198,6 +199,7 @@ function RowShell({ const isMine = variant === 'own'; const hasAttachment = !!msg.attachment; + const hasPostRef = !!msg.postRef; const hasReply = !!msg.replyTo; const attachmentOnly = hasAttachment && !msg.text.trim(); const bubbleStyle = attachmentOnly @@ -225,6 +227,15 @@ function RowShell({ {msg.attachment && ( )} + {msg.postRef && ( + + )} {msg.text.trim() ? ( {msg.text} ) : null} diff --git a/client-app/components/chat/PostRefCard.tsx b/client-app/components/chat/PostRefCard.tsx new file mode 100644 index 0000000..5d9500d --- /dev/null +++ b/client-app/components/chat/PostRefCard.tsx @@ -0,0 +1,143 @@ +/** + * PostRefCard — renders a shared feed post inside a chat bubble. + * + * Visually distinct from plain messages so the user sees at-a-glance + * that this came from the feed, not a direct-typed text. Matches + * VK's "shared wall post" embed pattern: + * + * [newspaper icon] ПОСТ + * @author · 2 строки excerpt'а + * [📷 Фото in this post] + * + * Tap → /(app)/feed/{postID}. The full post (with image + stats + + * like button) is displayed in the standard post-detail screen. + */ +import React from 'react'; +import { View, Text, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; + +import { useStore } from '@/lib/store'; +import { Avatar } from '@/components/Avatar'; + +export interface PostRefCardProps { + postID: string; + author: string; + excerpt: string; + hasImage?: boolean; + /** True when the card appears inside the sender's own bubble (our own + * share). Adjusts colour contrast so it reads on the blue bubble + * background. */ + own: boolean; +} + +function shortAddr(a: string, n = 6): string { + if (!a) return '—'; + return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; +} + +export function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefCardProps) { + const contacts = useStore(s => s.contacts); + + // Resolve author name the same way the feed does. + const contact = contacts.find(c => c.address === author); + const displayName = contact?.username + ? `@${contact.username}` + : contact?.alias ?? shortAddr(author); + + const onOpen = () => { + router.push(`/(app)/feed/${postID}` as never); + }; + + // Tinted palette based on bubble side — inside an "own" (blue) bubble + // the card uses a deeper blue so it reads as a distinct nested block, + // otherwise we use the standard card colours. + const bg = own ? 'rgba(0, 0, 0, 0.22)' : '#0a0a0a'; + const border = own ? 'rgba(255, 255, 255, 0.15)' : '#1f1f1f'; + const labelColor = own ? 'rgba(255, 255, 255, 0.75)' : '#1d9bf0'; + const bodyColor = own ? '#ffffff' : '#ffffff'; + const subColor = own ? 'rgba(255, 255, 255, 0.65)' : '#8b8b8b'; + + return ( + ({ + marginBottom: 6, + borderRadius: 14, + backgroundColor: pressed ? 'rgba(0,0,0,0.35)' : bg, + borderWidth: 1, + borderColor: border, + overflow: 'hidden', + })} + > + {/* Top ribbon: "ПОСТ" label — makes the shared nature unmistakable. */} + + + + ПОСТ + + + + {/* Author + excerpt */} + + + + + {displayName} + + {excerpt.length > 0 && ( + + {excerpt} + + )} + {hasImage && ( + + + + с фото + + + )} + + + + ); +} diff --git a/client-app/components/feed/PostCard.tsx b/client-app/components/feed/PostCard.tsx index 0b6d41d..786860e 100644 --- a/client-app/components/feed/PostCard.tsx +++ b/client-app/components/feed/PostCard.tsx @@ -35,6 +35,7 @@ import type { FeedPostItem } from '@/lib/feed'; import { formatRelativeTime, formatCount, likePost, unlikePost, deletePost, } from '@/lib/feed'; +import { ShareSheet } from '@/components/feed/ShareSheet'; export interface PostCardProps { post: FeedPostItem; @@ -56,6 +57,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }: const [localLiked, setLocalLiked] = useState(!!likedByMe); const [localLikeCount, setLocalLikeCount] = useState(post.likes); const [busy, setBusy] = useState(false); + const [shareOpen, setShareOpen] = useState(false); React.useEffect(() => { setLocalLiked(!!likedByMe); @@ -180,6 +182,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }: const bodyLines = compact ? undefined : 5; return ( + <> { - // Placeholder — copy postID to clipboard in a future PR. - Alert.alert('Ссылка', `dchain://post/${post.post_id}`); - }} + onPress={() => setShareOpen(true)} /> + setShareOpen(false)} + /> + ); } diff --git a/client-app/components/feed/ShareSheet.tsx b/client-app/components/feed/ShareSheet.tsx new file mode 100644 index 0000000..6e99588 --- /dev/null +++ b/client-app/components/feed/ShareSheet.tsx @@ -0,0 +1,307 @@ +/** + * ShareSheet — bottom-sheet picker that forwards a feed post into one + * (or several) chats. Opens when the user taps the share icon on a + * PostCard. + * + * Design notes + * ------------ + * - Single modal component, managed by the parent via `visible` + + * `onClose`. Parent passes the `post` it wants to share. + * - Multi-select: the user can tick several contacts at once and hit + * "Отправить". Fits the common "share with a couple of friends" + * flow better than one-at-a-time. + * - Only contacts with an x25519 key show up — those are the ones we + * can actually encrypt for. An info note explains absent contacts. + * - Search: typing filters the list by username / alias / address + * prefix. Useful once the user has more than a screenful of + * contacts. + */ +import React, { useMemo, useState } from 'react'; +import { + View, Text, Pressable, Modal, FlatList, TextInput, ActivityIndicator, Alert, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Avatar } from '@/components/Avatar'; +import { useStore } from '@/lib/store'; +import type { Contact } from '@/lib/types'; +import type { FeedPostItem } from '@/lib/feed'; +import { forwardPostToContacts } from '@/lib/forwardPost'; + +export interface ShareSheetProps { + visible: boolean; + post: FeedPostItem | null; + onClose: () => void; +} + +export function ShareSheet({ visible, post, onClose }: ShareSheetProps) { + const insets = useSafeAreaInsets(); + const contacts = useStore(s => s.contacts); + const keyFile = useStore(s => s.keyFile); + + const [query, setQuery] = useState(''); + const [picked, setPicked] = useState>(new Set()); + const [sending, setSending] = useState(false); + + const available = useMemo(() => { + const q = query.trim().toLowerCase(); + const withKeys = contacts.filter(c => !!c.x25519Pub); + if (!q) return withKeys; + return withKeys.filter(c => + (c.username ?? '').toLowerCase().includes(q) || + (c.alias ?? '').toLowerCase().includes(q) || + c.address.toLowerCase().startsWith(q), + ); + }, [contacts, query]); + + const toggle = (address: string) => { + setPicked(prev => { + const next = new Set(prev); + if (next.has(address)) next.delete(address); + else next.add(address); + return next; + }); + }; + + const doSend = async () => { + if (!post || !keyFile) return; + const targets = contacts.filter(c => picked.has(c.address)); + if (targets.length === 0) return; + setSending(true); + try { + const { ok, failed } = await forwardPostToContacts({ + post, contacts: targets, keyFile, + }); + if (failed > 0) { + Alert.alert('Готово', `Отправлено в ${ok} из ${ok + failed} чат${plural(ok + failed)}.`); + } + // Close + reset regardless — done is done. + setPicked(new Set()); + setQuery(''); + onClose(); + } catch (e: any) { + Alert.alert('Не удалось', String(e?.message ?? e)); + } finally { + setSending(false); + } + }; + + const closeAndReset = () => { + setPicked(new Set()); + setQuery(''); + onClose(); + }; + + return ( + + {/* Dim backdrop — tap to dismiss */} + + {/* Sheet body — stopPropagation so inner taps don't dismiss */} + e.stopPropagation?.()} + style={{ + backgroundColor: '#0a0a0a', + borderTopLeftRadius: 22, + borderTopRightRadius: 22, + paddingTop: 10, + paddingBottom: Math.max(insets.bottom, 10) + 10, + maxHeight: '78%', + borderTopWidth: 1, + borderTopColor: '#1f1f1f', + }} + > + {/* Drag handle */} + + + {/* Title row */} + + + Поделиться постом + + + + + + + + {/* Search */} + + + + + {query.length > 0 && ( + setQuery('')} hitSlop={6}> + + + )} + + + + {/* Contact list */} + c.address} + renderItem={({ item }) => ( + toggle(item.address)} + /> + )} + ListEmptyComponent={ + + + + {query.length > 0 + ? 'Нет контактов по такому запросу' + : 'Контакты с ключами шифрования отсутствуют'} + + + } + /> + + {/* Send button */} + + ({ + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 13, + borderRadius: 999, + backgroundColor: + picked.size === 0 ? '#1f1f1f' + : pressed ? '#1a8cd8' : '#1d9bf0', + })} + > + {sending ? ( + + ) : ( + + {picked.size === 0 + ? 'Выберите контакты' + : `Отправить (${picked.size})`} + + )} + + + + + + ); +} + +// ── Row ───────────────────────────────────────────────────────────────── + +function ContactRow({ contact, checked, onToggle }: { + contact: Contact; + checked: boolean; + onToggle: () => void; +}) { + const name = contact.username + ? `@${contact.username}` + : contact.alias ?? shortAddr(contact.address); + return ( + ({ + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + backgroundColor: pressed ? '#111111' : 'transparent', + })} + > + + + + {name} + + + {shortAddr(contact.address, 8)} + + + {/* Checkbox indicator */} + + {checked && } + + + ); +} + +function shortAddr(a: string, n = 6): string { + if (!a) return '—'; + return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; +} + +function plural(n: number): string { + const mod100 = n % 100; + const mod10 = n % 10; + if (mod100 >= 11 && mod100 <= 19) return 'ов'; + if (mod10 === 1) return ''; + if (mod10 >= 2 && mod10 <= 4) return 'а'; + return 'ов'; +} diff --git a/client-app/hooks/useGlobalInbox.ts b/client-app/hooks/useGlobalInbox.ts index 78a2a1b..f24d9dc 100644 --- a/client-app/hooks/useGlobalInbox.ts +++ b/client-app/hooks/useGlobalInbox.ts @@ -24,6 +24,7 @@ import { usePathname } from 'expo-router'; import { useStore } from '@/lib/store'; import { getWSClient } from '@/lib/ws'; import { decryptMessage } from '@/lib/crypto'; +import { tryParsePostRef } from '@/lib/forwardPost'; import { fetchInbox } from '@/lib/api'; import { appendMessage } from '@/lib/storage'; import { randomId } from '@/lib/utils'; @@ -74,12 +75,21 @@ export function useGlobalInbox() { // Стабильный id от сервера (sha256(nonce||ct)[:16]); fallback // на nonce-префикс если вдруг env.id пустой. const msgId = env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`; + const postRef = tryParsePostRef(text); const msg = { id: msgId, from: env.sender_pub, - text, + text: postRef ? '' : text, timestamp: env.timestamp, mine: false, + ...(postRef && { + postRef: { + postID: postRef.post_id, + author: postRef.author, + excerpt: postRef.excerpt, + hasImage: postRef.has_image, + }, + }), }; appendMsg(c.address, msg); await appendMessage(c.address, msg); diff --git a/client-app/hooks/useMessages.ts b/client-app/hooks/useMessages.ts index 0433104..5163f9d 100644 --- a/client-app/hooks/useMessages.ts +++ b/client-app/hooks/useMessages.ts @@ -19,6 +19,7 @@ import { getWSClient } from '@/lib/ws'; import { decryptMessage } from '@/lib/crypto'; import { appendMessage, loadMessages } from '@/lib/storage'; import { useStore } from '@/lib/store'; +import { tryParsePostRef } from '@/lib/forwardPost'; const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect @@ -58,16 +59,25 @@ export function useMessages(contactX25519: string) { ); if (!text) continue; - // Dedup id — используем стабильный серверный env.id (hex - // sha256(nonce||ct)[:16]). Раньше собирался из env.timestamp, - // но клиентский тип не имел sent_at, поэтому timestamp был - // undefined и все id коллапсировали на "undefined". + // Detect forwarded feed posts — plaintext is a tiny JSON + // envelope (see lib/forwardPost.ts). Regular text messages + // stay as-is. + const postRef = tryParsePostRef(text); + const msg = { id: env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`, from: env.sender_pub, - text, + text: postRef ? '' : text, timestamp: env.timestamp, mine: false, + ...(postRef && { + postRef: { + postID: postRef.post_id, + author: postRef.author, + excerpt: postRef.excerpt, + hasImage: postRef.has_image, + }, + }), }; appendMsg(contactX25519, msg); await appendMessage(contactX25519, msg); diff --git a/client-app/lib/forwardPost.ts b/client-app/lib/forwardPost.ts new file mode 100644 index 0000000..af6c8b0 --- /dev/null +++ b/client-app/lib/forwardPost.ts @@ -0,0 +1,145 @@ +/** + * Forward a feed post into a direct chat as a "post reference" message. + * + * What the receiver sees + * ---------------------- + * A special chat bubble rendering a compact card: + * [avatar] @author "post excerpt…" [📷 if has image] + * + * Tapping the card opens the full post detail. Design is modelled on + * VK/Twitter's "shared post" embed: visually distinct from a plain + * message so the user sees at a glance that this came from the feed, + * not from the sender directly. + * + * Transport + * --------- + * Same encrypted envelope as a normal chat message. The payload is + * plaintext JSON with a discriminator: + * + * { "kind": "post_ref", "post_id": "...", "author": "...", + * "excerpt": "first 120 chars of body", "has_image": true } + * + * The receiver's useMessages / useGlobalInbox hooks detect the JSON + * shape after decryption and assign it to Message.postRef for rendering. + * Plain-text messages stay wrapped in the same envelope format — the + * only difference is whether the decrypted body parses as our JSON + * schema. + */ + +import { encryptMessage } from './crypto'; +import { sendEnvelope } from './api'; +import { appendMessage } from './storage'; +import { useStore } from './store'; +import { randomId } from './utils'; +import type { Contact, Message } from './types'; +import type { FeedPostItem } from './feed'; + +const POST_REF_MARKER = 'dchain-post-ref'; +const EXCERPT_MAX = 120; + +export interface PostRefPayload { + kind: typeof POST_REF_MARKER; + post_id: string; + author: string; + excerpt: string; + has_image: boolean; +} + +/** Serialise a post ref for the wire. */ +export function encodePostRef(post: FeedPostItem): string { + const payload: PostRefPayload = { + kind: POST_REF_MARKER, + post_id: post.post_id, + author: post.author, + excerpt: truncate(post.content, EXCERPT_MAX), + has_image: !!post.has_attachment, + }; + return JSON.stringify(payload); +} + +/** + * Try to parse an incoming plaintext message as a post reference. + * Returns null if the payload isn't the expected shape — the caller + * then treats it as a normal text message. + */ +export function tryParsePostRef(plaintext: string): PostRefPayload | null { + const trimmed = plaintext.trim(); + if (!trimmed.startsWith('{')) return null; + try { + const parsed = JSON.parse(trimmed) as Partial; + if (parsed.kind !== POST_REF_MARKER) return null; + if (!parsed.post_id || !parsed.author) return null; + return { + kind: POST_REF_MARKER, + post_id: String(parsed.post_id), + author: String(parsed.author), + excerpt: String(parsed.excerpt ?? ''), + has_image: !!parsed.has_image, + }; + } catch { + return null; + } +} + +/** + * Forward `post` to each of the given contacts as a post-ref message. + * Creates a fresh envelope per recipient (can't fan-out a single + * ciphertext — each recipient's x25519 key seals differently) and + * drops a mirrored Message into our local chat history so the user + * sees the share in their own view too. + * + * Contacts without an x25519 public key are skipped with a warning + * instead of failing the whole batch. + */ +export async function forwardPostToContacts(params: { + post: FeedPostItem; + contacts: Contact[]; + keyFile: { pub_key: string; priv_key: string; x25519_pub: string; x25519_priv: string }; +}): Promise<{ ok: number; failed: number }> { + const { post, contacts, keyFile } = params; + const appendMsg = useStore.getState().appendMessage; + const body = encodePostRef(post); + + let ok = 0, failed = 0; + for (const c of contacts) { + if (!c.x25519Pub) { failed++; continue; } + try { + const { nonce, ciphertext } = encryptMessage( + body, keyFile.x25519_priv, c.x25519Pub, + ); + await sendEnvelope({ + senderPub: keyFile.x25519_pub, + recipientPub: c.x25519Pub, + senderEd25519Pub: keyFile.pub_key, + nonce, ciphertext, + }); + + // Mirror into local history so the sender sees "you shared this". + const mirrored: Message = { + id: randomId(), + from: keyFile.x25519_pub, + text: '', // postRef carries all the content + timestamp: Math.floor(Date.now() / 1000), + mine: true, + postRef: { + postID: post.post_id, + author: post.author, + excerpt: truncate(post.content, EXCERPT_MAX), + hasImage: !!post.has_attachment, + }, + }; + appendMsg(c.address, mirrored); + await appendMessage(c.address, mirrored); + ok++; + } catch { + failed++; + } + } + return { ok, failed }; +} + +function truncate(s: string, n: number): string { + if (!s) return ''; + if (s.length <= n) return s; + return s.slice(0, n).trimEnd() + '…'; +} diff --git a/client-app/lib/types.ts b/client-app/lib/types.ts index 6790ed1..45ba605 100644 --- a/client-app/lib/types.ts +++ b/client-app/lib/types.ts @@ -96,6 +96,22 @@ export interface Message { text: string; author: string; // @username / alias / "you" }; + /** + * Ссылка на пост из ленты. Если присутствует — сообщение рендерится как + * карточка-превью поста (аватар автора, хэндл, текст-excerpt, картинка + * если есть). Тап на карточку → открывается полный пост. Сценарий — юзер + * нажал Share в ленте и отправил пост в этот чат/ЛС. + * + * Содержимое (автор, excerpt) дублируется тут, чтобы карточку можно было + * рендерить оффлайн / когда у хостящей релей-ноды пропал пост — чат + * остаётся читаемым независимо от жизни ленты. + */ + postRef?: { + postID: string; + author: string; // Ed25519 hex — для чипа имени в карточке + excerpt: string; // первые 120 символов тела поста + hasImage?: boolean; + }; } // ─── Chat ────────────────────────────────────────────────────────────────────