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 ────────────────────────────────────────────────────────────────────