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