ci / api / api (push) Successful in 1m11s
ci / db / db (push) Failing after 23s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m29s
ci / ts / ts (packages + web) (push) Successful in 3m15s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m27s
150 lines
13 KiB
TypeScript
150 lines
13 KiB
TypeScript
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 { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
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';
|
|
|
|
function useSocial() {
|
|
const session = useSession();
|
|
const { server } = useSessionCtx();
|
|
const user = useUser();
|
|
const api = useMemo(() => socialApi(session), [session]);
|
|
const [active, setActive] = useState(AppState.currentState === 'active');
|
|
useEffect(() => { const sub = AppState.addEventListener('change', s => setActive(s === 'active')); return () => sub.remove(); }, []);
|
|
const key = ['m-social', server, user.id] as const;
|
|
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, enabled: active, refetchInterval: active ? 15_000 : false });
|
|
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();
|
|
const cache = useQueryClient();
|
|
const errorText = useErrorText();
|
|
const [sent, setSent] = useState<string[]>([]);
|
|
const [copied, setCopied] = useState(false);
|
|
const ids = useRef(new Map<string, string>());
|
|
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 = 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 });
|
|
} });
|
|
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 24, gap: 16 }}>
|
|
<Button title={t('form.cancel')} variant="ghost" onPress={close} />
|
|
<H1>{t('player.share')}</H1><Muted>{attachment.title}</Muted>
|
|
<Button title={t(copied ? 'social.copied' : 'social.copy')} disabled={send.isPending} onPress={() => send.mutate('copy')} />
|
|
<Button title={t('social.external')} variant="secondary" disabled={send.isPending} onPress={() => send.mutate('external')} />
|
|
<H1>{t('social.send_friend')}</H1>
|
|
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
|
|
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <Muted>{t('social.no_friends_share')}</Muted>}
|
|
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} title={`${p.display_name} · ${t(sent.includes(p.id) ? 'social.sent' : 'social.send')}`} variant="secondary" disabled={send.isPending || sent.includes(p.id)} onPress={() => send.mutate(p.id)} />)}
|
|
<ErrorBox>{send.error || inbox.error ? errorText(send.error ?? inbox.error) : null}</ErrorBox>
|
|
</ScrollView></Screen></Modal>;
|
|
}
|
|
|
|
export function SocialScreen({ root = false }: { root?: boolean }) {
|
|
const dock = useContext(ChatDockContext);
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const router = useRouter();
|
|
const { api, inbox, key } = useSocial();
|
|
const cache = useQueryClient();
|
|
const errorText = useErrorText();
|
|
const [search, setSearch] = useState('');
|
|
const [q, setQ] = useState('');
|
|
const [peer, setPeer] = useState('');
|
|
const [notifications, setNotifications] = useState(false);
|
|
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)} />
|
|
</View>
|
|
<ErrorBox>{inbox.error || results.error || act.error ? errorText(inbox.error ?? results.error ?? act.error) : null}</ErrorBox>
|
|
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
|
|
{notifications ? <>
|
|
{inbox.data?.notifications.length === 0 && <Muted>{t('social.no_notifications')}</Muted>}
|
|
{inbox.data?.notifications.map(n => <View key={n.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10, borderWidth: 1, borderColor: n.read ? colors.line : colors.accent }}>
|
|
<Text style={{ color: colors.text, fontWeight: '600' }}>{n.actor_name}</Text><Muted>{t(`social.kind_${n.kind}`)}</Muted><Muted>{new Date(n.created_at).toLocaleString()}</Muted>
|
|
<Button title={t('social.open')} variant="secondary" onPress={() => { setNotifications(false); setSearch(''); setQ(''); setPeer(n.actor_id); }} />
|
|
{!n.read && <Button title={t('social.mark_read')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'read_notification', target_id: n.id })} />}
|
|
</View>)}
|
|
</> : <>
|
|
<Field label={t('social.search')} value={search} onChangeText={setSearch} maxLength={80} />
|
|
{q.length >= 2 && results.isFetching && <Muted>{t('social.loading')}</Muted>}
|
|
{!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' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t(p.online ? 'social.online' : 'social.offline')}</Text>}
|
|
{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 })} />}
|
|
<Button title={t(p.relationship === 'blocked' ? 'social.unblock' : 'social.block')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: p.relationship === 'blocked' ? 'unblock' : 'block', target_id: p.id })} />
|
|
</View>)}
|
|
</>}
|
|
</ScrollView></Screen>;
|
|
}
|
|
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}/>;
|
|
}
|