feat(social): add listening activity, shared rooms and modern chat
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s

This commit is contained in:
mrleo1nid committed 2026-10-23 10:45:40 +03:00
1 parent 0a05bfe2cd
commit 11a16cd023
59 files changed
+5022 -542

No files matched your search

+54 -53
View File
@@ -1,9 +1,12 @@
import { attachmentPath, socialApi, uuidv7, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { attachmentPath, socialApi, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { eventId } from '../../player/eventId';
import { ListeningPanel, ListenTogetherButton } from './Listening';
import { ChatConversation } from './ChatConversation';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { AppState, Clipboard, KeyboardAvoidingView, Modal, ScrollView, Share, Text, View } from 'react-native';
import { AppState, Clipboard, KeyboardAvoidingView, Modal, Pressable, ScrollView, Share, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { useColors } from '../../theme';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
@@ -20,6 +23,30 @@ function useSocial() {
return { api, key, inbox, user, active };
}
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
const ChatDockContext = createContext<(id: string) => boolean>(() => false);
export function NativeChatProvider({ children }: { children: ReactNode }) {
const { inbox } = useSocial(); const colors = useColors(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [peer, setPeer] = useState<string | null>(null);
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
const selected = friends.find(p => p.id === peer); const unread = friends.reduce((sum,p) => sum+p.unread,0);
return <ChatDockContext.Provider value={id => { setPeer(id); setOpen(true); return true; }}>{children}
<View style={{ position: 'absolute', bottom: 158, right: 16 }}><Button title={`${t('social.messages')}${unread ? ` · ${unread}` : ''}`} variant="secondary" onPress={() => setOpen(true)} /></View>
<Modal transparent visible={open} animationType="slide" onRequestClose={() => setOpen(false)}>
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: colors.overlay }}><View style={{ height: '82%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', backgroundColor: colors.bg }}>
<Screen><View style={{ padding: 12, flexDirection: 'row', justifyContent: 'space-between' }}><Button title={t('social.people')} variant="ghost" onPress={() => setPeer(null)} /><Button title={t('social.minimize')} variant="ghost" onPress={() => setOpen(false)} /></View>
{selected ? <Chat key={selected.id} person={selected} active={open} /> : <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}><H1>{t('social.messages')}</H1>{friends.map(p => <Button key={p.id} title={`${p.display_name}${p.unread ? ` · ${p.unread}` : ''}`} variant="secondary" onPress={() => setPeer(p.id)} />)}{!friends.length && <Muted>{t('social.no_friends_share')}</Muted>}</ScrollView>}
</Screen>
</View></View>
</Modal>
</ChatDockContext.Provider>;
}
export function FriendRecommendations() {
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <View style={{ gap: 8 }}><Button title={t('social.friends_recommendations')} variant={(inbox.data?.friends_recommendations ?? true) ? 'primary' : 'secondary'} disabled={!inbox.data || save.isPending} onPress={() => save.mutate(!(inbox.data?.friends_recommendations ?? true))} /><Muted>{t('social.friends_recommendations_hint')}</Muted><ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox></View>;
}
export function MusicShareSheet({ attachment, url, close }: { attachment: MusicAttachment; url: string; close: () => void }) {
const { t } = useTranslation();
const { inbox, api, key } = useSocial();
@@ -31,7 +58,7 @@ export function MusicShareSheet({ attachment, url, close }: { attachment: MusicA
const send = useMutation({ mutationFn: async (peer: string) => {
if (peer === 'copy') { Clipboard.setString(url); setCopied(true); return; }
if (peer === 'external') { await Share.share({ title: attachment.title, message: url }); return; }
let id = ids.current.get(peer); if (!id) { id = uuidv7(); ids.current.set(peer, id); }
let id = ids.current.get(peer); if (!id) { id = eventId(Date.now()); ids.current.set(peer, id); }
await api.send(peer, { id, text: attachment.title, attachment_kind: attachment.kind, attachment_id: attachment.id });
setSent(v => [...v, peer]); await cache.invalidateQueries({ queryKey: key });
} });
@@ -49,6 +76,7 @@ export function MusicShareSheet({ attachment, url, close }: { attachment: MusicA
}
export function SocialScreen({ root = false }: { root?: boolean }) {
const dock = useContext(ChatDockContext);
const { t } = useTranslation();
const colors = useColors();
const router = useRouter();
@@ -62,12 +90,20 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
const act = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
if (selected) return <Screen><Button title={t('social.back')} variant="ghost" onPress={() => setPeer('')} /><Chat key={selected.id} person={selected} /></Screen>;
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
return <Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
{!root && <Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />}
<H1>{t('social.title')}</H1><Muted>{t('social.subtitle')}</Muted>
<View style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
<Muted>{t('social.activity_visibility')}</Muted>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} disabled={!inbox.data || visibility.isPending} onPress={() => visibility.mutate(v)} />)}</View>
<Muted>{t('social.activity_privacy_hint')}</Muted>
<ErrorBox>{visibility.error ? errorText(visibility.error) : null}</ErrorBox>
</View>
<ListeningPanel />
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
<Button title={t('social.people')} variant={notifications ? 'ghost' : 'primary'} onPress={() => setNotifications(false)} />
<Button title={`${t('social.notifications')} ${inbox.data?.unread || ''}`} variant={notifications ? 'primary' : 'ghost'} onPress={() => setNotifications(true)} />
@@ -87,7 +123,15 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
{!people.length && inbox.data && !results.isFetching && <Muted>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</Muted>}
{people.map(p => <View key={p.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
<Text style={{ color: colors.text, fontWeight: '600', fontSize: 17 }}>{p.display_name}</Text><Muted>@{p.username} · {t(`social.rel_${p.relationship}`)}</Muted>
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => setPeer(p.id)} />}
{p.activity && <View style={{ borderLeftWidth: 2, borderLeftColor: colors.ok, paddingLeft: 12, gap: 6 }}>
<Text style={{ color: colors.ok, fontSize: 12 }}>{t('social.listening')}</Text>
<Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-track', params: { id: p.activity!.track_id } })}><Text style={{ color: colors.accent2 }}>{p.activity.track_title}</Text></Pressable>
{!!p.activity.artist && <Muted>{p.activity.artist}</Muted>}
{p.activity.context === 'wave' && <Muted>{t('wave.title')}</Muted>}
{p.activity.context === 'playlist' && p.activity.playlist_id && <Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-playlist', params: { owner: p.id, id: p.activity!.playlist_id } })}><Text style={{ color: colors.accent2 }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Text></Pressable>}
</View>}
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => { if (!dock(p.id)) setPeer(p.id); }} />}
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
{p.relationship === 'none' && <Button title={t('social.add')} disabled={act.isPending} onPress={() => act.mutate({ action: 'request', target_id: p.id })} />}
{p.relationship === 'incoming' && <Button title={t('social.accept')} disabled={act.isPending} onPress={() => act.mutate({ action: 'accept', target_id: p.id })} />}
{['friend', 'incoming', 'outgoing'].includes(p.relationship) && <Button title={t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'remove', target_id: p.id })} />}
@@ -96,52 +140,9 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
</>}
</ScrollView></Screen>;
}
function Chat({ person }: { person: SocialPerson }) {
const { t } = useTranslation();
const colors = useColors();
const { server } = useSessionCtx();
const { api, key, user, active } = useSocial();
const router = useRouter();
const cache = useQueryClient();
const errorText = useErrorText();
const [text, setText] = useState('');
const [focused, setFocused] = useState(false);
useFocusEffect(useCallback(() => { setFocused(true); return () => setFocused(false); }, []));
const pending = useRef<{ id: string; text: string } | null>(null);
const messages = useInfiniteQuery({ queryKey: [...key, 'chat', person.id], initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) => api.messages(person.id, pageParam), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined, enabled: active && focused, refetchInterval: active && focused ? 8_000 : false });
const list = [...(messages.data?.pages ?? [])].reverse().flat();
const last = list.at(-1)?.seq;
const unread = list.some(m => m.sender_id !== user.id && !m.read);
useEffect(() => {
if (last && unread && active && focused) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey: key })).catch(() => undefined);
}, [last, unread, active, focused, api, person.id, messages.dataUpdatedAt]);
const send = useMutation({ mutationFn: async () => {
if (!pending.current || pending.current.text !== text) pending.current = { id: uuidv7(), text };
return api.send(person.id, { ...pending.current, attachment_kind: 'none' });
}, onSuccess: async () => { setText(''); pending.current = null; await cache.invalidateQueries({ queryKey: key }); } });
const scroll = useRef<ScrollView>(null);
const [scrollBottom, setScrollBottom] = useState(true);
useEffect(() => { setScrollBottom(true); }, [last]);
return <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<View style={{ padding: 16, gap: 8 }}><H1>{person.display_name}</H1><Muted>{t('social.chat_hint')}</Muted><ErrorBox>{messages.error || send.error ? errorText(messages.error ?? send.error) : null}</ErrorBox></View>
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 16, gap: 12 }} onContentSizeChange={() => { if (scrollBottom) scroll.current?.scrollToEnd({ animated: false }); }}>
{messages.hasNextPage && <Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={() => { setScrollBottom(false); void messages.fetchNextPage(); }} />}
{messages.isPending && <Muted>{t('social.loading')}</Muted>}
{messages.isSuccess && !list.length && <Muted>{t('social.first_message')}</Muted>}
{list.map(m => <View key={m.id} style={{ alignSelf: m.sender_id === user.id ? 'flex-end' : 'flex-start', maxWidth: '88%', padding: 14, borderRadius: 16, backgroundColor: m.sender_id === user.id ? colors['accent-soft'] : colors.surface2, gap: 8 }}>
<Text style={{ color: colors.text, fontSize: 15 }}>{m.text}</Text>
{attachmentPath(m) && <Button title={t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} variant="secondary" onPress={() => {
if (!server || !m.attachment_id) return;
if (m.attachment_kind === 'track') router.push({ pathname: '/social-track', params: { id: m.attachment_id } });
else router.push({ pathname: '/social-playlist', params: { id: m.attachment_id } });
}} />}
<Muted>{new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{m.sender_id === user.id ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</Muted>
</View>)}
</ScrollView>
<View style={{ padding: 16, gap: 12, borderTopWidth: 1, borderTopColor: colors.line }}>
<Field label={t('social.message')} value={text} onChangeText={setText} maxLength={4000} multiline editable={!send.isPending} />
<Button title={t('social.send')} disabled={!text.trim()} busy={send.isPending} onPress={() => send.mutate()} />
</View>
</KeyboardAvoidingView>;
function Chat({ person, active: visible = true }: { person: SocialPerson; active?: boolean }) {
const {api,key,user,active}=useSocial(); const {server}=useSessionCtx();
const [focused,setFocused]=useState(false);
useFocusEffect(useCallback(()=>{setFocused(true);return()=>setFocused(false);},[]));
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} server={server??''} active={visible&&active&&focused}/>;
}