Files
dchain/client-app/components/feed/ShareSheet.tsx
vsecoder 0bb5780a5d 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>
2026-04-18 21:26:43 +03:00

308 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 'ов';
}