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
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:
1 parent
f1528de948
commit
0a05bfe2cd
43 files changed
+2828
-389
No files matched your search
@@ -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/
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SocialScreen } from '../../features/social/Social';
|
||||
export default function FriendsTab() { return <SocialScreen root />; }
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SharedMusic } from '../features/social/SharedMusic';
|
||||
export default function SharedPlaylistScreen() { return <SharedMusic kind="playlist" />; }
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SharedMusic } from '../features/social/SharedMusic';
|
||||
export default function SharedTrackScreen() { return <SharedMusic kind="track" />; }
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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: 'Поделиться плейлистом' }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%; } }
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Музыка больше недоступна."
|
||||
}
|
||||
}
|
||||
@@ -122,5 +122,12 @@
|
||||
"downgrade": "Установлена более новая сборка. Откат выполняется вручную.",
|
||||
"version_unknown": "Не удалось определить commit работающего сервера. Обновите его вручную.",
|
||||
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
|
||||
},
|
||||
"social": {
|
||||
"invalid": "Проверьте сообщение или действие",
|
||||
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
|
||||
"rate_limited": "Слишком много действий. Подождите минуту",
|
||||
"message_conflict": "Это сообщение уже отправлено с другим содержимым",
|
||||
"attachment_unavailable": "Трек или ссылка на плейлист больше недоступны"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user