feat(social): add friends, private chats, inbox and music sharing
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 2m29s
ci / ts / ts (packages + web) (push) Successful in 2m55s

This commit is contained in:
mrleo1nid committed 2026-10-23 03:19:29 +03:00
1 parent f1528de948
commit 0a05bfe2cd
43 files changed
+2827 -388

No files matched your search

+1 -1
View File
@@ -71,7 +71,7 @@ jobs:
done
- name: Go — миграции и запросы store
working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/dbtest/
run: go test -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
working-directory: services/api
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
+139
View File
@@ -22,6 +22,88 @@ tags:
security:
- bearerAuth: []
paths:
/api/v1/me/social:
get:
operationId: getSocial
tags: [me]
description: Friends, incoming/outgoing requests, own blocks and up to 100 notifications, unread first. No private account fields.
responses:
'200':
description: Social inbox
content:
application/json:
schema: {$ref: '#/components/schemas/SocialInbox'}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/people:
get:
operationId: searchPeople
tags: [me]
parameters:
- in: query
name: q
required: true
schema: {type: string, minLength: 2, maxLength: 80}
responses:
'200':
description: Up to 30 people, excluding blocked accounts and either direction of personal blocking
content:
application/json:
schema:
type: array
items: {$ref: '#/components/schemas/SocialPerson'}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/actions:
post:
operationId: socialAction
tags: [me]
description: Request is idempotent; accept is recipient-only. Remove cancels, declines or unfriends. Block removes friendship. Unblock never restores it. Read chat acknowledges only messages through up_to_seq. Read notification uses notification ID as target_id.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/SocialAction'}
responses:
'204': {description: Applied}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/chats/{peerId}:
parameters:
- in: path
name: peerId
required: true
schema: {type: string, format: uuid}
get:
operationId: getDirectMessages
tags: [me]
description: Last 50 messages in ascending sequence order. Before loads older history. Requires current friendship, never marks messages read.
parameters:
- in: query
name: before
schema: {type: integer, format: int64, minimum: 1}
responses:
'200':
description: Message page
content:
application/json:
schema:
type: array
items: {$ref: '#/components/schemas/DirectMessage'}
default: {$ref: '#/components/responses/Problem'}
post:
operationId: sendDirectMessage
tags: [me]
description: Friends only. Client-generated UUID makes retries idempotent; changed payload for the same ID returns 409. Playlist attachment is its active share UUID; track attachment is a catalog UUID. No arbitrary URLs.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/SendDirectMessage'}
responses:
'200':
description: Stored message
content:
application/json:
schema: {$ref: '#/components/schemas/DirectMessage'}
default: {$ref: '#/components/responses/Problem'}
/api/v1/admin/server/updates:
get:
operationId: getServerUpdates
@@ -1994,6 +2076,63 @@ components:
schema:
$ref: "#/components/schemas/Problem"
schemas:
SocialPerson:
type: object
required: [id, username, display_name, relationship, unread]
properties:
id: {type: string, format: uuid}
username: {type: string}
display_name: {type: string}
relationship: {type: string, enum: [none, incoming, outgoing, friend, blocked]}
unread: {type: integer}
SocialNotification:
type: object
required: [id, actor_id, actor_name, kind, created_at, read]
properties:
id: {type: string, format: uuid}
actor_id: {type: string, format: uuid}
actor_name: {type: string}
kind: {type: string, enum: [friend_request, friend_accepted, message, music]}
created_at: {type: string, format: date-time}
read: {type: boolean}
SocialInbox:
type: object
required: [people, notifications, unread]
properties:
people:
type: array
items: {$ref: '#/components/schemas/SocialPerson'}
notifications:
type: array
items: {$ref: '#/components/schemas/SocialNotification'}
unread: {type: integer}
SocialAction:
type: object
required: [action, target_id]
properties:
action: {type: string, enum: [request, accept, remove, block, unblock, read_chat, read_notification]}
target_id: {type: string, format: uuid}
up_to_seq: {type: integer, format: int64, minimum: 1}
SendDirectMessage:
type: object
required: [id, text, attachment_kind]
properties:
id: {type: string, format: uuid}
text: {type: string, maxLength: 4000}
attachment_kind: {type: string, enum: [none, track, playlist]}
attachment_id: {type: string, format: uuid}
DirectMessage:
type: object
required: [id, seq, sender_id, text, attachment_kind, created_at, read]
properties:
id: {type: string, format: uuid}
seq: {type: integer, format: int64}
sender_id: {type: string, format: uuid}
text: {type: string}
attachment_kind: {type: string, enum: [none, track, playlist]}
attachment_id: {type: string, format: uuid}
created_at: {type: string, format: date-time}
read: {type: boolean}
ServerSource:
type: object
required: [id, name, url]
+12 -2
View File
@@ -8,18 +8,21 @@ import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme';
import { Cover, Icon, type IconName } from '../../ui/kit';
import { useSocialUnread } from '../../features/social/Social';
import { UpdateDialog } from '../../ui/UpdateDialog';
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' };
export default function TabsLayout() {
const colors = useColors();
const { t } = useTranslation();
const unread = useSocialUnread();
const labels: Record<string, string> = {
index: t('nav.home'),
search: t('mobile.search'),
collection: t('nav.collection'),
profile: t('mobile.profile'),
friends: t('social.people'),
};
const net = useNetInfo();
return (
@@ -34,6 +37,7 @@ export default function TabsLayout() {
routes={state.routes.map((r) => r.name)}
active={state.index}
labels={labels}
badges={{ friends: unread }}
onPress={(name) => navigation.navigate(name)}
/>
</View>
@@ -42,6 +46,7 @@ export default function TabsLayout() {
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<Tabs.Screen name="collection" />
<Tabs.Screen name="friends" />
<Tabs.Screen name="profile" />
</Tabs>
<UpdateDialog />
@@ -53,11 +58,13 @@ function TabBar({
routes,
active,
labels,
badges,
onPress,
}: {
routes: string[];
active: number;
labels: Record<string, string>;
badges: Record<string, number>;
onPress: (name: string) => void;
}) {
const colors = useColors();
@@ -81,10 +88,13 @@ function TabBar({
onPress={() => onPress(name)}
accessibilityRole="tab"
accessibilityState={{ selected: on }}
accessibilityLabel={labels[name]}
accessibilityLabel={`${labels[name]}${badges[name] ? ` · ${badges[name]}` : ''}`}
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
>
<View>
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
{!!badges[name] && <Text style={{ position: 'absolute', top: -5, left: 14, minWidth: 16, paddingHorizontal: 4, borderRadius: 9, backgroundColor: colors['accent-fill'], color: '#fff', fontSize: 10, textAlign: 'center' }}>{badges[name]! > 99 ? '99+' : badges[name]}</Text>}
</View>
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
{labels[name]}
</Text>
+2
View File
@@ -0,0 +1,2 @@
import { SocialScreen } from '../../features/social/Social';
export default function FriendsTab() { return <SocialScreen root />; }
+3
View File
@@ -110,6 +110,9 @@ function Screens() {
<Stack.Screen name="album/[id]" />
<Stack.Screen name="artist/[id]" />
<Stack.Screen name="playlists" />
<Stack.Screen name="social" />
<Stack.Screen name="social-track" />
<Stack.Screen name="social-playlist" />
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack.Protected>
</Stack>
+4 -12
View File
@@ -1,7 +1,8 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { MusicShareSheet } from '../features/social/Social';
import { trackShareUrl } from '@pipu/core';
import { useSessionCtx, useUser } from '../lib/session';
@@ -39,20 +40,11 @@ export default function PlayerScreen() {
const duration = st.durationMs || track.duration_ms;
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = async () => {
if (!server || sharing) return;
setSharing(true);
try {
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
} catch {
Alert.alert(t('player.share'), t('player.share_error'));
} finally {
setSharing(false);
}
};
const share = () => setSharing(true);
return (
<Screen>
{sharing && server && <MusicShareSheet attachment={{ kind: 'track', id: track.id, title: track.title }} url={trackShareUrl(server, track.id)} close={() => setSharing(false)} />}
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
+5 -3
View File
@@ -1,10 +1,11 @@
import type { components } from '@pipu/api-client';
import { MusicShareSheet } from '../features/social/Social';
import { playlistShareUrl, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, FlatList, Modal, ScrollView, Share, View } from 'react-native';
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller';
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
@@ -77,6 +78,7 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr
}
function Detail({ id }: { id: string }) {
const [shareToken, setShareToken] = useState<string | null>(null);
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
@@ -94,7 +96,7 @@ function Detail({ id }: { id: string }) {
if (!server || !p) throw new Error('Server unavailable');
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
await Share.share({ title: p.name, message: playlistShareUrl(server, token) }, { dialogTitle: t('playlists.share') });
setShareToken(token);
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
@@ -112,7 +114,7 @@ function Detail({ id }: { id: string }) {
const move = (index: number, delta: number) => {
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
};
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
return <Screen>{shareToken && server && p && <MusicShareSheet attachment={{ kind: 'playlist', id: shareToken, title: p.name }} url={playlistShareUrl(server, shareToken)} close={() => setShareToken(null)} />}<FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
<ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
+2
View File
@@ -0,0 +1,2 @@
import { SharedMusic } from '../features/social/SharedMusic';
export default function SharedPlaylistScreen() { return <SharedMusic kind="playlist" />; }
+2
View File
@@ -0,0 +1,2 @@
import { SharedMusic } from '../features/social/SharedMusic';
export default function SharedTrackScreen() { return <SharedMusic kind="track" />; }
+1
View File
@@ -0,0 +1 @@
export { SocialScreen as default } from '../features/social/Social';
@@ -0,0 +1,36 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { Button, ErrorBox, H1, Muted, Screen, TrackRow, useErrorText } from '../../ui/kit';
export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
const { id } = useLocalSearchParams<{ id: string }>();
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const errorText = useErrorText();
const [playError, setPlayError] = useState<unknown>();
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id], queryFn: async () => {
if (kind === 'track') {
const track = unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } }));
return { name: track.title, tracks: [track] };
}
return unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }));
} });
return <Screen><FlatList data={query.data?.tracks ?? []} keyExtractor={x => x.id}
ListHeaderComponent={<View style={{ padding: 20, gap: 16 }}>
<Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />
<H1>{query.data?.name ?? t(kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')}</H1>
{query.isPending && <Muted>{t('social.loading')}</Muted>}
<ErrorBox>{query.error || playError ? errorText(query.error ?? playError) : null}</ErrorBox>
</View>}
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(query.data?.tracks ?? [], index, { type: 'collection', id, label: query.data?.name }).catch(setPlayError)} />}
/></Screen>;
}
+147
View File
@@ -0,0 +1,147 @@
import { attachmentPath, socialApi, uuidv7, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AppState, Clipboard, KeyboardAvoidingView, Modal, 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; }
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 = uuidv7(); 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 { 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 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={{ 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' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => setPeer(p.id)} />}
{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 }: { 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>;
}
+6 -2
View File
@@ -32,7 +32,9 @@ for (const width of [320, 1280]) {
await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
const shared = new URL(sharedUrl);
expect(shared.origin).toBe(new URL(page.url()).origin);
@@ -47,7 +49,9 @@ for (const width of [320, 1280]) {
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
});
+48
View File
@@ -0,0 +1,48 @@
import { expect, test } from '@playwright/test';
for (const width of [320, 800, 1280]) {
test(`friends, messages and sharing at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
if (width === 800) await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { sessionStorage.setItem('shared-url', data.url ?? ''); } }));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
const openFriends = async () => {
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
};
await openFriends();
if (width === 800) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
await page.getByRole('button', { name: 'Написать', exact: true }).click();
if (width === 800) expect((await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!.width).toBeGreaterThan(180);
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill('Привет! Нашёл музыку для нашей поездки 🎵');
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(page.getByText('Привет! Нашёл музыку для нашей поездки 🎵', { exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeEmpty();
await page.screenshot({ path: info.outputPath('conversation.png'), fullPage: true });
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('listitem').first().click();
await page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('button', { name: 'Поделиться треком', exact: true }).click();
await expect(page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true })).toBeVisible();
if (width === 800) {
await page.getByRole('dialog').getByRole('button', { name: 'Отправить в приложение', exact: true }).click();
await expect.poll(() => page.evaluate(() => sessionStorage.getItem('shared-url'))).toContain('/track/');
}
await page.getByRole('dialog').getByRole('button', { name: 'moderator · Отправить', exact: true }).click();
await expect(page.getByRole('button', { name: 'moderator · Отправлено', exact: true })).toBeDisabled();
await page.screenshot({ path: info.outputPath('share-music.png') });
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
await openFriends();
await page.getByRole('button', { name: 'Написать', exact: true }).click();
await expect(page.getByRole('link', { name: 'Открыть трек ↗', exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
await page.getByRole('link', { name: 'Открыть трек ↗', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Группа крови', exact: true })).toBeVisible();
});
}
+49 -3
View File
@@ -14,6 +14,49 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup);
describe('social', () => {
it('shows persistent notifications and acknowledges only the chosen event', async () => {
const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
await screen.findByRole('heading', { name: 'Друзья и общение' });
api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Уведомления 1' }));
await screen.findByText('Хочет добавить вас в друзья');
await user.click(screen.getByRole('button', { name: 'Прочитано' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument());
expect(api.notifications[0]?.read).toBe(true);
expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id });
});
it('searches, requests, cancels, accepts and exchanges messages and music', async () => {
const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user', { display_name: 'Борис' });
const { api, router, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
await user.type(await screen.findByLabelText('Найти по имени или логину'), 'bob');
await user.click(await screen.findByRole('button', { name: 'В друзья' }));
await screen.findByRole('button', { name: 'Отменить заявку' });
await user.click(screen.getByRole('button', { name: 'Отменить заявку' }));
await screen.findByRole('button', { name: 'В друзья' });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Принять' }));
await user.click(await screen.findByRole('button', { name: 'Написать' }));
await user.type(await screen.findByLabelText('Сообщение', { exact: true }), 'Привет, послушай это');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
expect(await screen.findByText('Привет, послушай это')).toBeInTheDocument();
expect(api.messages).toHaveLength(1);
await router.navigate({ to: '/track/$id', params: { id: albumTracks[0]!.id } });
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(player).getByRole('button', { name: 'Поделиться треком' }));
await user.click(await screen.findByRole('button', { name: 'Борис · Отправить' }));
await screen.findByRole('button', { name: 'Борис · Отправлено' });
expect(api.messages.at(-1)).toMatchObject({ attachment_kind: 'track', attachment_id: albumTracks[0]!.id, recipient: bob.id });
expect(screen.getByRole('button', { name: 'Борис · Отправлено' })).toBeDisabled();
});
});
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
const api = fakeApi(users);
const i18n = await createI18n('ru');
@@ -27,7 +70,7 @@ async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: Fak
<App session={session} router={router} queryClient={queryClient} />
</I18nextProvider>,
);
return { api, router, session, i18n, user: userEvent.setup() };
return { api, router, session, i18n, queryClient, user: userEvent.setup() };
}
describe('sign-in', () => {
@@ -294,7 +337,8 @@ describe('player', () => {
await user.click(screen.getByRole('button', { name: 'Слушать' }));
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
await screen.findByRole('button', { name: 'Ссылка скопирована' });
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
});
@@ -517,7 +561,9 @@ describe('playlist sharing', () => {
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await screen.findByRole('heading', { name: 'Общий микс' });
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
expect(await screen.findByRole('status')).toHaveTextContent('Ссылка на плейлист скопирована');
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
await screen.findByRole('button', { name: 'Ссылка скопирована' });
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Закрыть' }));
const shareId = api.playlists.get(playlist.id)!.share_id!;
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
+3
View File
@@ -36,6 +36,7 @@ import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError';
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
import { SocialPage } from '../features/social/Social';
export interface RouterContext {
session: AuthSession;
@@ -147,6 +148,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
};
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string } => typeof s.peer === 'string' ? { peer: s.peer } : {}, component: () => <SocialPage peer={socialRoute.useSearch().peer} /> });
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
@@ -293,6 +295,7 @@ const routeTree = rootRoute.addChildren([
setupPasswordRoute,
appRoute.addChildren([
homeRoute,
socialRoute,
trackRoute,
playlistsRoute,
downloadsRoute,
+165
View File
@@ -0,0 +1,165 @@
import { attachmentPath, socialApi, uuidv7, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useDesktop } from '../../desktop';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import s from './social.module.css';
const scopes = new WeakMap<object, number>();
let nextScope = 0;
function useSocial() {
const session = useSession();
const user = useUser();
const server = useDesktop()?.server ?? '';
const api = useMemo(() => socialApi(session), [session]);
if (!scopes.has(session)) scopes.set(session, ++nextScope);
const key = ['social', server, scopes.get(session), user.id] as const;
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, staleTime: 5000, refetchInterval: 15_000, refetchOnWindowFocus: true });
return { api, key, inbox, user };
}
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
type ShareValue = { attachment: MusicAttachment; url: string };
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
export const useMusicShare = () => useContext(ShareContext);
export function SocialProvider({ children }: { children: ReactNode }) {
const [share, setShare] = useState<ShareValue | null>(null);
return <ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider>;
}
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
const { t } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [done, setDone] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const ids = useRef(new Map<string, string>());
const action = useMutation({ mutationFn: async (peer: string) => {
if (peer === 'copy') { await navigator.clipboard.writeText(value.url); setCopied(true); return; }
if (peer === 'external') {
try { await navigator.share({ title: value.attachment.title, url: value.url }); }
catch (error) { if (!(error instanceof DOMException && error.name === 'AbortError')) throw error; }
return;
}
let id = ids.current.get(peer); if (!id) { id = uuidv7(); ids.current.set(peer, id); }
await api.send(peer, { id, text: value.attachment.title, attachment_kind: value.attachment.kind, attachment_id: value.attachment.id });
setDone(old => [...old, peer]);
await cache.invalidateQueries({ queryKey: key });
} });
return <Dialog title={t('player.share')} onClose={close}>
<div className={s.stack}><strong>{value.attachment.title}</strong>
<Button busy={action.isPending && action.variables === 'copy'} onClick={() => action.mutate('copy')}>{t(copied ? 'social.copied' : 'social.copy')}</Button>
{!!navigator.share && <Button busy={action.isPending && action.variables === 'external'} onClick={() => action.mutate('external')}>{t('social.external')}</Button>}
<h3>{t('social.send_friend')}</h3>
{inbox.isPending && <p>{t('social.loading')}</p>}
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <p>{t('social.no_friends_share')}</p>}
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} disabled={done.includes(p.id) || action.isPending} onClick={() => action.mutate(p.id)}>{p.display_name} · {t(done.includes(p.id) ? 'social.sent' : 'social.send')}</Button>)}
<FormError>{action.error || inbox.error ? errorText(action.error ?? inbox.error) : null}</FormError>
</div>
</Dialog>;
}
export function SocialPage({ peer = '' }: { peer?: string }) {
const { t } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const navigate = useNavigate();
const errorText = useErrorText();
const [tab, setTab] = useState<'people' | 'notifications'>('people');
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
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 action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
const open = (id: string) => { setTab('people'); setSearch(''); setQ(''); void navigate({ to: '/friends', search: { peer: id } }); };
return <div className={s.page}>
<header><h1>{t('social.title')}</h1><p className={s.muted}>{t('social.subtitle')}</p></header>
<nav className={s.tabs} aria-label={t('social.title')}>
<Button variant={tab === 'people' ? 'primary' : 'ghost'} onClick={() => setTab('people')}>{t('social.people')}</Button>
<Button variant={tab === 'notifications' ? 'primary' : 'ghost'} onClick={() => setTab('notifications')}>{t('social.notifications')} {inbox.data?.unread || ''}</Button>
</nav>
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
{inbox.isPending && <p role="status">{t('social.loading')}</p>}
{tab === 'notifications' ? <div className={s.stack}>
{inbox.data?.notifications.length === 0 && <p className={s.empty}>{t('social.no_notifications')}</p>}
{inbox.data?.notifications.map(n => <article key={n.id} className={`${s.person} ${!n.read ? s.unread : ''}`}>
<div><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString()}</time></div>
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
</article>)}
</div> : <div className={s.layout}>
<aside className={s.stack}>
<Field label={t('social.search')} value={search} onChange={setSearch} type="search" maxLength={80} />
{q.length >= 2 && results.isFetching && <p role="status">{t('social.loading')}</p>}
{!people.length && inbox.data && !results.isFetching && <p className={s.empty}>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</p>}
{people.map(p => <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={a => action.mutate({ action: a, target_id: p.id })} />)}
</aside>
{selected ? <Chat key={selected.id} person={selected} /> : <div className={s.empty}>{t('social.choose_chat')}</div>}
</div>}
</div>;
}
function Person({ person: p, busy, open, act, selected }: { person: SocialPerson; busy: boolean; selected: boolean; open: () => void; act: (a: SocialAction['action']) => void }) {
const { t } = useTranslation();
return <article className={`${s.person} ${selected ? s.selected : ''}`}>
<div className={s.identity}><span className={s.avatar} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><div><strong>{p.display_name}</strong><div className={s.muted}>@{p.username} · {t(`social.rel_${p.relationship}`)}</div></div></div>
<div className={s.actions}>
{p.relationship === 'friend' && <Button onClick={open}>{t('social.chat')}{p.unread ? ` · ${p.unread}` : ''}</Button>}
{p.relationship === 'none' && <Button disabled={busy} onClick={() => act('request')}>{t('social.add')}</Button>}
{p.relationship === 'incoming' && <Button disabled={busy} variant="primary" onClick={() => act('accept')}>{t('social.accept')}</Button>}
{['incoming', 'outgoing', 'friend'].includes(p.relationship) && <Button disabled={busy} variant="ghost" onClick={() => act('remove')}>{t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')}</Button>}
<Button disabled={busy} variant="ghost" onClick={() => act(p.relationship === 'blocked' ? 'unblock' : 'block')}>{t(p.relationship === 'blocked' ? 'social.unblock' : 'social.block')}</Button>
</div>
</article>;
}
function Chat({ person }: { person: SocialPerson }) {
const { t } = useTranslation();
const { api, key, user } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [text, setText] = useState('');
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, refetchInterval: 8_000 });
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);
const [visible, setVisible] = useState(document.visibilityState === 'visible');
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
useEffect(() => {
if (!last || !unread || !visible) return;
let cancelled = false;
void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => { if (!cancelled) void cache.invalidateQueries({ queryKey: key }); }).catch(() => undefined);
return () => { cancelled = true; };
}, [last, unread, visible, api, person.id, cache, user.id, messages.dataUpdatedAt]);
const end = useRef<HTMLDivElement>(null);
useEffect(() => { end.current?.scrollIntoView?.({ block: 'nearest' }); }, [last]);
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 }); } });
return <section className={s.chat} aria-label={t('social.chat')}>
<header><strong>{person.display_name}</strong><p className={s.muted}>{t('social.chat_hint')}</p></header>
<FormError>{messages.error || send.error ? errorText(messages.error ?? send.error) : null}</FormError>
<div className={s.messages}>
{messages.hasNextPage && <Button busy={messages.isFetchingNextPage} onClick={() => void messages.fetchNextPage()}>{t('social.older')}</Button>}
{messages.isPending && <p>{t('social.loading')}</p>}
{!list.length && messages.isSuccess && <p className={s.empty}>{t('social.first_message')}</p>}
{list.map(m => <article key={m.id} className={`${s.bubble} ${m.sender_id === user.id ? s.mine : ''}`}>
<div>{m.text}</div>
{attachmentPath(m) && <Link className={s.music} to={m.attachment_kind === 'track' ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id: m.attachment_id!, shareId: m.attachment_id! }}>{t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} ↗</Link>}
<small className={s.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')}` : ''}</small>
</article>)}<div ref={end} />
</div>
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
<textarea aria-label={t('social.message')} placeholder={t('social.message')} maxLength={4000} rows={2} value={text} disabled={send.isPending} onChange={e => setText(e.target.value)} />
<Button type="submit" variant="primary" busy={send.isPending} disabled={!text.trim()}>{t('social.send')}</Button>
</form>
</section>;
}
@@ -0,0 +1,22 @@
.page { max-width: 1200px; padding: 32px; margin: auto; display: grid; gap: 24px; }
.page h1 { margin: 0; font-size: 28px; }
.muted { color: var(--text3); font-size: 13px; }
.tabs, .actions { display: flex; flex-wrap: wrap; gap: 8px; }
.layout { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(280px, 1fr); gap: 24px; align-items: start; }
.stack { display: grid; gap: 12px; }
.person { padding: 16px; border: 1px solid var(--line); background: var(--surface); border-radius: 16px; display: grid; gap: 16px; overflow-wrap: anywhere; }
.identity { display: flex; gap: 12px; align-items: center; }
.avatar { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; color: var(--accent2); background: var(--accent-soft); }
.selected, .unread { border-color: var(--accent); }
.empty { color: var(--text3); padding: 36px 16px; text-align: center; line-height: 1.7; }
.chat { border: 1px solid var(--line); border-radius: 20px; background: var(--surface); overflow: hidden; min-width: 0; }
.chat header { padding: 20px; border-bottom: 1px solid var(--line); }
.chat header p { margin-bottom: 0; }
.messages { display: flex; flex-direction: column; gap: 12px; padding: 20px; max-height: 55vh; min-height: 220px; overflow: auto; }
.bubble { display: grid; gap: 8px; padding: 12px 16px; border-radius: 16px 16px 16px 4px; background: var(--surface2); max-width: 85%; align-self: start; white-space: pre-wrap; overflow-wrap: anywhere; }
.mine { align-self: end; background: var(--accent-soft); border-radius: 16px 16px 4px 16px; }
.music { color: var(--accent2); padding: 10px; border: 1px solid var(--line2); border-radius: 10px; text-decoration: none; }
.composer { display: flex; gap: 12px; padding: 16px; border-top: 1px solid var(--line); align-items: end; }
.composer textarea { resize: vertical; min-width: 0; flex: 1; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--text); padding: 12px; font: inherit; }
@media(max-width: 760px) { .layout { grid-template-columns: 1fr; } .page { padding: 20px 16px; } .composer { flex-wrap: wrap; } }
@media(min-width: 761px) and (max-width: 1023px) { .composer { flex-wrap: wrap; justify-content: end; } .composer textarea { flex-basis: 100%; } }
+6 -7
View File
@@ -9,7 +9,9 @@ import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist
import type { components } from '@pipu/api-client';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { sharePlaylist } from '../shared/lib/share';
import { playlistShareUrl } from '@pipu/core';
import { desktopHandle } from '../desktop';
import { useMusicShare } from '../features/social/Social';
import { formatDuration } from '../shared/lib/format';
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } from '../shared/ui/icons';
@@ -56,6 +58,7 @@ export function PlaylistPage({ id, shared = false }: { id: string; shared?: bool
}
function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
const openShare = useMusicShare();
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
@@ -65,20 +68,17 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
const [edit, setEdit] = useState(false);
const [remove, setRemove] = useState(false);
const [manage, setManage] = useState(false);
const [copied, setCopied] = useState(false);
const sharing = useMutation({ mutationFn: async () => {
setCopied(false);
let token = shareId ?? playlist.share_id;
if (!readOnly) {
token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id;
cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current);
}
if (!token) throw new Error('Share link unavailable');
const result = await sharePlaylist(token, playlist.name);
if (result === 'copied') setCopied(true);
openShare({ attachment: { kind: 'playlist', id: token, title: playlist.name }, url: playlistShareUrl(desktopHandle()?.server ?? window.location.origin, token) });
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })),
onSuccess: () => { setCopied(false); cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
onSuccess: () => { cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
@@ -117,7 +117,6 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
</>}
</div>
{!readOnly && <p>{t('playlists.share_hint')}</p>}
{copied && <p role="status">{t('playlists.link_copied')}</p>}
<FormError>{sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null}</FormError>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
{!playlist.tracks.length && (
+4
View File
@@ -10,6 +10,10 @@ export function installE2EFetch() {
makeUser('mobile-admin', 'admin', { reviews_uploads: true }),
makeUser('moderator', 'moderator', { reviews_uploads: true }),
]);
// A ready conversation lets browser tests exercise messages and music sharing.
const alice = [...api.users.values()].find(u => u.username === 'alice')!;
const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
const orig = window.fetch.bind(window);
window.fetch = async (input, init) => {
const req = new Request(input, init);
+47 -1
View File
@@ -78,6 +78,21 @@ export function fakeApi(initial: FakeUser[]) {
const reactions = new Map<string, 'like' | 'dislike'>();
const playlistOwners = new Map<string, string>();
const playlists = new Map<string, components['schemas']['Playlist']>();
const friendships = new Map<string, { sender: string; accepted: boolean }>();
const blocks = new Set<string>();
const messages: (components['schemas']['DirectMessage'] & { recipient: string })[] = [];
const notifications: (components['schemas']['SocialNotification'] & { recipient: string })[] = [];
const pair = (a: string, b: string) => [a, b].sort().join(':');
const isBlocked = (a: string, b: string) => blocks.has(`${a}:${b}`) || blocks.has(`${b}:${a}`);
const socialPerson = (me: string, p: FakeUser): components['schemas']['SocialPerson'] => {
const f = friendships.get(pair(me, p.id));
return { id: p.id, username: p.username, display_name: p.display_name ?? p.username,
relationship: blocks.has(`${me}:${p.id}`) ? 'blocked' : f?.accepted ? 'friend' : f ? f.sender === me ? 'outgoing' : 'incoming' : 'none',
unread: messages.filter(m => m.recipient === me && m.sender_id === p.id && !m.read).length };
};
const notify = (recipient: string, actor: FakeUser, kind: components['schemas']['SocialNotification']['kind']) => notifications.unshift({
id: crypto.randomUUID(), recipient, actor_id: actor.id, actor_name: actor.display_name ?? actor.username, kind, read: false, created_at: new Date().toISOString(),
});
let managementSources = [{ id: 'origin', name: 'Текущий репозиторий', url: 'https://git.example.org/pipu/music.git' }];
let managementJob: components['schemas']['ServerJob'] | null = null;
@@ -201,6 +216,37 @@ export function fakeApi(initial: FakeUser[]) {
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
if (path === '/api/v1/me/social') return json({
people: [...users.values()].filter(p => p.id !== user.id && !p.is_blocked && !blocks.has(`${p.id}:${user.id}`)).map(p => socialPerson(user.id, p)).filter(p => p.relationship !== 'none'),
notifications: notifications.filter(n => n.recipient === user.id && !isBlocked(user.id, n.actor_id)),
unread: notifications.filter(n => n.recipient === user.id && !n.read && !isBlocked(user.id, n.actor_id)).length,
});
if (path === '/api/v1/me/social/people') {
const q = url.searchParams.get('q')?.toLowerCase() ?? '';
return json([...users.values()].filter(p => p.id !== user.id && !p.is_blocked && !isBlocked(user.id,p.id) && `${p.username} ${p.display_name ?? ''}`.toLowerCase().includes(q)).map(p => socialPerson(user.id,p)));
}
if (path === '/api/v1/me/social/actions' && body) {
const peer = String(body.target_id); const action = body.action; const p = users.get(peer); const f = friendships.get(pair(user.id,peer));
if (action === 'request' && p && !isBlocked(user.id,peer) && !f) { friendships.set(pair(user.id,peer),{ sender:user.id,accepted:false }); notify(peer,user,'friend_request'); }
if (action === 'accept' && f && f.sender !== user.id && !f.accepted) { f.accepted=true; notify(peer,user,'friend_accepted'); }
if (action === 'remove' || action === 'block') friendships.delete(pair(user.id,peer));
if (action === 'block') blocks.add(`${user.id}:${peer}`);
if (action === 'unblock') blocks.delete(`${user.id}:${peer}`);
if (action === 'read_notification') notifications.filter(n => n.recipient === user.id && n.id === peer).forEach(n => { n.read=true; });
if (action === 'read_chat') { messages.filter(m => m.recipient === user.id && m.sender_id === peer && m.seq <= Number(body.up_to_seq)).forEach(m => { m.read=true; }); notifications.filter(n => n.recipient === user.id && n.actor_id === peer).forEach(n => { n.read=true; }); }
return new Response(null,{status:204});
}
const chatPath = /^\/api\/v1\/me\/social\/chats\/([^/]+)$/.exec(path);
if (chatPath) {
const peer=chatPath[1]!;if (!friendships.get(pair(user.id,peer))?.accepted || isBlocked(user.id,peer)) return problem(403,'social.unavailable');
if (req.method === 'GET') return json(messages.filter(m => pair(m.sender_id,m.recipient)===pair(user.id,peer) && (!url.searchParams.has('before') || m.seq<Number(url.searchParams.get('before')))).slice(-50));
if (req.method === 'POST' && body) {
const old=messages.find(m => m.id===body.id);if(old)return json(old);
const message={...body,recipient:peer,sender_id:user.id,seq:messages.length+1,created_at:new Date().toISOString(),read:false} as typeof messages[number];
messages.push(message);notify(peer,user,body.attachment_kind==='none'?'message':'music');return json(message);
}
}
const sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path);
if (sharedPath && req.method === 'GET') {
@@ -396,5 +442,5 @@ export function fakeApi(initial: FakeUser[]) {
return problem(404, 'common.not_found');
};
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, signOut: () => (current = null) };
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, friendships, notifications, messages, signOut: () => (current = null) };
}
+6 -2
View File
@@ -38,8 +38,10 @@ import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox';
import s from './AppShell.module.css';
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
import { useSocialUnread, SocialProvider } from '../features/social/Social';
type NavPath =
| '/friends'
| '/'
| '/admin/users'
| '/admin/audit'
@@ -150,16 +152,17 @@ function NavSection({
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
export function AppShell() {
return useAuthState().status === 'signed_in' ? (
<PlayerProvider>
<SocialProvider><PlayerProvider>
<DesktopUpdateDialog />
<Shell />
</PlayerProvider>
</PlayerProvider></SocialProvider>
) : null;
}
function Shell() {
const { t } = useTranslation();
const user = useUser();
const socialUnread = useSocialUnread();
const session = useSession();
const desktop = useDesktop();
const navigate = useNavigate();
@@ -177,6 +180,7 @@ function Shell() {
const navigation = (mobile = false) => (
<>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavLink to="/friends" label={t('social.title')} icon={groupIcon} activeIcon={groupFillIcon} badge={socialUnread} />
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
{desktop && <NavLink to="/collection/downloads" label={t('desktop.offline')} icon={downloadIcon} activeIcon={downloadIcon} />}
+9 -29
View File
@@ -7,7 +7,9 @@ import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { shareTrack } from '../shared/lib/share';
import { trackShareUrl } from '@pipu/core';
import { desktopHandle } from '../desktop';
import { useMusicShare } from '../features/social/Social';
import {
albumIcon,
closeIcon,
@@ -36,20 +38,11 @@ import s from './PlayerBar.module.css';
export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const { t } = useTranslation();
const player = usePlayer();
const openShare = useMusicShare();
const st = usePlayerState();
const narrow = useNarrow();
const [sheetOpen, setSheetOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
const [sharing, setSharing] = useState(false);
useEffect(() => {
setShareStatus(null);
}, [st.track?.id]);
useEffect(() => {
if (!shareStatus) return;
const timer = setTimeout(() => setShareStatus(null), 4000);
return () => clearTimeout(timer);
}, [shareStatus]);
const queueButton = useRef<HTMLButtonElement>(null);
const queueId = useId();
const closeQueue = useCallback(() => {
@@ -72,17 +65,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
}
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = async () => {
setSharing(true);
setShareStatus(null);
try {
const result = await shareTrack(track.id, track.title);
if (result === 'copied') setShareStatus('copied');
} catch {
setShareStatus('error');
} finally {
setSharing(false);
}
const share = () => {
openShare({ attachment: { kind: 'track', id: track.id, title: track.title }, url: trackShareUrl(desktopHandle()?.server ?? window.location.origin, track.id) });
};
const repeatLabel = t(`player.repeat_${st.repeat}`);
const ctx = st.context;
@@ -198,7 +182,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<div className={s.right}>
<AddToPlaylist trackIds={[track.id]} compact className={cx(s.icon, s.playlistButton)} />
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button
@@ -255,11 +239,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
variant={variant}
onClose={() => setSheetOpen(false)}
share={share}
sharing={sharing}
/>
)}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
</footer>
);
}
@@ -281,12 +263,10 @@ function NowPlayingSheet({
variant,
onClose,
share,
sharing,
}: {
variant: 'bar' | 'island';
onClose: () => void;
share: () => Promise<void>;
sharing: boolean;
share: () => void;
}) {
const { t } = useTranslation();
const player = usePlayer();
@@ -370,7 +350,7 @@ function NowPlayingSheet({
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
</button>
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
<button type="button" className={s.icon} aria-label={t('player.share')} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>
+1
View File
@@ -17,6 +17,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
| [genius.md](genius.md) | Genius: токен, поиск метаданных, парсинг текстов и проверочная команда |
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
| [social.md](social.md) | Друзья, заявки, личные сообщения, уведомления и отправка музыки |
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
| [web.md](web.md) | Веб-клиент |
| [desktop.md](desktop.md) | Десктоп Windows / Linux / macOS (Tauri) |
+1
View File
@@ -96,6 +96,7 @@ pipu-music/
│ │ ├── internal/
│ │ │ ├── auth/ library/ search/ genius/
│ │ │ ├── playback/ wave/ uploads/ appupdate/ system/
│ │ │ ├── social/ # друзья, личные сообщения, центр уведомлений
│ │ │ └── platform/ # db, redis, config, logging, http, jobs
│ │ └── migrations/ # goose SQL-миграции; SQL-запросы — в адаптерах internal/
│ └── recommender/ # Python (uv)
+58
View File
@@ -0,0 +1,58 @@
# Друзья, сообщения и уведомления
Социальные функции работают между пользователями одного сервера. В web и desktop
открывается раздел «Друзья и общение», на Android — вкладка «Друзья».
## Поведение
- Поиск по логину и отображаемому имени начинается с двух символов, возвращает до 30 человек.
Пароли, роли, настройки и другие приватные поля в результат не попадают.
- Заявка требует явного принятия получателем. Встречная заявка не принимает дружбу автоматически.
Можно отменить свою заявку, отклонить входящую или удалить друга.
- Личные сообщения и отправка музыки доступны только текущим друзьям. Блокировка прекращает
дружбу и закрывает общение в обоих направлениях. Разблокировка дружбу не восстанавливает.
- История сохраняется после удаления друга, но доступна снова только после новой взаимной дружбы.
В первой странице чата — последние 50 сообщений; ранние подгружаются отдельно.
- В сообщении до 4000 символов. Музыкальное вложение — ID трека либо действующая ссылка
общего доступа к плейлисту. Отзыв ссылки делает вложенный плейлист недоступным.
- «Поделиться» открывает выбор: скопировать ссылку, вызвать системную отправку (когда она
поддерживается клиентом) или отправить конкретному другу одним нажатием. Отправка другу
создаёт сообщение с музыкальным вложением. Повтор после сетевого сбоя использует тот же UUID.
## Спокойные уведомления
Четыре типа: входящая заявка, принятие заявки, личное сообщение, музыка.
Центр событий и счётчики сохраняются на сервере, отметки прочтения общие для устройств.
Нет звуков, автоматических всплывающих окон и запроса разрешений ОС.
Список уведомлений возвращает до 100 событий, сначала непрочитанные. По мере подтверждения
новых становятся доступны более старые непрочитанные события. Открытый видимый чат подтверждает
сообщения только до последнего полученного номера; более поздние сообщения не теряются.
В скрытой вкладке и в фоне Android автоматического подтверждения нет.
Сейчас это уведомления **внутри работающего приложения**, с обновлением центра каждые 15 секунд
и открытого чата каждые 8 секунд. Web использует стандартную приостановку фонового polling
React Query; Android останавливает polling при уходе приложения в фон. Уведомления ОС при
закрытом клиенте (Web Push/FCM/нативный desktop) в эту реализацию не входят.
## Реализация и проверки
Контракт: `api/openapi.yaml`, операции `getSocial`, `searchPeople`, `socialAction`,
`getDirectMessages`, `sendDirectMessage`. Модуль API: `internal/social`.
Миграция `00019_social.sql` создаёт дружбу, блокировки, сообщения и уведомления.
Миграции применяются штатным запуском API.
Мутации пары сериализованы транзакционной advisory-блокировкой PostgreSQL. Отправка сообщения
и создание уведомления атомарны. UUID сообщения глобально уникален; повтор с другим содержимым
возвращает конфликт. Чужое уведомление нельзя отметить прочитанным. Поиск скрывает заблокированные
аккаунты и личные блокировки, включая обратное направление.
Ограничения: до 50 исходящих ожидающих заявок, до 10 новых заявок за минуту,
до 30 сообщений одному человеку за минуту; тело социального POST ограничено 32 KiB.
Тексты выводятся обычным текстом, вложения формируют только внутренние маршруты каталога.
Проверки: `go test ./internal/social`,
`TEST_DATABASE_URL=… go test -tags integration ./internal/social`,
`pnpm --filter @pipu/web test src/App.test.tsx -t 'social|playlist sharing'`,
`pnpm --filter @pipu/web test:e2e e2e/social.spec.ts e2e/playlists.spec.ts`.
Интеграционный сценарий включён в `ci-db`.
+248
View File
@@ -4,6 +4,76 @@
*/
export interface paths {
"/api/v1/me/social": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Friends, incoming/outgoing requests, own blocks and up to 100 notifications, unread first. No private account fields. */
get: operations["getSocial"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/social/people": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["searchPeople"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/social/actions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** @description Request is idempotent; accept is recipient-only. Remove cancels, declines or unfriends. Block removes friendship. Unblock never restores it. Read chat acknowledges only messages through up_to_seq. Read notification uses notification ID as target_id. */
post: operations["socialAction"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/social/chats/{peerId}": {
parameters: {
query?: never;
header?: never;
path: {
peerId: string;
};
cookie?: never;
};
/** @description Last 50 messages in ascending sequence order. Before loads older history. Requires current friendship, never marks messages read. */
get: operations["getDirectMessages"];
put?: never;
/** @description Friends only. Client-generated UUID makes retries idempotent; changed payload for the same ID returns 409. Playlist attachment is its active share UUID; track attachment is a catalog UUID. No arbitrary URLs. */
post: operations["sendDirectMessage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/server/updates": {
parameters: {
query?: never;
@@ -1622,6 +1692,65 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
SocialPerson: {
/** Format: uuid */
id: string;
username: string;
display_name: string;
/** @enum {string} */
relationship: "none" | "incoming" | "outgoing" | "friend" | "blocked";
unread: number;
};
SocialNotification: {
/** Format: uuid */
id: string;
/** Format: uuid */
actor_id: string;
actor_name: string;
/** @enum {string} */
kind: "friend_request" | "friend_accepted" | "message" | "music";
/** Format: date-time */
created_at: string;
read: boolean;
};
SocialInbox: {
people: components["schemas"]["SocialPerson"][];
notifications: components["schemas"]["SocialNotification"][];
unread: number;
};
SocialAction: {
/** @enum {string} */
action: "request" | "accept" | "remove" | "block" | "unblock" | "read_chat" | "read_notification";
/** Format: uuid */
target_id: string;
/** Format: int64 */
up_to_seq?: number;
};
SendDirectMessage: {
/** Format: uuid */
id: string;
text: string;
/** @enum {string} */
attachment_kind: "none" | "track" | "playlist";
/** Format: uuid */
attachment_id?: string;
};
DirectMessage: {
/** Format: uuid */
id: string;
/** Format: int64 */
seq: number;
/** Format: uuid */
sender_id: string;
text: string;
/** @enum {string} */
attachment_kind: "none" | "track" | "playlist";
/** Format: uuid */
attachment_id?: string;
/** Format: date-time */
created_at: string;
read: boolean;
};
ServerSource: {
id: string;
name: string;
@@ -2788,6 +2917,125 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
getSocial: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Social inbox */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocialInbox"];
};
};
default: components["responses"]["Problem"];
};
};
searchPeople: {
parameters: {
query: {
q: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Up to 30 people, excluding blocked accounts and either direction of personal blocking */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocialPerson"][];
};
};
default: components["responses"]["Problem"];
};
};
socialAction: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocialAction"];
};
};
responses: {
/** @description Applied */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
getDirectMessages: {
parameters: {
query?: {
before?: number;
};
header?: never;
path: {
peerId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Message page */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DirectMessage"][];
};
};
default: components["responses"]["Problem"];
};
};
sendDirectMessage: {
parameters: {
query?: never;
header?: never;
path: {
peerId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SendDirectMessage"];
};
};
responses: {
/** @description Stored message */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DirectMessage"];
};
};
default: components["responses"]["Problem"];
};
};
getServerUpdates: {
parameters: {
query?: {
+1
View File
@@ -23,6 +23,7 @@ export {
} from './player';
export { normalizeGain, TARGET_LUFS } from './gain';
export { playlistShareUrl, trackShareUrl } from './share';
export { socialApi, attachmentPath, type SocialPerson, type SocialInbox, type SocialAction, type DirectMessage, type SendDirectMessage, type MusicAttachment } from './social';
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { attachmentPath } from './social';
describe('music attachments', () => {
it('creates only internal catalog links and escapes identifiers', () => {
expect(attachmentPath({ attachment_kind: 'track', attachment_id: 'a/b?sig=secret' })).toBe('/track/a%2Fb%3Fsig%3Dsecret');
expect(attachmentPath({ attachment_kind: 'playlist', attachment_id: 'a/b' })).toBe('/shared/playlist/a%2Fb');
expect(attachmentPath({ attachment_kind: 'none' })).toBeNull();
expect(attachmentPath({ attachment_kind: 'track' })).toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
import type { components } from '@pipu/api-client';
import { type AuthSession, unwrap } from './auth';
export type SocialPerson = components['schemas']['SocialPerson'];
export type SocialInbox = components['schemas']['SocialInbox'];
export type SocialAction = components['schemas']['SocialAction'];
export type DirectMessage = components['schemas']['DirectMessage'];
export type SendDirectMessage = components['schemas']['SendDirectMessage'];
export type MusicAttachment = { kind: 'track' | 'playlist'; id: string; title: string };
/** All clients use the authenticated transport, including refresh and account isolation. */
export function socialApi(session: AuthSession) {
return {
inbox: async () => unwrap(await session.client.GET('/api/v1/me/social')),
search: async (q: string) => unwrap(await session.client.GET('/api/v1/me/social/people', { params: { query: { q } } })),
action: async (body: SocialAction) => unwrap(await session.client.POST('/api/v1/me/social/actions', { body })),
messages: async (peerId: string, before?: number) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before } } })),
send: async (peerId: string, body: SendDirectMessage) => unwrap(await session.client.POST('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId } }, body })),
};
}
export function attachmentPath(message: Pick<DirectMessage, 'attachment_kind' | 'attachment_id'>): string | null {
if (!message.attachment_id) return null;
if (message.attachment_kind === 'track') return `/track/${encodeURIComponent(message.attachment_id)}`;
if (message.attachment_kind === 'playlist') return `/shared/playlist/${encodeURIComponent(message.attachment_id)}`;
return null;
}
+49
View File
@@ -385,5 +385,54 @@
"move_up": "Move {{name}} up",
"delete": "Delete playlist",
"no_results": "No playlists found"
},
"social": {
"title": "Friends & messages",
"subtitle": "Music worth sharing. Conversations at your own pace.",
"people": "Friends",
"notifications": "Notifications",
"search": "Find by name or username",
"loading": "Loading…",
"empty": "Find someone you know and send your first request.",
"no_results": "No matches. Try another username.",
"no_notifications": "All quiet here. New events will appear here.",
"choose_chat": "Choose a friend to start a conversation.",
"open": "Open",
"mark_read": "Mark read",
"chat": "Message",
"add": "Add friend",
"accept": "Accept",
"decline": "Decline",
"cancel": "Cancel request",
"remove": "Unfriend",
"block": "Block",
"unblock": "Unblock",
"rel_none": "Not connected",
"rel_incoming": "Incoming request",
"rel_outgoing": "Request sent",
"rel_friend": "Friend",
"rel_blocked": "Blocked",
"kind_friend_request": "Sent you a friend request",
"kind_friend_accepted": "Accepted your friend request",
"kind_message": "New private message",
"kind_music": "Shared music with you",
"chat_hint": "A private conversation. No sounds or interruptions.",
"older": "Earlier messages",
"first_message": "Say hello or share music from the player.",
"listen_track": "Open track",
"listen_playlist": "Open playlist",
"read": "Read",
"sent": "Sent",
"message": "Message",
"send": "Send",
"copy": "Copy link",
"copied": "Link copied",
"external": "Share to an app",
"send_friend": "Send to a friend",
"no_friends_share": "Add friends to share music in one tap.",
"back": "Back",
"quiet": "No sounds. New events appear in the counter.",
"share_error": "Could not send. Please try again.",
"attachment_unavailable": "This music is no longer available."
}
}
+7
View File
@@ -122,5 +122,12 @@
"downgrade": "A newer build is installed. Downgrades require a manual rollback.",
"version_unknown": "Could not identify the running server commit. Update it manually.",
"target_changed": "The source changed after checking. Check for updates again."
},
"social": {
"invalid": "Check your message or action",
"unavailable": "Chats are available to friends only. The friendship may have ended or the person is unavailable",
"rate_limited": "Too many actions. Please wait a minute",
"message_conflict": "This message was already sent with different content",
"attachment_unavailable": "This track or playlist link is no longer available"
}
}
+49
View File
@@ -385,5 +385,54 @@
"move_up": "Переместить «{{name}}» выше",
"delete": "Удалить плейлист",
"no_results": "Плейлисты не найдены"
},
"social": {
"title": "Друзья и общение",
"subtitle": "Музыка, которой хочется делиться. Разговоры — в своём ритме.",
"people": "Друзья",
"notifications": "Уведомления",
"search": "Найти по имени или логину",
"loading": "Загрузка…",
"empty": "Найдите знакомых и отправьте первую заявку.",
"no_results": "Никого не нашли. Попробуйте другой логин.",
"no_notifications": "Здесь пока тихо. Новые события появятся здесь.",
"choose_chat": "Выберите друга, чтобы начать разговор.",
"open": "Открыть",
"mark_read": "Прочитано",
"chat": "Написать",
"add": "В друзья",
"accept": "Принять",
"decline": "Отклонить",
"cancel": "Отменить заявку",
"remove": "Удалить из друзей",
"block": "Заблокировать",
"unblock": "Разблокировать",
"rel_none": "Ещё не знакомы",
"rel_incoming": "Входящая заявка",
"rel_outgoing": "Заявка отправлена",
"rel_friend": "Друг",
"rel_blocked": "Заблокирован",
"kind_friend_request": "Хочет добавить вас в друзья",
"kind_friend_accepted": "Принял(а) вашу заявку",
"kind_message": "Новое личное сообщение",
"kind_music": "Поделился(ась) музыкой",
"chat_hint": "Личный разговор. Без звуков и лишних оповещений.",
"older": "Ранние сообщения",
"first_message": "Начните с приветствия или отправьте музыку из плеера.",
"listen_track": "Открыть трек",
"listen_playlist": "Открыть плейлист",
"read": "Прочитано",
"sent": "Отправлено",
"message": "Сообщение",
"send": "Отправить",
"copy": "Скопировать ссылку",
"copied": "Ссылка скопирована",
"external": "Отправить в приложение",
"send_friend": "Отправить другу",
"no_friends_share": "Добавьте друзей, чтобы отправлять музыку одним нажатием.",
"back": "Назад",
"quiet": "Без звуков. Новые события отмечаются счётчиком.",
"share_error": "Не удалось отправить. Попробуйте ещё раз.",
"attachment_unavailable": "Музыка больше недоступна."
}
}
+7
View File
@@ -122,5 +122,12 @@
"downgrade": "Установлена более новая сборка. Откат выполняется вручную.",
"version_unknown": "Не удалось определить commit работающего сервера. Обновите его вручную.",
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
},
"social": {
"invalid": "Проверьте сообщение или действие",
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
"rate_limited": "Слишком много действий. Подождите минуту",
"message_conflict": "Это сообщение уже отправлено с другим содержимым",
"attachment_unavailable": "Трек или ссылка на плейлист больше недоступны"
}
}
+2
View File
@@ -31,6 +31,7 @@ import (
"pipu/api/internal/playback"
"pipu/api/internal/search"
"pipu/api/internal/server"
"pipu/api/internal/social"
"pipu/api/internal/system"
"pipu/api/internal/uploads"
"pipu/api/internal/wave"
@@ -173,6 +174,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
Client: &http.Client{}}, libStore, dto, log)
wh.Warm = pb.Prewarm
return server.NewRouter(server.Deps{
Social: social.NewHandler(pool),
Management: management.NewHandler(management.Mailbox{Dir: cfg.ManagementDir}),
Genius: genius.NewHandler(geniusSvc),
Log: log,
File diff suppressed because it is too large. Load diff
+3
View File
@@ -122,6 +122,9 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
if route == "POST /api/v1/admin/users/import" {
r.Body = http.MaxBytesReader(w, r.Body, MaxUserArchiveBytes)
}
if r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/me/social/") {
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
})
}
+5 -1
View File
@@ -17,6 +17,7 @@ import (
"pipu/api/internal/platform/httpx"
"pipu/api/internal/playback"
"pipu/api/internal/search"
"pipu/api/internal/social"
"pipu/api/internal/system"
"pipu/api/internal/uploads"
"pipu/api/internal/wave"
@@ -24,6 +25,7 @@ import (
// Deps — модули, реализующие части контракта API.
type Deps struct {
Social *social.Handler
Management *management.Handler
Genius *genius.Handler
Log *slog.Logger
@@ -48,6 +50,7 @@ type Deps struct {
// По мере появления модулей (catalog, ...) они встраиваются сюда.
// Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler.
type (
socialHandler = social.Handler
managementHandler = management.Handler
geniusHandler = genius.Handler
systemHandler = system.Handler
@@ -61,6 +64,7 @@ type (
)
type handlers struct {
*socialHandler
*managementHandler
*geniusHandler
*systemHandler
@@ -93,7 +97,7 @@ func NewRouter(d Deps) http.Handler {
d.TUS.Mount(r)
}
strict := gen.NewStrictHandlerWithOptions(handlers{managementHandler: d.Management, geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
strict := gen.NewStrictHandlerWithOptions(handlers{socialHandler: d.Social, managementHandler: d.Management, geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
[]gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: httpx.RequestError,
ResponseErrorHandlerFunc: httpx.ResponseError(log),
+108
View File
@@ -0,0 +1,108 @@
// Package social owns friendships, private conversations and the persistent inbox.
package social
import (
"context"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/platform/apperr"
)
type Handler struct{ store *Store }
func NewHandler(pool *pgxpool.Pool) *Handler { return &Handler{store: &Store{pool: pool}} }
func userID(ctx context.Context) (uuid.UUID, error) {
p, ok := auth.PrincipalFrom(ctx)
if !ok {
return uuid.Nil, apperr.Unauthorized("auth.unauthorized", "sign in required")
}
return p.UserID, nil
}
func invalid() error {
return apperr.Validation("social", "social.invalid", "invalid social operation", nil)
}
func denied() error {
return apperr.Forbidden("social.unavailable", "friendship required or person unavailable")
}
func (h *Handler) GetSocial(ctx context.Context, _ gen.GetSocialRequestObject) (gen.GetSocialResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
v, e := h.store.inbox(ctx, id)
return gen.GetSocial200JSONResponse(v), e
}
func (h *Handler) SearchPeople(ctx context.Context, r gen.SearchPeopleRequestObject) (gen.SearchPeopleResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
q := strings.TrimSpace(r.Params.Q)
if utf8.RuneCountInString(q) < 2 || utf8.RuneCountInString(q) > 80 {
return nil, invalid()
}
v, e := h.store.people(ctx, id, q)
return gen.SearchPeople200JSONResponse(v), e
}
func (h *Handler) SocialAction(ctx context.Context, r gen.SocialActionRequestObject) (gen.SocialActionResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil || r.Body.TargetId == uuid.Nil || r.Body.TargetId == id {
return nil, invalid()
}
if e = h.store.action(ctx, id, *r.Body); e != nil {
return nil, e
}
return gen.SocialAction204Response{}, nil
}
func (h *Handler) GetDirectMessages(ctx context.Context, r gen.GetDirectMessagesRequestObject) (gen.GetDirectMessagesResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.PeerId == uuid.Nil || r.PeerId == id || (r.Params.Before != nil && *r.Params.Before < 1) {
return nil, invalid()
}
v, e := h.store.messages(ctx, id, r.PeerId, r.Params.Before)
return gen.GetDirectMessages200JSONResponse(v), e
}
func validateMessage(m gen.SendDirectMessage) error {
if m.Id == uuid.Nil || utf8.RuneCountInString(m.Text) > 4000 {
return invalid()
}
switch m.AttachmentKind {
case "none":
if strings.TrimSpace(m.Text) == "" || m.AttachmentId != nil {
return invalid()
}
case "track", "playlist":
if m.AttachmentId == nil || *m.AttachmentId == uuid.Nil {
return invalid()
}
default:
return invalid()
}
return nil
}
func (h *Handler) SendDirectMessage(ctx context.Context, r gen.SendDirectMessageRequestObject) (gen.SendDirectMessageResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil || r.PeerId == id || r.PeerId == uuid.Nil {
return nil, invalid()
}
if e = validateMessage(*r.Body); e != nil {
return nil, e
}
v, e := h.store.send(ctx, id, r.PeerId, *r.Body)
return gen.SendDirectMessage200JSONResponse(v), e
}
@@ -0,0 +1,143 @@
//go:build integration
package social
import (
"context"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"os"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/db"
"sync"
"testing"
)
func TestSocialIsolationAndLifecycle(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, e := db.Connect(ctx, url)
require.NoError(t, e)
defer pool.Close()
require.NoError(t, db.Migrate(ctx, pool))
s := &Store{pool: pool}
a, b, c := uuid.New(), uuid.New(), uuid.New()
for _, id := range []uuid.UUID{a, b, c} {
_, e = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash) VALUES($1,$2,'x')`, id, id.String())
require.NoError(t, e)
}
defer pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1)`, []uuid.UUID{a, b, c})
action := func(user, peer uuid.UUID, op string) error {
return s.action(ctx, user, gen.SocialAction{Action: gen.SocialActionAction(op), TargetId: peer})
}
require.NoError(t, action(a, b, "request"))
require.NoError(t, action(a, b, "request"))
parallel(t, func() error { return action(a, b, "request") })
inbox, e := s.inbox(ctx, b)
require.NoError(t, e)
require.Len(t, inbox.Notifications, 1)
require.Equal(t, 1, inbox.Unread)
require.EqualValues(t, "incoming", inbox.People[0].Relationship)
require.Error(t, action(a, b, "accept"))
require.Error(t, action(c, b, "accept"))
_, e = s.send(ctx, a, b, gen.SendDirectMessage{Id: uuid.New(), Text: "before acceptance", AttachmentKind: "none"})
require.Error(t, e)
require.NoError(t, action(b, a, "accept"))
require.NoError(t, action(b, a, "accept"))
inbox, e = s.inbox(ctx, a)
require.NoError(t, e)
require.Len(t, inbox.Notifications, 1)
require.EqualValues(t, "friend", inbox.People[0].Relationship)
body := gen.SendDirectMessage{Id: uuid.New(), Text: "Привет", AttachmentKind: "none"}
m, e := s.send(ctx, a, b, body)
require.NoError(t, e)
again, e := s.send(ctx, a, b, body)
require.NoError(t, e)
require.Equal(t, m.Seq, again.Seq)
parallel(t, func() error { _, err := s.send(ctx, a, b, body); return err })
body.Text = "changed"
_, e = s.send(ctx, a, b, body)
require.Error(t, e)
_, e = s.messages(ctx, c, b, nil)
require.Error(t, e)
inbox, e = s.inbox(ctx, b)
require.NoError(t, e)
require.Equal(t, 1, inbox.Unread)
// Another user cannot acknowledge this user's notification.
require.NoError(t, s.action(ctx, c, gen.SocialAction{Action: "read_notification", TargetId: inbox.Notifications[0].Id}))
inbox, e = s.inbox(ctx, b)
require.NoError(t, e)
require.Equal(t, 1, inbox.Unread)
second, e := s.send(ctx, a, b, gen.SendDirectMessage{Id: uuid.New(), Text: "Later", AttachmentKind: "none"})
require.NoError(t, e)
require.NoError(t, s.action(ctx, b, gen.SocialAction{Action: "read_chat", TargetId: a, UpToSeq: &m.Seq}))
history, e := s.messages(ctx, a, b, nil)
require.NoError(t, e)
require.Len(t, history, 2)
require.True(t, history[0].Read)
require.False(t, history[1].Read)
before, e := s.messages(ctx, b, a, &second.Seq)
require.NoError(t, e)
require.Len(t, before, 1)
require.NoError(t, action(b, a, "block"))
require.NoError(t, action(a, b, "block"))
for _, id := range []uuid.UUID{a, b} {
blocked, err := s.inbox(ctx, id)
require.NoError(t, err)
require.Len(t, blocked.People, 1)
require.EqualValues(t, "blocked", blocked.People[0].Relationship)
}
require.NoError(t, action(a, b, "unblock"))
_, e = s.send(ctx, a, b, gen.SendDirectMessage{Id: uuid.New(), Text: "blocked", AttachmentKind: "none"})
require.Error(t, e)
_, e = s.messages(ctx, b, a, nil)
require.Error(t, e)
require.Error(t, action(a, b, "request"))
found, e := s.people(ctx, a, b.String())
require.NoError(t, e)
require.Empty(t, found)
require.NoError(t, action(b, a, "unblock"))
require.Error(t, action(a, b, "accept"))
require.NoError(t, action(a, b, "request"))
require.NoError(t, action(b, a, "accept"))
require.NoError(t, action(a, b, "remove"))
_, e = s.messages(ctx, b, a, nil)
require.Error(t, e)
// An attachment must reference available catalog music or an active playlist share.
require.NoError(t, action(a, c, "request"))
require.NoError(t, action(c, a, "accept"))
missing := uuid.New()
_, e = s.send(ctx, a, c, gen.SendDirectMessage{Id: uuid.New(), AttachmentKind: "playlist", AttachmentId: &missing})
require.Error(t, e)
playlist, share := uuid.New(), uuid.New()
_, e = pool.Exec(ctx, `INSERT INTO playlists(id,user_id,name,share_id) VALUES($1,$2,'Music',$3)`, playlist, a, share)
require.NoError(t, e)
attachment, e := s.send(ctx, a, c, gen.SendDirectMessage{Id: uuid.New(), AttachmentKind: "playlist", AttachmentId: &share})
require.NoError(t, e)
require.Equal(t, &share, attachment.AttachmentId)
_, e = pool.Exec(ctx, `UPDATE playlists SET share_id=NULL WHERE id=$1`, playlist)
require.NoError(t, e)
_, e = s.send(ctx, a, c, gen.SendDirectMessage{Id: uuid.New(), AttachmentKind: "playlist", AttachmentId: &share})
require.Error(t, e)
found, e = s.people(ctx, a, "%_")
require.NoError(t, e)
require.Empty(t, found)
}
func parallel(t *testing.T, operation func() error) {
t.Helper()
var wg sync.WaitGroup
errors := make(chan error, 8)
for range 8 {
wg.Add(1)
go func() { defer wg.Done(); errors <- operation() }()
}
wg.Wait()
close(errors)
for err := range errors {
require.NoError(t, err)
}
}
@@ -0,0 +1,40 @@
package social
import (
"context"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"strings"
"testing"
)
func TestMessageValidation(t *testing.T) {
id := uuid.New()
for _, m := range []gen.SendDirectMessage{
{Id: id, Text: " \n ", AttachmentKind: "none"},
{Text: "hello", AttachmentKind: "none"},
{Id: id, Text: strings.Repeat("я", 4001), AttachmentKind: "none"},
{Id: id, Text: "hello", AttachmentKind: "url", AttachmentId: &id},
{Id: id, AttachmentKind: "playlist"},
{Id: id, Text: "hello", AttachmentKind: "none", AttachmentId: &id},
} {
require.Error(t, validateMessage(m))
}
require.NoError(t, validateMessage(gen.SendDirectMessage{Id: id, Text: strings.Repeat("я", 4000), AttachmentKind: "none"}))
require.NoError(t, validateMessage(gen.SendDirectMessage{Id: id, AttachmentKind: "track", AttachmentId: &id}))
}
func TestHandlersRequireIdentityAndRejectSelf(t *testing.T) {
h := &Handler{}
_, e := h.GetSocial(context.Background(), gen.GetSocialRequestObject{})
require.Error(t, e)
id := uuid.New()
ctx := auth.WithPrincipal(context.Background(), auth.Principal{UserID: id})
_, e = h.SocialAction(ctx, gen.SocialActionRequestObject{Body: &gen.SocialAction{Action: "request", TargetId: id}})
require.Error(t, e)
_, e = h.SendDirectMessage(ctx, gen.SendDirectMessageRequestObject{PeerId: id})
require.Error(t, e)
_, e = h.SearchPeople(ctx, gen.SearchPeopleRequestObject{Params: gen.SearchPeopleParams{Q: " "}})
require.Error(t, e)
}
+318
View File
@@ -0,0 +1,318 @@
package social
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
type Store struct{ pool *pgxpool.Pool }
// Every mutation of a pair takes the same transaction lock, including block and send.
// Thus a send cannot race past a committed unfriend/block, even on another API replica.
func pairTx(ctx context.Context, pool *pgxpool.Pool, a, b uuid.UUID) (pgx.Tx, error) {
tx, e := pool.Begin(ctx)
if e != nil {
return nil, e
}
low, high := a.String(), b.String()
if low > high {
low, high = high, low
}
_, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, low+high)
if e != nil {
_ = tx.Rollback(ctx)
return nil, e
}
return tx, nil
}
func allowed(ctx context.Context, tx pgx.Tx, a, b uuid.UUID) error {
var ok bool
e := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM friendships f JOIN users u ON u.id=$2
WHERE low_id=LEAST($1::uuid,$2::uuid) AND high_id=GREATEST($1::uuid,$2::uuid) AND accepted AND NOT u.is_blocked)
AND NOT EXISTS(SELECT 1 FROM social_blocks WHERE (user_id=$1 AND peer_id=$2) OR (user_id=$2 AND peer_id=$1))`, a, b).Scan(&ok)
if e != nil {
return e
}
if !ok {
return denied()
}
return nil
}
const personSQL = `SELECT u.id,u.username,COALESCE(u.display_name,u.username::text),
CASE WHEN b.user_id IS NOT NULL THEN 'blocked' WHEN f.accepted THEN 'friend' WHEN f.sender_id=$1 THEN 'outgoing' WHEN f.sender_id IS NOT NULL THEN 'incoming' ELSE 'none' END,
(SELECT count(*) FROM direct_messages m WHERE m.recipient_id=$1 AND m.sender_id=u.id AND m.read_at IS NULL)
FROM users u
LEFT JOIN friendships f ON f.low_id=LEAST($1::uuid,u.id) AND f.high_id=GREATEST($1::uuid,u.id)
LEFT JOIN social_blocks b ON b.user_id=$1 AND b.peer_id=u.id
WHERE u.id<>$1 AND NOT u.is_blocked
AND (b.user_id IS NOT NULL OR NOT EXISTS(SELECT 1 FROM social_blocks x WHERE x.user_id=u.id AND x.peer_id=$1))`
func scanPeople(rows pgx.Rows) ([]gen.SocialPerson, error) {
defer rows.Close()
out := []gen.SocialPerson{}
for rows.Next() {
var p gen.SocialPerson
if e := rows.Scan(&p.Id, &p.Username, &p.DisplayName, &p.Relationship, &p.Unread); e != nil {
return nil, e
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) people(ctx context.Context, id uuid.UUID, q string) ([]gen.SocialPerson, error) {
// strpos treats %, _ and backslashes literally; search is deliberately bounded.
rows, e := s.pool.Query(ctx, personSQL+` AND b.user_id IS NULL AND (strpos(lower(u.username::text),lower($2))>0 OR strpos(lower(COALESCE(u.display_name,'')),lower($2))>0) ORDER BY u.username LIMIT 30`, id, q)
if e != nil {
return nil, e
}
return scanPeople(rows)
}
func (s *Store) inbox(ctx context.Context, id uuid.UUID) (gen.SocialInbox, error) {
out := gen.SocialInbox{People: []gen.SocialPerson{}, Notifications: []gen.SocialNotification{}}
rows, e := s.pool.Query(ctx, personSQL+` AND (f.sender_id IS NOT NULL OR b.user_id IS NOT NULL) ORDER BY u.username`, id)
if e != nil {
return out, e
}
out.People, e = scanPeople(rows)
if e != nil {
return out, e
}
rows, e = s.pool.Query(ctx, `SELECT n.id,n.actor_id,COALESCE(u.display_name,u.username::text),n.kind,n.created_at,n.read_at IS NOT NULL
FROM social_notifications n JOIN users u ON u.id=n.actor_id WHERE n.user_id=$1 AND NOT u.is_blocked
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=n.actor_id) OR (b.peer_id=$1 AND b.user_id=n.actor_id))
ORDER BY n.read_at IS NOT NULL,n.created_at DESC,n.id DESC LIMIT 100`, id)
if e != nil {
return out, e
}
defer rows.Close()
for rows.Next() {
var n gen.SocialNotification
if e = rows.Scan(&n.Id, &n.ActorId, &n.ActorName, &n.Kind, &n.CreatedAt, &n.Read); e != nil {
return out, e
}
out.Notifications = append(out.Notifications, n)
}
if e = rows.Err(); e != nil {
return out, e
}
rows.Close()
e = s.pool.QueryRow(ctx, `SELECT count(*) FROM social_notifications n JOIN users u ON u.id=n.actor_id WHERE n.user_id=$1 AND n.read_at IS NULL AND NOT u.is_blocked
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=n.actor_id) OR (b.peer_id=$1 AND b.user_id=n.actor_id))`, id).Scan(&out.Unread)
return out, e
}
func notify(ctx context.Context, tx pgx.Tx, to, actor uuid.UUID, kind string, seq *int64) error {
_, e := tx.Exec(ctx, `INSERT INTO social_notifications(id,user_id,actor_id,kind,message_seq) VALUES($1,$2,$3,$4,$5)`, uuid.New(), to, actor, kind, seq)
return e
}
func (s *Store) action(ctx context.Context, id uuid.UUID, a gen.SocialAction) error {
peer := a.TargetId
if a.Action == "read_notification" {
_, e := s.pool.Exec(ctx, `UPDATE social_notifications SET read_at=COALESCE(read_at,now()) WHERE id=$1 AND user_id=$2`, peer, id)
return e
}
tx, e := pairTx(ctx, s.pool, id, peer)
if e != nil {
return e
}
defer tx.Rollback(ctx)
switch a.Action {
case "read_chat":
if a.UpToSeq == nil || *a.UpToSeq < 1 {
return invalid()
}
if e = allowed(ctx, tx, id, peer); e != nil {
return e
}
_, e = tx.Exec(ctx, `UPDATE direct_messages SET read_at=COALESCE(read_at,now()) WHERE recipient_id=$1 AND sender_id=$2 AND seq<=$3`, id, peer, *a.UpToSeq)
if e == nil {
_, e = tx.Exec(ctx, `UPDATE social_notifications SET read_at=COALESCE(read_at,now()) WHERE user_id=$1 AND actor_id=$2 AND message_seq<=$3`, id, peer, *a.UpToSeq)
}
case "block":
_, e = tx.Exec(ctx, `INSERT INTO social_blocks(user_id,peer_id) SELECT $1,id FROM users WHERE id=$2 ON CONFLICT DO NOTHING`, id, peer)
if e == nil {
_, e = tx.Exec(ctx, `DELETE FROM friendships WHERE low_id=LEAST($1::uuid,$2::uuid) AND high_id=GREATEST($1::uuid,$2::uuid)`, id, peer)
}
if e == nil {
_, e = tx.Exec(ctx, `UPDATE social_notifications SET read_at=COALESCE(read_at,now()) WHERE (user_id=$1 AND actor_id=$2) OR (user_id=$2 AND actor_id=$1)`, id, peer)
}
case "unblock":
_, e = tx.Exec(ctx, `DELETE FROM social_blocks WHERE user_id=$1 AND peer_id=$2`, id, peer)
case "remove":
_, e = tx.Exec(ctx, `DELETE FROM friendships WHERE low_id=LEAST($1::uuid,$2::uuid) AND high_id=GREATEST($1::uuid,$2::uuid)`, id, peer)
if e == nil {
_, e = tx.Exec(ctx, `UPDATE social_notifications SET read_at=COALESCE(read_at,now()) WHERE ((user_id=$1 AND actor_id=$2) OR (user_id=$2 AND actor_id=$1)) AND kind IN ('friend_request','friend_accepted')`, id, peer)
}
case "request", "accept":
var available bool
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id=$2 AND NOT is_blocked) AND NOT EXISTS(SELECT 1 FROM social_blocks WHERE (user_id=$1 AND peer_id=$2) OR (user_id=$2 AND peer_id=$1))`, id, peer).Scan(&available)
if e != nil {
return e
}
if !available {
return denied()
}
if a.Action == "request" {
var exists bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM friendships WHERE low_id=LEAST($1::uuid,$2::uuid) AND high_id=GREATEST($1::uuid,$2::uuid))`, id, peer).Scan(&exists); e != nil {
return e
}
if exists {
return tx.Commit(ctx)
}
// Serialize the sender's quota across requests to different people.
if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "social-requests:"+id.String()); e != nil {
return e
}
var recent int
e = tx.QueryRow(ctx, `SELECT count(*) FROM social_notifications WHERE actor_id=$1 AND kind='friend_request' AND created_at>now()-interval '1 minute'`, id).Scan(&recent)
if e != nil {
return e
}
if recent >= 10 {
return apperr.TooManyRequests("social.rate_limited", 60)
}
var count int
e = tx.QueryRow(ctx, `SELECT count(*) FROM friendships WHERE sender_id=$1 AND NOT accepted`, id).Scan(&count)
if e != nil {
return e
}
if count >= 50 {
return apperr.TooManyRequests("social.rate_limited", 60)
}
result, err := tx.Exec(ctx, `INSERT INTO friendships(low_id,high_id,sender_id) VALUES(LEAST($1::uuid,$2::uuid),GREATEST($1::uuid,$2::uuid),$1) ON CONFLICT DO NOTHING`, id, peer)
e = err
if e == nil && result.RowsAffected() > 0 {
e = notify(ctx, tx, peer, id, "friend_request", nil)
}
} else {
result, err := tx.Exec(ctx, `UPDATE friendships SET accepted=true WHERE low_id=LEAST($1::uuid,$2::uuid) AND high_id=GREATEST($1::uuid,$2::uuid) AND sender_id=$2 AND NOT accepted`, id, peer)
e = err
if e == nil && result.RowsAffected() > 0 {
e = notify(ctx, tx, peer, id, "friend_accepted", nil)
} else if e == nil {
if e = allowed(ctx, tx, id, peer); e != nil {
return e
}
}
if e == nil {
_, e = tx.Exec(ctx, `UPDATE social_notifications SET read_at=COALESCE(read_at,now()) WHERE user_id=$1 AND actor_id=$2 AND kind='friend_request'`, id, peer)
}
}
default:
return invalid()
}
if e != nil {
return e
}
return tx.Commit(ctx)
}
const messageColumns = `id,seq,sender_id,text,attachment_kind,attachment_id,created_at,read_at IS NOT NULL`
func scanMessage(row pgx.Row) (gen.DirectMessage, error) {
var m gen.DirectMessage
e := row.Scan(&m.Id, &m.Seq, &m.SenderId, &m.Text, &m.AttachmentKind, &m.AttachmentId, &m.CreatedAt, &m.Read)
return m, e
}
func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64) ([]gen.DirectMessage, error) {
tx, e := pairTx(ctx, s.pool, id, peer)
if e != nil {
return nil, e
}
defer tx.Rollback(ctx)
if e = allowed(ctx, tx, id, peer); e != nil {
return nil, e
}
rows, e := tx.Query(ctx, `SELECT `+messageColumns+` FROM direct_messages WHERE LEAST(sender_id,recipient_id)=LEAST($1::uuid,$2::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($1::uuid,$2::uuid) AND ($3::bigint IS NULL OR seq<$3) ORDER BY seq DESC LIMIT 50`, id, peer, before)
if e != nil {
return nil, e
}
defer rows.Close()
out := []gen.DirectMessage{}
for rows.Next() {
m, e := scanMessage(rows)
if e != nil {
return nil, e
}
out = append(out, m)
}
if e = rows.Err(); e != nil {
return nil, e
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, nil
}
func (s *Store) send(ctx context.Context, id, peer uuid.UUID, in gen.SendDirectMessage) (gen.DirectMessage, error) {
var zero gen.DirectMessage
tx, e := pairTx(ctx, s.pool, id, peer)
if e != nil {
return zero, e
}
defer tx.Rollback(ctx)
if e = allowed(ctx, tx, id, peer); e != nil {
return zero, e
}
// The same client UUID may be retried against different peers concurrently.
if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "social-message:"+in.Id.String()); e != nil {
return zero, e
}
m, e := scanMessage(tx.QueryRow(ctx, `SELECT `+messageColumns+` FROM direct_messages WHERE id=$1`, in.Id))
if e == nil {
var recipient uuid.UUID
e = tx.QueryRow(ctx, `SELECT recipient_id FROM direct_messages WHERE id=$1`, in.Id).Scan(&recipient)
sameAttachment := (m.AttachmentId == nil && in.AttachmentId == nil) || (m.AttachmentId != nil && in.AttachmentId != nil && *m.AttachmentId == *in.AttachmentId)
if e != nil {
return zero, e
}
if m.SenderId != id || recipient != peer || m.Text != strings.TrimSpace(in.Text) || string(m.AttachmentKind) != string(in.AttachmentKind) || !sameAttachment {
return zero, apperr.Conflict("social.message_conflict", "message ID already used")
}
return m, nil
}
if !errors.Is(e, pgx.ErrNoRows) {
return zero, e
}
var count int
e = tx.QueryRow(ctx, `SELECT count(*) FROM direct_messages WHERE sender_id=$1 AND recipient_id=$2 AND created_at>now()-interval '1 minute'`, id, peer).Scan(&count)
if e != nil {
return zero, e
}
if count >= 30 {
return zero, apperr.TooManyRequests("social.rate_limited", 60)
}
if in.AttachmentKind != "none" {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM tracks t JOIN media_files f ON f.id=t.primary_file_id JOIN library_sources s ON s.id=f.source_id WHERE t.id=$1 AND f.missing_since IS NULL AND s.status='mounted')`
if in.AttachmentKind == "playlist" {
query = `SELECT EXISTS(SELECT 1 FROM playlists WHERE share_id=$1)`
}
if e = tx.QueryRow(ctx, query, in.AttachmentId).Scan(&exists); e != nil {
return zero, e
}
if !exists {
return zero, apperr.NotFound("social.attachment_unavailable", "music is unavailable")
}
}
m, e = scanMessage(tx.QueryRow(ctx, `INSERT INTO direct_messages(id,sender_id,recipient_id,text,attachment_kind,attachment_id) VALUES($1,$2,$3,$4,$5,$6) RETURNING `+messageColumns, in.Id, id, peer, strings.TrimSpace(in.Text), in.AttachmentKind, in.AttachmentId))
if e != nil {
return zero, e
}
kind := "message"
if in.AttachmentKind != "none" {
kind = "music"
}
if e = notify(ctx, tx, peer, id, kind, &m.Seq); e != nil {
return zero, e
}
return m, tx.Commit(ctx)
}
+52
View File
@@ -0,0 +1,52 @@
-- +goose Up
CREATE TABLE friendships (
low_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
high_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
sender_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
accepted boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(low_id,high_id),
CHECK(low_id < high_id),
CHECK(sender_id IN (low_id,high_id))
);
CREATE INDEX friendships_high_idx ON friendships(high_id);
CREATE TABLE social_blocks (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
peer_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY(user_id,peer_id), CHECK(user_id <> peer_id)
);
CREATE TABLE direct_messages (
seq bigserial PRIMARY KEY,
id uuid NOT NULL UNIQUE,
sender_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipient_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text text NOT NULL DEFAULT '',
attachment_kind text NOT NULL DEFAULT 'none' CHECK(attachment_kind IN ('none','track','playlist')),
attachment_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz,
CHECK(sender_id <> recipient_id),
CHECK(length(text) <= 4000),
CHECK((attachment_kind='none' AND attachment_id IS NULL AND length(trim(text))>0)
OR (attachment_kind<>'none' AND attachment_id IS NOT NULL))
);
CREATE INDEX direct_messages_pair_idx ON direct_messages(LEAST(sender_id,recipient_id),GREATEST(sender_id,recipient_id),seq DESC);
CREATE INDEX direct_messages_unread_idx ON direct_messages(recipient_id,sender_id,seq) WHERE read_at IS NULL;
CREATE INDEX direct_messages_sender_time_idx ON direct_messages(sender_id,created_at DESC);
CREATE TABLE social_notifications (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind text NOT NULL CHECK(kind IN ('friend_request','friend_accepted','message','music')),
message_seq bigint REFERENCES direct_messages(seq) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz
);
CREATE INDEX social_notifications_user_idx ON social_notifications(user_id,created_at DESC);
CREATE INDEX social_notifications_request_rate_idx ON social_notifications(actor_id,created_at DESC) WHERE kind='friend_request';
-- +goose Down
DROP TABLE social_notifications;
DROP TABLE direct_messages;
DROP TABLE social_blocks;
DROP TABLE friendships;