feat(feed/chat): VK-style share post to chats + list breathing room
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 <ShareSheet>; 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
{/* Floating compose button.
|
||||||
|
|||||||
@@ -123,7 +123,11 @@ export default function HashtagScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
contentContainerStyle={posts.length === 0 ? { flexGrow: 1 } : undefined}
|
contentContainerStyle={
|
||||||
|
posts.length === 0
|
||||||
|
? { flexGrow: 1 }
|
||||||
|
: { paddingTop: 8, paddingBottom: 24 }
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { relTime } from '@/lib/dates';
|
|||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { AttachmentPreview } from '@/components/chat/AttachmentPreview';
|
import { AttachmentPreview } from '@/components/chat/AttachmentPreview';
|
||||||
import { ReplyQuote } from '@/components/chat/ReplyQuote';
|
import { ReplyQuote } from '@/components/chat/ReplyQuote';
|
||||||
|
import { PostRefCard } from '@/components/chat/PostRefCard';
|
||||||
|
|
||||||
export const PEER_AVATAR_SLOT = 34;
|
export const PEER_AVATAR_SLOT = 34;
|
||||||
const SWIPE_THRESHOLD = 60;
|
const SWIPE_THRESHOLD = 60;
|
||||||
@@ -198,6 +199,7 @@ function RowShell({
|
|||||||
|
|
||||||
const isMine = variant === 'own';
|
const isMine = variant === 'own';
|
||||||
const hasAttachment = !!msg.attachment;
|
const hasAttachment = !!msg.attachment;
|
||||||
|
const hasPostRef = !!msg.postRef;
|
||||||
const hasReply = !!msg.replyTo;
|
const hasReply = !!msg.replyTo;
|
||||||
const attachmentOnly = hasAttachment && !msg.text.trim();
|
const attachmentOnly = hasAttachment && !msg.text.trim();
|
||||||
const bubbleStyle = attachmentOnly
|
const bubbleStyle = attachmentOnly
|
||||||
@@ -225,6 +227,15 @@ function RowShell({
|
|||||||
{msg.attachment && (
|
{msg.attachment && (
|
||||||
<AttachmentPreview attachment={msg.attachment} own={isMine} />
|
<AttachmentPreview attachment={msg.attachment} own={isMine} />
|
||||||
)}
|
)}
|
||||||
|
{msg.postRef && (
|
||||||
|
<PostRefCard
|
||||||
|
postID={msg.postRef.postID}
|
||||||
|
author={msg.postRef.author}
|
||||||
|
excerpt={msg.postRef.excerpt}
|
||||||
|
hasImage={!!msg.postRef.hasImage}
|
||||||
|
own={isMine}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{msg.text.trim() ? (
|
{msg.text.trim() ? (
|
||||||
<Text style={bubbleText}>{msg.text}</Text>
|
<Text style={bubbleText}>{msg.text}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
143
client-app/components/chat/PostRefCard.tsx
Normal file
143
client-app/components/chat/PostRefCard.tsx
Normal file
@@ -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 (
|
||||||
|
<Pressable
|
||||||
|
onPress={onOpen}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
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. */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="newspaper-outline" size={11} color={labelColor} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: labelColor,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ПОСТ
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Author + excerpt */}
|
||||||
|
<View style={{ flexDirection: 'row', paddingHorizontal: 10, paddingBottom: 10 }}>
|
||||||
|
<Avatar name={displayName} address={author} size={28} />
|
||||||
|
<View style={{ flex: 1, marginLeft: 8, minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
color: bodyColor,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
{excerpt.length > 0 && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={3}
|
||||||
|
style={{
|
||||||
|
color: subColor,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{excerpt}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{hasImage && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="image-outline" size={11} color={subColor} />
|
||||||
|
<Text style={{ color: subColor, fontSize: 11 }}>
|
||||||
|
с фото
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import type { FeedPostItem } from '@/lib/feed';
|
|||||||
import {
|
import {
|
||||||
formatRelativeTime, formatCount, likePost, unlikePost, deletePost,
|
formatRelativeTime, formatCount, likePost, unlikePost, deletePost,
|
||||||
} from '@/lib/feed';
|
} from '@/lib/feed';
|
||||||
|
import { ShareSheet } from '@/components/feed/ShareSheet';
|
||||||
|
|
||||||
export interface PostCardProps {
|
export interface PostCardProps {
|
||||||
post: FeedPostItem;
|
post: FeedPostItem;
|
||||||
@@ -56,6 +57,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
|
|||||||
const [localLiked, setLocalLiked] = useState<boolean>(!!likedByMe);
|
const [localLiked, setLocalLiked] = useState<boolean>(!!likedByMe);
|
||||||
const [localLikeCount, setLocalLikeCount] = useState<number>(post.likes);
|
const [localLikeCount, setLocalLikeCount] = useState<number>(post.likes);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setLocalLiked(!!likedByMe);
|
setLocalLiked(!!likedByMe);
|
||||||
@@ -180,6 +182,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
|
|||||||
const bodyLines = compact ? undefined : 5;
|
const bodyLines = compact ? undefined : 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onOpenDetail}
|
onPress={onOpenDetail}
|
||||||
onLongPress={onLongPress}
|
onLongPress={onLongPress}
|
||||||
@@ -354,15 +357,18 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
|
|||||||
<View style={{ alignItems: 'flex-end' }}>
|
<View style={{ alignItems: 'flex-end' }}>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon="share-outline"
|
icon="share-outline"
|
||||||
onPress={() => {
|
onPress={() => setShareOpen(true)}
|
||||||
// Placeholder — copy postID to clipboard in a future PR.
|
|
||||||
Alert.alert('Ссылка', `dchain://post/${post.post_id}`);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<ShareSheet
|
||||||
|
visible={shareOpen}
|
||||||
|
post={post}
|
||||||
|
onClose={() => setShareOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
307
client-app/components/feed/ShareSheet.tsx
Normal file
307
client-app/components/feed/ShareSheet.tsx
Normal file
@@ -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<Set<string>>(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 (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
transparent
|
||||||
|
onRequestClose={closeAndReset}
|
||||||
|
>
|
||||||
|
{/* Dim backdrop — tap to dismiss */}
|
||||||
|
<Pressable
|
||||||
|
onPress={closeAndReset}
|
||||||
|
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.72)', justifyContent: 'flex-end' }}
|
||||||
|
>
|
||||||
|
{/* Sheet body — stopPropagation so inner taps don't dismiss */}
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => 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 */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: 44, height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title row */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
paddingHorizontal: 16, marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}>
|
||||||
|
Поделиться постом
|
||||||
|
</Text>
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
<Pressable onPress={closeAndReset} hitSlop={8}>
|
||||||
|
<Ionicons name="close" size={22} color="#8b8b8b" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<View style={{ paddingHorizontal: 16, marginBottom: 10 }}>
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
gap: 6,
|
||||||
|
}}>
|
||||||
|
<Ionicons name="search" size={14} color="#6a6a6a" />
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder="Поиск по контактам"
|
||||||
|
placeholderTextColor="#5a5a5a"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
}}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
{query.length > 0 && (
|
||||||
|
<Pressable onPress={() => setQuery('')} hitSlop={6}>
|
||||||
|
<Ionicons name="close-circle" size={16} color="#6a6a6a" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Contact list */}
|
||||||
|
<FlatList
|
||||||
|
data={available}
|
||||||
|
keyExtractor={c => c.address}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ContactRow
|
||||||
|
contact={item}
|
||||||
|
checked={picked.has(item.address)}
|
||||||
|
onToggle={() => toggle(item.address)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={{
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<Ionicons name="people-outline" size={28} color="#5a5a5a" />
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 10 }}>
|
||||||
|
{query.length > 0
|
||||||
|
? 'Нет контактов по такому запросу'
|
||||||
|
: 'Контакты с ключами шифрования отсутствуют'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Send button */}
|
||||||
|
<View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={doSend}
|
||||||
|
disabled={picked.size === 0 || sending}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 13,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor:
|
||||||
|
picked.size === 0 ? '#1f1f1f'
|
||||||
|
: pressed ? '#1a8cd8' : '#1d9bf0',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={{
|
||||||
|
color: picked.size === 0 ? '#5a5a5a' : '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
|
}}>
|
||||||
|
{picked.size === 0
|
||||||
|
? 'Выберите контакты'
|
||||||
|
: `Отправить (${picked.size})`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ContactRow({ contact, checked, onToggle }: {
|
||||||
|
contact: Contact;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const name = contact.username
|
||||||
|
? `@${contact.username}`
|
||||||
|
: contact.alias ?? shortAddr(contact.address);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onToggle}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
backgroundColor: pressed ? '#111111' : 'transparent',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Avatar name={name} address={contact.address} size={38} />
|
||||||
|
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}
|
||||||
|
>
|
||||||
|
{shortAddr(contact.address, 8)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{/* Checkbox indicator */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 22, height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: checked ? '#1d9bf0' : '#2a2a2a',
|
||||||
|
backgroundColor: checked ? '#1d9bf0' : 'transparent',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{checked && <Ionicons name="checkmark" size={14} color="#ffffff" />}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 'ов';
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import { usePathname } from 'expo-router';
|
|||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { getWSClient } from '@/lib/ws';
|
import { getWSClient } from '@/lib/ws';
|
||||||
import { decryptMessage } from '@/lib/crypto';
|
import { decryptMessage } from '@/lib/crypto';
|
||||||
|
import { tryParsePostRef } from '@/lib/forwardPost';
|
||||||
import { fetchInbox } from '@/lib/api';
|
import { fetchInbox } from '@/lib/api';
|
||||||
import { appendMessage } from '@/lib/storage';
|
import { appendMessage } from '@/lib/storage';
|
||||||
import { randomId } from '@/lib/utils';
|
import { randomId } from '@/lib/utils';
|
||||||
@@ -74,12 +75,21 @@ export function useGlobalInbox() {
|
|||||||
// Стабильный id от сервера (sha256(nonce||ct)[:16]); fallback
|
// Стабильный id от сервера (sha256(nonce||ct)[:16]); fallback
|
||||||
// на nonce-префикс если вдруг env.id пустой.
|
// на nonce-префикс если вдруг env.id пустой.
|
||||||
const msgId = env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`;
|
const msgId = env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`;
|
||||||
|
const postRef = tryParsePostRef(text);
|
||||||
const msg = {
|
const msg = {
|
||||||
id: msgId,
|
id: msgId,
|
||||||
from: env.sender_pub,
|
from: env.sender_pub,
|
||||||
text,
|
text: postRef ? '' : text,
|
||||||
timestamp: env.timestamp,
|
timestamp: env.timestamp,
|
||||||
mine: false,
|
mine: false,
|
||||||
|
...(postRef && {
|
||||||
|
postRef: {
|
||||||
|
postID: postRef.post_id,
|
||||||
|
author: postRef.author,
|
||||||
|
excerpt: postRef.excerpt,
|
||||||
|
hasImage: postRef.has_image,
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
appendMsg(c.address, msg);
|
appendMsg(c.address, msg);
|
||||||
await appendMessage(c.address, msg);
|
await appendMessage(c.address, msg);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { getWSClient } from '@/lib/ws';
|
|||||||
import { decryptMessage } from '@/lib/crypto';
|
import { decryptMessage } from '@/lib/crypto';
|
||||||
import { appendMessage, loadMessages } from '@/lib/storage';
|
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
|
import { tryParsePostRef } from '@/lib/forwardPost';
|
||||||
|
|
||||||
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
|
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
|
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;
|
if (!text) continue;
|
||||||
|
|
||||||
// Dedup id — используем стабильный серверный env.id (hex
|
// Detect forwarded feed posts — plaintext is a tiny JSON
|
||||||
// sha256(nonce||ct)[:16]). Раньше собирался из env.timestamp,
|
// envelope (see lib/forwardPost.ts). Regular text messages
|
||||||
// но клиентский тип не имел sent_at, поэтому timestamp был
|
// stay as-is.
|
||||||
// undefined и все id коллапсировали на "undefined".
|
const postRef = tryParsePostRef(text);
|
||||||
|
|
||||||
const msg = {
|
const msg = {
|
||||||
id: env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`,
|
id: env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`,
|
||||||
from: env.sender_pub,
|
from: env.sender_pub,
|
||||||
text,
|
text: postRef ? '' : text,
|
||||||
timestamp: env.timestamp,
|
timestamp: env.timestamp,
|
||||||
mine: false,
|
mine: false,
|
||||||
|
...(postRef && {
|
||||||
|
postRef: {
|
||||||
|
postID: postRef.post_id,
|
||||||
|
author: postRef.author,
|
||||||
|
excerpt: postRef.excerpt,
|
||||||
|
hasImage: postRef.has_image,
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
appendMsg(contactX25519, msg);
|
appendMsg(contactX25519, msg);
|
||||||
await appendMessage(contactX25519, msg);
|
await appendMessage(contactX25519, msg);
|
||||||
|
|||||||
145
client-app/lib/forwardPost.ts
Normal file
145
client-app/lib/forwardPost.ts
Normal file
@@ -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<PostRefPayload>;
|
||||||
|
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() + '…';
|
||||||
|
}
|
||||||
@@ -96,6 +96,22 @@ export interface Message {
|
|||||||
text: string;
|
text: string;
|
||||||
author: string; // @username / alias / "you"
|
author: string; // @username / alias / "you"
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Ссылка на пост из ленты. Если присутствует — сообщение рендерится как
|
||||||
|
* карточка-превью поста (аватар автора, хэндл, текст-excerpt, картинка
|
||||||
|
* если есть). Тап на карточку → открывается полный пост. Сценарий — юзер
|
||||||
|
* нажал Share в ленте и отправил пост в этот чат/ЛС.
|
||||||
|
*
|
||||||
|
* Содержимое (автор, excerpt) дублируется тут, чтобы карточку можно было
|
||||||
|
* рендерить оффлайн / когда у хостящей релей-ноды пропал пост — чат
|
||||||
|
* остаётся читаемым независимо от жизни ленты.
|
||||||
|
*/
|
||||||
|
postRef?: {
|
||||||
|
postID: string;
|
||||||
|
author: string; // Ed25519 hex — для чипа имени в карточке
|
||||||
|
excerpt: string; // первые 120 символов тела поста
|
||||||
|
hasImage?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Chat ────────────────────────────────────────────────────────────────────
|
// ─── Chat ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user