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>
125 lines
5.0 KiB
TypeScript
125 lines
5.0 KiB
TypeScript
/**
|
||
* useGlobalInbox — app-wide inbox listener.
|
||
*
|
||
* Подписан на WS-топик `inbox:<my_x25519>` при любом экране внутри
|
||
* (app)-группы. Когда приходит push с envelope, мы:
|
||
* 1. Декриптуем — если это наш контакт, добавляем в store.
|
||
* 2. Инкрементим unreadByContact[address].
|
||
* 3. Показываем local notification (от кого + счётчик).
|
||
*
|
||
* НЕ дублирует chat-detail'овский `useMessages` — тот делает initial
|
||
* HTTP-pull при открытии чата и слушает тот же топик (двойная подписка
|
||
* с фильтром по sender_pub). Оба держат в store консистентное состояние
|
||
* через `appendMessage` (который идемпотентен по id).
|
||
*
|
||
* Фильтрация "app backgrounded" не нужна: Expo notifications'handler
|
||
* показывает banner и в foreground, но при активном чате с этим
|
||
* контактом нотификация dismiss'ится автоматически через
|
||
* clearContactNotifications (вызывается при mount'е chats/[id]).
|
||
*/
|
||
import { useEffect, useRef } from 'react';
|
||
import { AppState } from 'react-native';
|
||
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';
|
||
|
||
import { notifyIncoming } from './useNotifications';
|
||
|
||
export function useGlobalInbox() {
|
||
const keyFile = useStore(s => s.keyFile);
|
||
const contacts = useStore(s => s.contacts);
|
||
const appendMsg = useStore(s => s.appendMessage);
|
||
const incrementUnread = useStore(s => s.incrementUnread);
|
||
const pathname = usePathname();
|
||
const contactsRef = useRef(contacts);
|
||
const pathnameRef = useRef(pathname);
|
||
|
||
useEffect(() => { contactsRef.current = contacts; }, [contacts]);
|
||
useEffect(() => { pathnameRef.current = pathname; }, [pathname]);
|
||
|
||
useEffect(() => {
|
||
if (!keyFile?.x25519_pub) return;
|
||
|
||
const ws = getWSClient();
|
||
|
||
const handleEnvelopePull = async () => {
|
||
try {
|
||
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||
for (const env of envelopes) {
|
||
// Найти контакт по sender_pub — если не знакомый, игнорим
|
||
// (для MVP; в future можно показывать "unknown sender").
|
||
const c = contactsRef.current.find(
|
||
x => x.x25519Pub === env.sender_pub,
|
||
);
|
||
if (!c) continue;
|
||
|
||
let text = '';
|
||
try {
|
||
text = decryptMessage(
|
||
env.ciphertext,
|
||
env.nonce,
|
||
env.sender_pub,
|
||
keyFile.x25519_priv,
|
||
) ?? '';
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (!text) continue;
|
||
|
||
// Стабильный 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: 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);
|
||
|
||
// Если пользователь прямо сейчас в этом чате — unread не инкрементим,
|
||
// notification не показываем.
|
||
const inThisChat =
|
||
pathnameRef.current === `/chats/${c.address}` ||
|
||
pathnameRef.current.startsWith(`/chats/${c.address}/`);
|
||
if (inThisChat && AppState.currentState === 'active') continue;
|
||
|
||
incrementUnread(c.address);
|
||
const unread = useStore.getState().unreadByContact[c.address] ?? 1;
|
||
notifyIncoming({
|
||
contactAddress: c.address,
|
||
senderName: c.username ? `@${c.username}` : (c.alias ?? 'New message'),
|
||
unreadCount: unread,
|
||
});
|
||
}
|
||
} catch {
|
||
/* silent — ошибки pull'а обрабатывает useMessages */
|
||
}
|
||
};
|
||
|
||
const off = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
|
||
if (frame.event !== 'inbox') return;
|
||
handleEnvelopePull();
|
||
});
|
||
|
||
return off;
|
||
}, [keyFile, appendMsg, incrementUnread]);
|
||
}
|