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

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

No files matched your search

+296 -2
View File
@@ -22,6 +22,118 @@ tags:
security:
- bearerAuth: []
paths:
/api/v1/me/social/recommendations:
put:
operationId: setFriendsRecommendations
tags: [me]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [enabled]
properties:
enabled: {type: boolean}
responses:
'204': {description: Saved}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/listening:
get:
operationId: getListeningState
tags: [me]
responses:
'200':
description: Listening preferences, current room and pending requests
content:
application/json:
schema: {$ref: '#/components/schemas/ListeningState'}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/listening/preferences:
put:
operationId: setListeningPreferences
tags: [me]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ListeningPreferences'}
responses:
'204': {description: Saved}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/listening/actions:
post:
operationId: listeningAction
tags: [me]
description: Join requests target the host; invites target a guest and use the sender's own stream. Accept/decline/cancel target a request UUID. Leave/end target the room UUID. Automatic acceptance always respects the receiving user's admission policy. No control transfer.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ListeningAction'}
responses:
'204': {description: Applied}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/listening/state:
put:
operationId: publishListeningState
tags: [me]
description: Only the room owner's source device may publish. Monotonic sequence prevents stale updates; room expires after 30 seconds without host heartbeat.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ListeningRoomInput'}
responses:
'204': {description: Applied}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/activity:
put:
operationId: publishListeningActivity
tags: [me]
description: Ephemeral playback lease, expires after 90 seconds. Ignored when visibility is nobody. Sequence is monotonic per device; paused updates clear only that device. No offline replay.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ListeningActivityInput'}
responses:
'204': {description: Applied}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/activity/visibility:
put:
operationId: setActivityVisibility
tags: [me]
description: Applies to all devices. Nobody clears all current activity immediately.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [visibility]
properties:
visibility: {$ref: '#/components/schemas/ActivityVisibility'}
responses:
'204': {description: Saved}
default: {$ref: '#/components/responses/Problem'}
/api/v1/social/activity/{userId}/playlist:
get:
operationId: getActivityPlaylist
tags: [me]
description: Read-only current playlist subject to live visibility, friendship, blocking and expiry. Does not create or reveal a permanent share token.
parameters:
- in: path
name: userId
required: true
schema: {type: string, format: uuid}
responses:
'200':
description: Read-only playlist
content:
application/json:
schema: {$ref: '#/components/schemas/Playlist'}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social:
get:
operationId: getSocial
@@ -79,6 +191,9 @@ paths:
- in: query
name: before
schema: {type: integer, format: int64, minimum: 1}
- in: query
name: q
schema: {type: string, maxLength: 100}
responses:
'200':
description: Message page
@@ -104,6 +219,69 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/DirectMessage'}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/chats/{peerId}/meta:
parameters:
- in: path
name: peerId
required: true
schema: {type: string, format: uuid}
get:
operationId: getChatMeta
tags: [me]
responses:
'200':
description: Ephemeral typing indicator, friends only
content:
application/json:
schema:
type: object
required: [typing]
properties:
typing: {type: boolean}
default: {$ref: '#/components/responses/Problem'}
put:
operationId: setChatTyping
tags: [me]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [typing]
properties:
typing: {type: boolean}
responses:
'204': {description: Applied, lease expires after 6 seconds}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/chats/{peerId}/messages/{messageId}:
patch:
operationId: updateChatMessage
tags: [me]
description: Friends only. Edit/delete are sender-only. Delete removes content for both participants; react toggles one allowed emoji. Reply targets must belong to the same conversation.
parameters:
- in: path
name: peerId
required: true
schema: {type: string, format: uuid}
- in: path
name: messageId
required: true
schema: {type: string, format: uuid}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action]
properties:
action: {type: string, enum: [edit, delete, react]}
text: {type: string, maxLength: 4000}
emoji: {type: string, enum: ['❤️', '👍', '🔥', '😂', '🎵', '👏']}
responses:
'204': {description: Applied}
default: {$ref: '#/components/responses/Problem'}
/api/v1/admin/server/updates:
get:
operationId: getServerUpdates
@@ -2076,6 +2254,103 @@ components:
schema:
$ref: "#/components/schemas/Problem"
schemas:
ListeningPreferences:
type: object
required: [requests, auto_accept, autoplay]
properties:
requests: {$ref: '#/components/schemas/ActivityVisibility'}
auto_accept: {$ref: '#/components/schemas/ActivityVisibility'}
autoplay: {type: boolean, default: true}
ListeningAction:
type: object
required: [id, action, target_id]
properties:
id: {type: string, format: uuid}
action: {type: string, enum: [join, invite, accept, decline, cancel, leave, end]}
target_id: {type: string, format: uuid}
device_id: {type: string, format: uuid}
track_id: {type: string, format: uuid}
position_ms: {type: integer, minimum: 0}
playing: {type: boolean}
ListeningRoomInput:
type: object
required: [room_id, device_id, sequence, playing, position_ms]
properties:
room_id: {type: string, format: uuid}
device_id: {type: string, format: uuid}
sequence: {type: integer, format: int64, minimum: 1}
track_id: {type: string, format: uuid}
playing: {type: boolean}
position_ms: {type: integer, minimum: 0}
ListeningRoom:
type: object
required: [id, owner_id, owner_name, device_id, track_id, position_ms, playing, updated_at, participants]
properties:
id: {type: string, format: uuid}
owner_id: {type: string, format: uuid}
owner_name: {type: string}
device_id: {type: string, format: uuid}
track_id: {type: string, format: uuid, nullable: true}
position_ms: {type: integer}
playing: {type: boolean}
updated_at: {type: string, format: date-time}
participants:
type: array
items:
type: object
required: [id, name]
properties:
id: {type: string, format: uuid}
name: {type: string}
ListeningRequest:
type: object
required: [id, sender_id, sender_name, recipient_id, owner_id, kind, expires_at]
properties:
id: {type: string, format: uuid}
sender_id: {type: string, format: uuid}
sender_name: {type: string}
recipient_id: {type: string, format: uuid}
owner_id: {type: string, format: uuid}
kind: {type: string, enum: [join, invite]}
expires_at: {type: string, format: date-time}
ListeningState:
type: object
required: [preferences, room, requests, server_time]
properties:
preferences: {$ref: '#/components/schemas/ListeningPreferences'}
room:
nullable: true
allOf:
- {$ref: '#/components/schemas/ListeningRoom'}
requests:
type: array
items: {$ref: '#/components/schemas/ListeningRequest'}
server_time: {type: string, format: date-time}
ActivityVisibility:
type: string
enum: [nobody, friends, everyone]
default: nobody
ListeningActivityInput:
type: object
required: [device_id, sequence, playing, context]
properties:
device_id: {type: string, format: uuid}
sequence: {type: integer, format: int64, minimum: 1}
playing: {type: boolean}
position_ms: {type: integer, minimum: 0, default: 0}
track_id: {type: string, format: uuid}
context: {type: string, enum: [other, wave, playlist]}
playlist_id: {type: string, format: uuid}
ListeningActivity:
type: object
required: [track_id, track_title, artist, context]
properties:
track_id: {type: string, format: uuid}
track_title: {type: string}
artist: {type: string}
context: {type: string, enum: [other, wave, playlist]}
playlist_id: {type: string, format: uuid}
playlist_name: {type: string}
SocialPerson:
type: object
required: [id, username, display_name, relationship, unread]
@@ -2085,6 +2360,7 @@ components:
display_name: {type: string}
relationship: {type: string, enum: [none, incoming, outgoing, friend, blocked]}
unread: {type: integer}
activity: {$ref: '#/components/schemas/ListeningActivity'}
SocialNotification:
type: object
required: [id, actor_id, actor_name, kind, created_at, read]
@@ -2092,13 +2368,15 @@ components:
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]}
kind: {type: string, enum: [friend_request, friend_accepted, message, music, listening_request, listening_invite, listening_accepted]}
created_at: {type: string, format: date-time}
read: {type: boolean}
SocialInbox:
type: object
required: [people, notifications, unread]
required: [people, notifications, unread, activity_visibility, friends_recommendations]
properties:
friends_recommendations: {type: boolean, default: true}
activity_visibility: {$ref: '#/components/schemas/ActivityVisibility'}
people:
type: array
items: {$ref: '#/components/schemas/SocialPerson'}
@@ -2121,6 +2399,7 @@ components:
text: {type: string, maxLength: 4000}
attachment_kind: {type: string, enum: [none, track, playlist]}
attachment_id: {type: string, format: uuid}
reply_to: {type: string, format: uuid}
DirectMessage:
type: object
required: [id, seq, sender_id, text, attachment_kind, created_at, read]
@@ -2133,6 +2412,21 @@ components:
attachment_id: {type: string, format: uuid}
created_at: {type: string, format: date-time}
read: {type: boolean}
reply_to: {type: string, format: uuid}
reply_preview: {type: string}
edited_at: {type: string, format: date-time}
deleted: {type: boolean}
reactions:
type: array
items:
$ref: '#/components/schemas/MessageReaction'
MessageReaction:
type: object
required: [emoji, count, mine]
properties:
emoji: {type: string}
count: {type: integer}
mine: {type: boolean}
ServerSource:
type: object
required: [id, name, url]
+5 -3
View File
@@ -8,7 +8,8 @@ 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 { NativeChatProvider, useSocialUnread } from '../../features/social/Social';
import { ListeningBanner } from '../../features/social/Listening';
import { UpdateDialog } from '../../ui/UpdateDialog';
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' };
@@ -26,12 +27,13 @@ export default function TabsLayout() {
};
const net = useNetInfo();
return (
<>
<NativeChatProvider>
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
<Tabs
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
tabBar={({ state, navigation }) => (
<View>
<ListeningBanner />
<MiniPlayer />
<TabBar
routes={state.routes.map((r) => r.name)}
@@ -50,7 +52,7 @@ export default function TabsLayout() {
<Tabs.Screen name="profile" />
</Tabs>
<UpdateDialog />
</>
</NativeChatProvider>
);
}
+7 -4
View File
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { MusicShareSheet } from '../features/social/Social';
import { ListeningBanner } from '../features/social/Listening';
import { trackShareUrl } from '@pipu/core';
import { useSessionCtx, useUser } from '../lib/session';
@@ -22,6 +23,7 @@ export default function PlayerScreen() {
const { t } = useTranslation();
const router = useRouter();
const st = usePlayerState();
const locked = st.listening?.locked === true;
const prefs = useUser().ui_prefs;
const { server } = useSessionCtx();
const [sharing, setSharing] = useState(false);
@@ -45,6 +47,7 @@ export default function PlayerScreen() {
return (
<Screen>
{sharing && server && <MusicShareSheet attachment={{ kind: 'track', id: track.id, title: track.title }} url={trackShareUrl(server, track.id)} close={() => setSharing(false)} />}
<ListeningBanner />
<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')}>
@@ -94,7 +97,7 @@ export default function PlayerScreen() {
<View style={{ gap: 6 }}>
<Pressable
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
disabled={locked} onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
accessibilityRole="adjustable"
accessibilityLabel={t('player.progress')}
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
@@ -116,18 +119,18 @@ export default function PlayerScreen() {
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
</Pressable>
<Pressable onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
<Pressable disabled={locked} onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
<Icon name="skip-previous" size={40} color={colors.text} />
</Pressable>
<Pressable
onPress={() => player.toggle()}
disabled={locked} onPress={() => player.toggle()}
accessibilityRole="button"
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }}
>
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
</Pressable>
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Pressable disabled={locked} onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Icon name="skip-next" size={40} color={colors.text} />
</Pressable>
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
@@ -0,0 +1,61 @@
import { CHAT_REACTIONS, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityInfo, Animated, KeyboardAvoidingView, Modal, Pressable, ScrollView, Text, View } from 'react-native';
import { eventId } from '../../player/eventId';
import { useColors } from '../../theme';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
import { readDraft, saveDraft } from './drafts';
function Bubble({ children, mine, animate }: { children: ReactNode; mine: boolean; animate: boolean }) {
const colors = useColors(); const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => { if (!animate) return; opacity.setValue(0); const animation = Animated.timing(opacity,{toValue:1,duration:180,useNativeDriver:true});animation.start();return()=>animation.stop(); },[animate,opacity]);
return <Animated.View style={{ alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '90%', padding: 14, borderRadius: 16, backgroundColor: mine ? colors['accent-soft'] : colors.surface2, gap: 8, opacity, transform:[{translateY:opacity.interpolate({inputRange:[0,1],outputRange:[6,0]})}] }}>{children}</Animated.View>;
}
export function ChatConversation({ person, api, queryKey, userId, server, active }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; server: string; active: boolean }) {
const colors=useColors();const {t,i18n}=useTranslation();const router=useRouter();const cache=useQueryClient();const errorText=useErrorText();
const [text,setText]=useState('');const [ready,setReady]=useState(false);const touched=useRef(false);const [search,setSearch]=useState('');const [q,setQ]=useState('');
const [reply,setReply]=useState<DirectMessage|null>(null);const [editing,setEditing]=useState<DirectMessage|null>(null);const [menu,setMenu]=useState<DirectMessage|null>(null);const [emojis,setEmojis]=useState(false);const [reduced,setReduced]=useState(true);
const beforeEdit=useRef('');const pending=useRef<{id:string;text:string;reply_to?:string}|null>(null);
useEffect(()=>{let live=true;void readDraft(server,userId,person.id).then(value=>{if(live){if(!touched.current)setText(value);setReady(true);}});return()=>{live=false;};},[server,userId,person.id]);
useEffect(()=>{if(!ready||editing)return;const timer=setTimeout(()=>{void saveDraft(server,userId,person.id,text).catch(()=>undefined);},300);return()=>clearTimeout(timer);},[text,ready,editing,server,userId,person.id]);
useEffect(()=>{void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);const sub=AccessibilityInfo.addEventListener('reduceMotionChanged',setReduced);return()=>sub.remove();},[]);
useEffect(()=>{const timer=setTimeout(()=>setQ(search.trim()),250);return()=>clearTimeout(timer);},[search]);
useEffect(()=>{const timer=setTimeout(()=>{void api.typing(person.id,active&&touched.current&&!!text.trim()&&!editing).catch(()=>undefined);},400);return()=>clearTimeout(timer);},[text,active,editing,api,person.id]);
useEffect(()=>()=>{void api.typing(person.id,false).catch(()=>undefined);},[api,person.id]);
const meta=useQuery({queryKey:[...queryKey,'typing',person.id],queryFn:()=>api.chatMeta(person.id),enabled:active,refetchInterval:active?2000:false});
const messages=useInfiniteQuery({queryKey:[...queryKey,'chat',person.id,q],initialPageParam:undefined as number|undefined,queryFn:({pageParam})=>api.messages(person.id,pageParam,q),getNextPageParam:last=>last.length===50?last[0]?.seq:undefined,enabled:active,refetchInterval:active?3000:false});
const list=[...(messages.data?.pages??[])].reverse().flat();const last=list.at(-1)?.seq;const unread=list.some(m=>m.sender_id!==userId&&!m.read);
useEffect(()=>{if(last&&unread&&active&&!q)void api.action({action:'read_chat',target_id:person.id,up_to_seq:last}).then(()=>cache.invalidateQueries({queryKey})).catch(()=>undefined);},[last,unread,active,q,api,person.id,messages.dataUpdatedAt]);
const refresh=()=>cache.invalidateQueries({queryKey});
const update=useMutation({mutationFn:(input:{id:string;body:ChatMessageUpdate})=>api.updateMessage(person.id,input.id,input.body),onSuccess:refresh});
const send=useMutation({mutationFn:async()=>{if(editing){await api.updateMessage(person.id,editing.id,{action:'edit',text});return;}if(!pending.current||pending.current.text!==text||pending.current.reply_to!==reply?.id)pending.current={id:eventId(Date.now()),text,...(reply?{reply_to:reply.id}:{})};await api.send(person.id,{...pending.current,attachment_kind:'none'});},onSuccess:async()=>{setText(editing?beforeEdit.current:'');setEditing(null);setReply(null);pending.current=null;await refresh();}});
const scroll=useRef<ScrollView>(null);const [bottom,setBottom]=useState(true);useEffect(()=>setBottom(true),[last]);
const cancelEdit=()=>{setText(beforeEdit.current);setEditing(null);};
const mutate=(m:DirectMessage,body:ChatMessageUpdate)=>{if(!update.isPending)update.mutate({id:m.id,body});setMenu(null);};
return <KeyboardAvoidingView behavior="padding" style={{flex:1}}>
<View style={{padding:12,gap:8}}><H1>{person.display_name}</H1><Field label={t('social.chat_search')} value={search} onChangeText={setSearch} maxLength={100}/><ErrorBox>{messages.error||send.error||update.error?errorText(messages.error??send.error??update.error):null}</ErrorBox></View>
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{padding:14,gap:12}} onContentSizeChange={()=>{if(bottom&&!q)scroll.current?.scrollToEnd({animated:!reduced});}}>
{messages.hasNextPage&&<Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={()=>{setBottom(false);void messages.fetchNextPage();}}/>}
{!list.length&&<Muted>{t(messages.isPending?'social.loading':q?'social.chat_no_results':'social.first_message')}</Muted>}
{list.map((m,i)=><View key={m.id} style={{gap:8}}>
{(i===0||new Date(list[i-1]!.created_at).toDateString()!==new Date(m.created_at).toDateString())&&<Text style={{color:colors.text3,fontSize:11,textAlign:'center'}}>{new Date(m.created_at).toLocaleDateString(i18n.language,{day:'numeric',month:'long'})}</Text>}
<Bubble mine={m.sender_id===userId} animate={!reduced}>
<Pressable disabled={m.deleted} onLongPress={()=>setMenu(m)} accessibilityLabel={t('social.message_actions')}>
{m.reply_to&&<View style={{borderLeftWidth:2,borderLeftColor:colors.accent,paddingLeft:8,marginBottom:8}}><Muted>{m.reply_preview||t('social.message_deleted')}</Muted></View>}
<Text selectable style={{color:m.deleted?colors.text3:colors.text,fontSize:15,fontStyle:m.deleted?'italic':'normal'}}>{m.deleted?t('social.message_deleted'):m.text}</Text>
</Pressable>
{!m.deleted&&m.attachment_id&&m.attachment_kind!=='none'&&<Button title={t(m.attachment_kind==='track'?'social.listen_track':'social.listen_playlist')} variant="secondary" onPress={()=>router.push({pathname:m.attachment_kind==='track'?'/social-track':'/social-playlist',params:{id:m.attachment_id}})}/>}
{!m.deleted&&!!m.reactions?.length&&<View style={{flexDirection:'row',gap:6,flexWrap:'wrap'}}>{m.reactions.map(r=><Pressable key={r.emoji} accessibilityRole="button" accessibilityState={{selected:r.mine}} onPress={()=>mutate(m,{action:'react',emoji:r.emoji as typeof CHAT_REACTIONS[number]})} style={{padding:6,borderRadius:12,backgroundColor:r.mine?colors['accent-soft']:colors.surface3}}><Text style={{color:colors.text}}>{r.emoji} {r.count}</Text></Pressable>)}</View>}
<View style={{flexDirection:'row',gap:8,alignItems:'center'}}><Text style={{color:colors.text3,fontSize:10,flex:1}}>{new Date(m.created_at).toLocaleTimeString(i18n.language,{hour:'2-digit',minute:'2-digit'})}{m.edited_at&&!m.deleted?` · ${t('social.edited')}`:''}{m.sender_id===userId?` · ${t(m.read?'social.read':'social.sent')}`:''}</Text>{!m.deleted&&<Pressable accessibilityRole="button" accessibilityLabel={t('social.message_actions')} hitSlop={10} onPress={()=>setMenu(m)}><Text style={{color:colors.text3}}>•••</Text></Pressable>}</View>
</Bubble>
</View>)}
</ScrollView>
<Text accessibilityLiveRegion="polite" style={{minHeight:18,paddingHorizontal:14,color:colors.text3,fontSize:11}}>{active&&meta.data?.typing?`${person.display_name} ${t('social.typing')}`:''}</Text>
{(reply||editing)&&<View style={{padding:10,backgroundColor:colors['accent-soft'],gap:4}}><Muted>{t(editing?'social.editing':'social.replying')}</Muted><Text numberOfLines={2} style={{color:colors.text2}}>{(editing??reply)?.text}</Text><Button title={t(editing?'social.cancel_edit':'social.cancel_reply')} variant="ghost" onPress={()=>editing?cancelEdit():setReply(null)}/></View>}
<View style={{padding:12,gap:8,borderTopWidth:1,borderTopColor:colors.line}}><Field label={t('social.message')} value={text} onChangeText={value=>{touched.current=true;setText(value);}} maxLength={4000} multiline editable={!send.isPending}/><View style={{flexDirection:'row',gap:8}}><Button title="☺" variant="ghost" onPress={()=>setEmojis(v=>!v)}/><View style={{flex:1}}><Button title={t(editing?'playlists.save':'social.send')} busy={send.isPending} disabled={!text.trim()} onPress={()=>send.mutate()}/></View></View>{emojis&&<View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>{setText(v=>(v+emoji).slice(0,4000));setEmojis(false);}}/>)}</View>}</View>
<Modal transparent visible={!!menu} animationType={reduced?'none':'fade'} onRequestClose={()=>setMenu(null)}><View style={{flex:1,justifyContent:'flex-end',backgroundColor:colors.overlay}}><Screen style={{flex:0,maxHeight:'80%',borderTopLeftRadius:24,borderTopRightRadius:24}}><ScrollView contentContainerStyle={{padding:20,gap:10}}><Button title={t('form.close')} variant="ghost" onPress={()=>setMenu(null)}/>{menu&&<><Button title={t('social.reply')} variant="secondary" onPress={()=>{if(editing)cancelEdit();setReply(menu);setMenu(null);}}/><View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>mutate(menu,{action:'react',emoji})}/>)}</View>{menu.sender_id===userId&&<><Button title={t('social.edit_message')} variant="secondary" onPress={()=>{beforeEdit.current=text;setEditing(menu);setText(menu.text);setReply(null);setMenu(null);}}/><Button title={t('social.delete_message')} variant="danger" onPress={()=>mutate(menu,{action:'delete'})}/></>}</>}</ScrollView></Screen></View></Modal>
</KeyboardAvoidingView>;
}
@@ -0,0 +1,59 @@
import { socialApi, type ListeningPreferences, type SocialPerson } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, ScrollView, Switch, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { eventId } from '../../player/eventId';
import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme';
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from '../../ui/kit';
function useListening() {
const session = useSession(); const { server } = useSessionCtx(); const user = useUser();
const api = useMemo(() => socialApi(session), [session]); const key = ['m-listening', server, user.id] as const;
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
return { api, key, query, user };
}
export function ListeningPanel() {
const { api, key, query, user } = useListening(); const { t } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText(); const colors = useColors();
const [expanded, setExpanded] = useState(false); const prefs = query.data?.preferences;
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const action = useMutation({ mutationFn: (a: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: eventId(Date.now()), action: a.action, target_id: a.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <View style={{ gap: 12 }}><Button title={t('social.together')} variant="secondary" onPress={() => setExpanded(v => !v)} />
{expanded && <View style={{ padding: 16, borderRadius: 16, gap: 14, backgroundColor: colors.surface }}><Muted>{t('social.stream_prefs_hint')}</Muted>
{(['requests', 'auto_accept'] as const).map(field => <View key={field} style={{ gap: 8 }}><Muted>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</Muted><View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} disabled={!prefs || save.isPending} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onPress={() => prefs && save.mutate({ ...prefs, [field]: v })} />)}</View></View>)}
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}><Text style={{ color: colors.text, flex: 1 }}>{t('social.stream_autoplay')}</Text><Switch accessibilityLabel={t('social.stream_autoplay')} value={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onValueChange={autoplay => prefs && save.mutate({ ...prefs, autoplay })} /></View>
</View>}
{query.data?.requests.map(r => <View key={r.id} style={{ padding: 16, borderRadius: 16, gap: 10, backgroundColor: colors.surface }}><Text style={{ color: colors.text }}>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</Text>{r.recipient_id === user.id ? <><Button title={t('social.accept')} disabled={action.isPending} onPress={() => action.mutate({ action: 'accept', target: r.id })} /><Button title={t('social.decline')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'decline', target: r.id })} /></> : <Button title={t('form.cancel')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'cancel', target: r.id })} />}</View>)}
<ErrorBox>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</ErrorBox>
</View>;
}
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
const { t } = useTranslation(); const [open, setOpen] = useState(false);
return <><Button title={t('social.together')} variant="ghost" onPress={() => setOpen(true)} />{open && <TogetherSheet person={person} close={() => setOpen(false)} />}</>;
}
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
<Muted>{t('social.stream_hint')}</Muted><ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
{action.isSuccess ? <Muted>{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</Muted> : <Button title={t('social.send')} busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onPress={() => action.mutate()} />}
</ScrollView></Screen></Modal>;
}
export function ListeningBanner() {
const state = usePlayerState(); const session = useSession(); const user = useUser(); const colors = useColors(); const { t } = useTranslation(); const errorText = useErrorText(); const room = state.listening?.room;
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: eventId(Date.now()), action: room.owner_id === user.id ? 'end' : 'leave', target_id: room.id }) });
if (!room) return null;
return <View style={{ padding: 12, gap: 6, backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.ok }}><Text style={{ color: colors.text, fontWeight: '600' }}>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</Text>
<Muted>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</Muted>
<Muted>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</Muted>
{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button title={t('social.stream_start_audio')} variant="secondary" onPress={() => player.beginListeningAudio()} />}
<Button title={t(room.owner_id === user.id ? 'social.stream_end' : 'social.stream_leave')} variant="ghost" busy={action.isPending} onPress={() => action.mutate()} />
<ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
</View>;
}
@@ -9,7 +9,7 @@ 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 { id, owner } = useLocalSearchParams<{ id: string; owner?: string }>();
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
@@ -17,20 +17,22 @@ export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
const { t } = useTranslation();
const errorText = useErrorText();
const [playError, setPlayError] = useState<unknown>();
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id], queryFn: async () => {
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id, owner], refetchInterval: owner ? 15_000 : false, queryFn: async () => {
if (owner) return unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId: owner } } }));
if (kind === 'track') {
const track = unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } }));
return { name: track.title, tracks: [track] };
return { id: track.id, 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}
const visible = query.isError ? undefined : query.data;
return <Screen><FlatList data={visible?.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>
<H1>{visible?.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)} />}
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(visible?.tracks ?? [], index, { type: kind === 'track' ? 'queue' : 'collection', id: visible?.id, label: visible?.name }).catch(setPlayError)} />}
/></Screen>;
}
+54 -53
View File
@@ -1,9 +1,12 @@
import { attachmentPath, socialApi, uuidv7, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { attachmentPath, socialApi, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { eventId } from '../../player/eventId';
import { ListeningPanel, ListenTogetherButton } from './Listening';
import { ChatConversation } from './ChatConversation';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { AppState, Clipboard, KeyboardAvoidingView, Modal, ScrollView, Share, Text, View } from 'react-native';
import { AppState, Clipboard, KeyboardAvoidingView, Modal, Pressable, ScrollView, Share, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { useColors } from '../../theme';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
@@ -20,6 +23,30 @@ function useSocial() {
return { api, key, inbox, user, active };
}
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
const ChatDockContext = createContext<(id: string) => boolean>(() => false);
export function NativeChatProvider({ children }: { children: ReactNode }) {
const { inbox } = useSocial(); const colors = useColors(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [peer, setPeer] = useState<string | null>(null);
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
const selected = friends.find(p => p.id === peer); const unread = friends.reduce((sum,p) => sum+p.unread,0);
return <ChatDockContext.Provider value={id => { setPeer(id); setOpen(true); return true; }}>{children}
<View style={{ position: 'absolute', bottom: 158, right: 16 }}><Button title={`${t('social.messages')}${unread ? ` · ${unread}` : ''}`} variant="secondary" onPress={() => setOpen(true)} /></View>
<Modal transparent visible={open} animationType="slide" onRequestClose={() => setOpen(false)}>
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: colors.overlay }}><View style={{ height: '82%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', backgroundColor: colors.bg }}>
<Screen><View style={{ padding: 12, flexDirection: 'row', justifyContent: 'space-between' }}><Button title={t('social.people')} variant="ghost" onPress={() => setPeer(null)} /><Button title={t('social.minimize')} variant="ghost" onPress={() => setOpen(false)} /></View>
{selected ? <Chat key={selected.id} person={selected} active={open} /> : <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}><H1>{t('social.messages')}</H1>{friends.map(p => <Button key={p.id} title={`${p.display_name}${p.unread ? ` · ${p.unread}` : ''}`} variant="secondary" onPress={() => setPeer(p.id)} />)}{!friends.length && <Muted>{t('social.no_friends_share')}</Muted>}</ScrollView>}
</Screen>
</View></View>
</Modal>
</ChatDockContext.Provider>;
}
export function FriendRecommendations() {
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <View style={{ gap: 8 }}><Button title={t('social.friends_recommendations')} variant={(inbox.data?.friends_recommendations ?? true) ? 'primary' : 'secondary'} disabled={!inbox.data || save.isPending} onPress={() => save.mutate(!(inbox.data?.friends_recommendations ?? true))} /><Muted>{t('social.friends_recommendations_hint')}</Muted><ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox></View>;
}
export function MusicShareSheet({ attachment, url, close }: { attachment: MusicAttachment; url: string; close: () => void }) {
const { t } = useTranslation();
const { inbox, api, key } = useSocial();
@@ -31,7 +58,7 @@ export function MusicShareSheet({ attachment, url, close }: { attachment: MusicA
const send = useMutation({ mutationFn: async (peer: string) => {
if (peer === 'copy') { Clipboard.setString(url); setCopied(true); return; }
if (peer === 'external') { await Share.share({ title: attachment.title, message: url }); return; }
let id = ids.current.get(peer); if (!id) { id = uuidv7(); ids.current.set(peer, id); }
let id = ids.current.get(peer); if (!id) { id = eventId(Date.now()); ids.current.set(peer, id); }
await api.send(peer, { id, text: attachment.title, attachment_kind: attachment.kind, attachment_id: attachment.id });
setSent(v => [...v, peer]); await cache.invalidateQueries({ queryKey: key });
} });
@@ -49,6 +76,7 @@ export function MusicShareSheet({ attachment, url, close }: { attachment: MusicA
}
export function SocialScreen({ root = false }: { root?: boolean }) {
const dock = useContext(ChatDockContext);
const { t } = useTranslation();
const colors = useColors();
const router = useRouter();
@@ -62,12 +90,20 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
const act = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
if (selected) return <Screen><Button title={t('social.back')} variant="ghost" onPress={() => setPeer('')} /><Chat key={selected.id} person={selected} /></Screen>;
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
return <Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
{!root && <Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />}
<H1>{t('social.title')}</H1><Muted>{t('social.subtitle')}</Muted>
<View style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
<Muted>{t('social.activity_visibility')}</Muted>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} disabled={!inbox.data || visibility.isPending} onPress={() => visibility.mutate(v)} />)}</View>
<Muted>{t('social.activity_privacy_hint')}</Muted>
<ErrorBox>{visibility.error ? errorText(visibility.error) : null}</ErrorBox>
</View>
<ListeningPanel />
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
<Button title={t('social.people')} variant={notifications ? 'ghost' : 'primary'} onPress={() => setNotifications(false)} />
<Button title={`${t('social.notifications')} ${inbox.data?.unread || ''}`} variant={notifications ? 'primary' : 'ghost'} onPress={() => setNotifications(true)} />
@@ -87,7 +123,15 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
{!people.length && inbox.data && !results.isFetching && <Muted>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</Muted>}
{people.map(p => <View key={p.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
<Text style={{ color: colors.text, fontWeight: '600', fontSize: 17 }}>{p.display_name}</Text><Muted>@{p.username} · {t(`social.rel_${p.relationship}`)}</Muted>
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => setPeer(p.id)} />}
{p.activity && <View style={{ borderLeftWidth: 2, borderLeftColor: colors.ok, paddingLeft: 12, gap: 6 }}>
<Text style={{ color: colors.ok, fontSize: 12 }}>{t('social.listening')}</Text>
<Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-track', params: { id: p.activity!.track_id } })}><Text style={{ color: colors.accent2 }}>{p.activity.track_title}</Text></Pressable>
{!!p.activity.artist && <Muted>{p.activity.artist}</Muted>}
{p.activity.context === 'wave' && <Muted>{t('wave.title')}</Muted>}
{p.activity.context === 'playlist' && p.activity.playlist_id && <Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-playlist', params: { owner: p.id, id: p.activity!.playlist_id } })}><Text style={{ color: colors.accent2 }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Text></Pressable>}
</View>}
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => { if (!dock(p.id)) setPeer(p.id); }} />}
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
{p.relationship === 'none' && <Button title={t('social.add')} disabled={act.isPending} onPress={() => act.mutate({ action: 'request', target_id: p.id })} />}
{p.relationship === 'incoming' && <Button title={t('social.accept')} disabled={act.isPending} onPress={() => act.mutate({ action: 'accept', target_id: p.id })} />}
{['friend', 'incoming', 'outgoing'].includes(p.relationship) && <Button title={t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'remove', target_id: p.id })} />}
@@ -96,52 +140,9 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
</>}
</ScrollView></Screen>;
}
function Chat({ person }: { person: SocialPerson }) {
const { t } = useTranslation();
const colors = useColors();
const { server } = useSessionCtx();
const { api, key, user, active } = useSocial();
const router = useRouter();
const cache = useQueryClient();
const errorText = useErrorText();
const [text, setText] = useState('');
const [focused, setFocused] = useState(false);
useFocusEffect(useCallback(() => { setFocused(true); return () => setFocused(false); }, []));
const pending = useRef<{ id: string; text: string } | null>(null);
const messages = useInfiniteQuery({ queryKey: [...key, 'chat', person.id], initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) => api.messages(person.id, pageParam), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined, enabled: active && focused, refetchInterval: active && focused ? 8_000 : false });
const list = [...(messages.data?.pages ?? [])].reverse().flat();
const last = list.at(-1)?.seq;
const unread = list.some(m => m.sender_id !== user.id && !m.read);
useEffect(() => {
if (last && unread && active && focused) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey: key })).catch(() => undefined);
}, [last, unread, active, focused, api, person.id, messages.dataUpdatedAt]);
const send = useMutation({ mutationFn: async () => {
if (!pending.current || pending.current.text !== text) pending.current = { id: uuidv7(), text };
return api.send(person.id, { ...pending.current, attachment_kind: 'none' });
}, onSuccess: async () => { setText(''); pending.current = null; await cache.invalidateQueries({ queryKey: key }); } });
const scroll = useRef<ScrollView>(null);
const [scrollBottom, setScrollBottom] = useState(true);
useEffect(() => { setScrollBottom(true); }, [last]);
return <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<View style={{ padding: 16, gap: 8 }}><H1>{person.display_name}</H1><Muted>{t('social.chat_hint')}</Muted><ErrorBox>{messages.error || send.error ? errorText(messages.error ?? send.error) : null}</ErrorBox></View>
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 16, gap: 12 }} onContentSizeChange={() => { if (scrollBottom) scroll.current?.scrollToEnd({ animated: false }); }}>
{messages.hasNextPage && <Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={() => { setScrollBottom(false); void messages.fetchNextPage(); }} />}
{messages.isPending && <Muted>{t('social.loading')}</Muted>}
{messages.isSuccess && !list.length && <Muted>{t('social.first_message')}</Muted>}
{list.map(m => <View key={m.id} style={{ alignSelf: m.sender_id === user.id ? 'flex-end' : 'flex-start', maxWidth: '88%', padding: 14, borderRadius: 16, backgroundColor: m.sender_id === user.id ? colors['accent-soft'] : colors.surface2, gap: 8 }}>
<Text style={{ color: colors.text, fontSize: 15 }}>{m.text}</Text>
{attachmentPath(m) && <Button title={t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} variant="secondary" onPress={() => {
if (!server || !m.attachment_id) return;
if (m.attachment_kind === 'track') router.push({ pathname: '/social-track', params: { id: m.attachment_id } });
else router.push({ pathname: '/social-playlist', params: { id: m.attachment_id } });
}} />}
<Muted>{new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{m.sender_id === user.id ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</Muted>
</View>)}
</ScrollView>
<View style={{ padding: 16, gap: 12, borderTopWidth: 1, borderTopColor: colors.line }}>
<Field label={t('social.message')} value={text} onChangeText={setText} maxLength={4000} multiline editable={!send.isPending} />
<Button title={t('social.send')} disabled={!text.trim()} busy={send.isPending} onPress={() => send.mutate()} />
</View>
</KeyboardAvoidingView>;
function Chat({ person, active: visible = true }: { person: SocialPerson; active?: boolean }) {
const {api,key,user,active}=useSocial(); const {server}=useSessionCtx();
const [focused,setFocused]=useState(false);
useFocusEffect(useCallback(()=>{setFocused(true);return()=>setFocused(false);},[]));
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} server={server??''} active={visible&&active&&focused}/>;
}
+14
View File
@@ -0,0 +1,14 @@
import * as FileSystem from 'expo-file-system';
import { sha256 } from 'js-sha256';
import { chatDraftKey } from '@pipu/core';
const directory = `${FileSystem.documentDirectory}chat-drafts/`;
const path = (server: string, user: string, peer: string) => `${directory}${sha256(chatDraftKey(server,user,peer))}.txt`;
export async function readDraft(server: string, user: string, peer: string) {
try { return await FileSystem.readAsStringAsync(path(server,user,peer)); } catch { return ''; }
}
export async function saveDraft(server: string, user: string, peer: string, text: string) {
if (!text) { await FileSystem.deleteAsync(path(server,user,peer), { idempotent: true }); return; }
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
await FileSystem.writeAsStringAsync(path(server,user,peer),text);
}
+86 -5
View File
@@ -1,6 +1,10 @@
import NetInfo from '@react-native-community/netinfo';
import {
EventBuffer,
createActivityPublisher,
type ActivityPublisher,
ListeningCoordinator,
type ListeningRoom,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveInfo,
@@ -32,6 +36,7 @@ export { offlineWave };
export type { WaveInfo } from '@pipu/core';
export interface PlayerState {
listening?: import('@pipu/core').ListeningClientState;
ready: boolean;
track: Track | null;
queue: Track[];
@@ -72,6 +77,11 @@ async function quality(): Promise<Quality> {
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
*/
class Controller {
private activity: ActivityPublisher | null = null;
private listening: ListeningCoordinator | null = null;
private interrupted = false;
private remoteRoom: string | null = null;
private queueVersion = 0;
private session: AuthSession | null = null;
private server = '';
private events: EventBuffer | null = null;
@@ -104,12 +114,27 @@ class Controller {
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
this.activity?.update(this.state);
this.listening?.update();
for (const fn of this.listeners) fn();
}
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
attach(session: AuthSession | null, server: string, userId: string | null) {
if (this.session === session) return;
this.activity?.dispose();
this.listening?.dispose();
this.listening = null;
this.activity = session && userId ? createActivityPublisher(session, eventId(Date.now())) : null;
if (session && this.activity) {
this.listening = new ListeningCoordinator(session, this.activity.deviceId, {
read: this.getState,
pause: () => { this.queueVersion++; this.leaveWave(); void TrackPlayer.pause().catch(() => undefined); },
apply: (room, position, current) => this.syncListening(room, position, current),
changed: listening => this.update({ listening }),
});
this.listening.start();
}
void this.events?.flush();
this.session = session;
this.server = server;
@@ -240,35 +265,49 @@ class Controller {
}
async playList(tracks: Track[], index: number, context: PlayContext) {
if (this.listening?.locked) return;
const version = ++this.queueVersion;
const current = () => version === this.queueVersion && !this.listening?.locked;
const list = tracks.filter(playable);
const target = tracks[index];
if (!target || !list.length) return;
await this.setup();
if (!current()) return;
const native = await this.toRntp(list);
if (!current()) return;
this.leaveWave();
this.finishAttempt(this.lastPositionMs / 1000);
const start = Math.max(list.indexOf(target), 0);
this.update({ queue: list, context, index: start, track: list[start] ?? null });
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(list));
if (!current()) return;
await TrackPlayer.add(native);
if (!current()) return;
await TrackPlayer.skip(start);
if (!current()) return;
await TrackPlayer.play();
}
toggle() {
if (this.listening?.locked) return;
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
}
next() {
if (this.listening?.locked) return;
void TrackPlayer.skipToNext().catch(() => undefined);
}
async prev() {
if (this.listening?.locked) return;
const { position } = await TrackPlayer.getProgress();
if (this.listening?.locked) return;
if (position > 3) await TrackPlayer.seekTo(0);
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
}
seek(ms: number) {
if (this.listening?.locked) return;
const t = this.state.track;
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
this.attempt?.seek(ms);
@@ -306,6 +345,7 @@ class Controller {
// --- «Моя волна» ---
async startWave(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
if (!this.session) return;
await this.setup();
this.leaveWave();
@@ -335,6 +375,7 @@ class Controller {
}
async setWaveContext(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
const wave = this.state.wave;
if (!wave || !this.session) return this.startWave(activity, mood);
const version = this.wavePolicy.changeContext(wave.sessionId);
@@ -422,18 +463,28 @@ class Controller {
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
async restore() {
if (!this.session || this.state.track) return;
if (!this.session || this.state.track || this.listening?.locked) return;
const session = this.session;
const initial = this.state;
const current = () => this.session === session && this.state.queue === initial.queue && !this.state.track && !this.listening?.locked;
await this.setup();
if (!current()) return;
try {
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
const st = unwrap(await session.client.GET('/api/v1/me/playback-state'));
if (!current()) return;
if (!st.track_id || !st.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
if (!current()) return;
const tracks = res.items.filter(playable);
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
if (!tracks.length) return;
const native = await this.toRntp(tracks);
if (!current()) return;
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(tracks));
if (this.state.queue !== tracks || this.listening?.locked) return;
await TrackPlayer.add(native);
if (this.state.queue !== tracks || this.listening?.locked) return;
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
} catch {
// офлайн или нет сохранённого состояния
@@ -441,6 +492,7 @@ class Controller {
}
async reset() {
this.queueVersion++;
this.wavePolicy.activate(null);
this.attempt = null;
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
@@ -451,6 +503,35 @@ class Controller {
this.save();
void this.events?.flush();
}
play() { if (!this.listening?.locked) void TrackPlayer.play(); }
pause() { if (!this.listening?.locked) void TrackPlayer.pause(); }
setInterrupted(paused: boolean) { this.interrupted = paused; }
beginListeningAudio() { this.listening?.beginAudio(); }
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
if (!room.track_id) { await TrackPlayer.pause(); return; }
await this.setup();
if (!current()) return;
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
const track = unwrap(await this.session!.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
const native = await this.toRntp([track]);
if (!current()) return;
this.remoteRoom = room.id;
this.leaveWave(); this.finishAttempt(this.lastPositionMs / 1000);
await TrackPlayer.pause();
if (!current()) return;
this.update({ queue: [track], track, index: 0, durationMs: track.duration_ms, positionMs: 0, context: { type: 'queue' }, wave: null });
await TrackPlayer.reset();
if (!current()) return;
await TrackPlayer.add(native);
}
if (!current()) return;
const target = Math.min(position, Math.max(0, this.state.durationMs - 100));
if (Math.abs(this.state.positionMs - target) > 1500) await TrackPlayer.seekTo(target / 1000);
if (!current()) { await TrackPlayer.pause(); return; }
if (room.playing && !this.interrupted) { if (!this.state.playing) await TrackPlayer.play(); }
else if (this.state.playing) await TrackPlayer.pause();
}
}
export const player = new Controller();
+4 -3
View File
@@ -7,14 +7,15 @@ import { player } from './controller';
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
*/
export async function PlaybackService(): Promise<void> {
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play());
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop());
TrackPlayer.addEventListener(Event.RemotePlay, () => player.play());
TrackPlayer.addEventListener(Event.RemotePause, () => player.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => player.pause());
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
player.setInterrupted(paused || permanent);
if (permanent) void TrackPlayer.pause();
else if (paused) void TrackPlayer.pause();
});
+2
View File
@@ -3,6 +3,7 @@ import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { FriendRecommendations } from '../features/social/Social';
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../lib/session';
@@ -38,6 +39,7 @@ export function RecommendationSettings() {
const [editing, setEditing] = useState(false);
const errorText = useErrorText();
return <View>
<FriendRecommendations />
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
+15
View File
@@ -70,3 +70,18 @@ it('does not let a late context response replace a manually selected playlist',
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
expect(player.getState().context?.type).toBe('album');
});
it('does not restore a stale saved queue after manual playback starts', async () => {
const { player } = await import('../src/player/controller');
const saved = deferred<unknown>();
const get = vi.fn().mockReturnValue(saved.promise);
const post = vi.fn();
player.attach({ client: { GET: get, POST: post } } as unknown as AuthSession, 'http://localhost', null);
const restoring = player.restore();
await vi.waitFor(() => expect(get).toHaveBeenCalled());
await player.playList([track('chosen')], 0, { type: 'queue' });
saved.resolve({ data: { track_id: 'old', queue: ['old'], position_ms: 500 }, response: new Response() });
await restoring;
expect(player.getState().track?.id).toBe('chosen');
expect(post).not.toHaveBeenCalledWith('/api/v1/tracks/batch', expect.anything());
});
+5
View File
@@ -3,6 +3,7 @@ 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.emulateMedia({ reducedMotion: 'reduce' });
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');
@@ -21,6 +22,10 @@ for (const width of [320, 800, 1280]) {
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.getByRole('button', { name: 'Действия с сообщением', exact: true }).click();
await expect(page.getByRole('menuitem', { name: 'Удалить для всех', exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: '❤️', exact: true }).click();
await expect(page.getByRole('button', { name: '❤️ 1', exact: true })).toBeVisible();
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');
+69 -1
View File
@@ -1,4 +1,4 @@
import { AuthSession } from '@pipu/core';
import { AuthSession, refreshListening } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient } from '@tanstack/react-query';
import { createMemoryHistory } from '@tanstack/react-router';
@@ -15,6 +15,74 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup);
describe('social', () => {
it('follows a room with locked playback controls, independent volume and local exit', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, session, user } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
const id = crypto.randomUUID();
api.listeningRooms.set(id, { id, owner_id: bob.id, owner_name: 'bob', device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, position_ms: 5000, playing: false, updated_at: new Date().toISOString(), participants: [] });
api.listeningMembers.set(alice.id, id); api.listeningMembers.set(bob.id, id);
refreshListening(session);
await screen.findByText('Слушаете вместе с bob');
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(player).getByRole('button', { name: 'Слушать' })).toBeDisabled());
expect(within(player).getByRole('slider', { name: 'Громкость' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
await waitFor(() => expect(screen.queryByText('Слушаете вместе с bob')).not.toBeInTheDocument());
expect(api.listeningMembers.has(alice.id)).toBe(false);
});
it('edits, replies, reacts, searches, deletes and preserves a minimized draft', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Написать' }));
const composer = () => screen.getByRole('textbox', { name: 'Сообщение' });
await user.type(composer(), 'Первый вариант');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
await screen.findByText('Первый вариант');
const actions = async (name: string) => {
await user.click((await screen.findAllByRole('button', { name: 'Действия с сообщением' }))[0]!);
await user.click(await screen.findByRole('menuitem', { name }));
};
await actions('Редактировать');
await user.clear(composer()); await user.type(composer(), 'Исправленный текст');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByText('Исправленный текст', { exact: true });
await actions('❤️');
await screen.findByRole('button', { name: '❤️ 1' });
await actions('Ответить');
await user.type(composer(), 'Ответ на музыку');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(api.messages).toHaveLength(2));
expect(api.messages[1]?.reply_to).toBe(api.messages[0]?.id);
await user.type(screen.getByRole('searchbox', { name: 'Поиск в переписке' }), 'Исправленный');
await waitFor(() => expect(screen.queryByText('Ответ на музыку', { exact: true })).not.toBeInTheDocument());
await actions('Удалить для всех');
await waitFor(() => expect(api.messages[0]?.deleted).toBe(true));
await user.clear(screen.getByRole('searchbox', { name: 'Поиск в переписке' }));
await user.type(composer(), 'Мой черновик');
await user.click(screen.getByRole('button', { name: 'Свернуть чат' }));
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'bob' }));
expect(composer()).toHaveValue('Мой черновик');
});
it('saves privacy and independent request admission preferences', async () => {
const alice = makeUser('alice', 'user');
const { api, user } = await renderApp([alice], '/friends', { signedInAs: alice });
const privacy = await screen.findByRole('group', { name: 'Показывать мой статус' });
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
await waitFor(() => expect(api.activityVisibility.get(alice.id)).toBe('friends'));
await user.click(screen.getByText('Слушать вместе', { exact: true }));
const requests = await screen.findByRole('group', { name: 'Принимать запросы и приглашения от' });
const auto = screen.getByRole('group', { name: 'Автоматически принимать от' });
await user.click(within(requests).getByRole('button', { name: 'Друзьям' }));
await waitFor(() => expect(api.listeningPreferences.get(alice.id)?.requests).toBe('friends'));
await waitFor(() => expect(within(auto).getByRole('button', { name: 'Всем' })).toBeEnabled());
await user.click(within(auto).getByRole('button', { name: 'Всем' }));
await waitFor(() => expect(api.listeningPreferences.get(alice.id)).toMatchObject({ requests: 'friends', auto_accept: 'everyone' }));
});
it('shows persistent notifications and acknowledges only the chosen event', async () => {
const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user');
+3 -1
View File
@@ -26,7 +26,7 @@ import { ConnectPage } from '../pages/ConnectPage';
import { TrackPage } from '../pages/TrackPage';
import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage';
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
import { ActivityPlaylistPage, PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage';
@@ -148,6 +148,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
};
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> });
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 /> });
@@ -295,6 +296,7 @@ const routeTree = rootRoute.addChildren([
setupPasswordRoute,
appRoute.addChildren([
homeRoute,
activityPlaylistRoute,
socialRoute,
trackRoute,
playlistsRoute,
@@ -0,0 +1,89 @@
import { attachmentPath, CHAT_REACTIONS, chatDraftKey, uuidv7, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Fragment, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDesktop } from '../../desktop';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Field, FormError, Menu } from '../../shared/ui/ui';
import s from './social.module.css';
export function ChatConversation({ person, api, queryKey, userId, compact = false, active = true }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; compact?: boolean; active?: boolean }) {
const { t, i18n } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText();
const server = useDesktop()?.server ?? window.location.origin;
const draftKey = chatDraftKey(server, userId, person.id);
const [text, setText] = useState(() => { try { return localStorage.getItem(draftKey) ?? ''; } catch { return ''; } });
const [reply, setReply] = useState<DirectMessage | null>(null); const [editing, setEditing] = useState<DirectMessage | null>(null);
const [search, setSearch] = useState(''); const [q, setQ] = useState(''); const [emojis, setEmojis] = useState(false);
const beforeEdit = useRef(''); const pending = useRef<{ id: string; text: string; reply_to?: string } | null>(null);
const typedAt = useRef(0);
const [visible, setVisible] = useState(document.visibilityState === 'visible');
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 250); return () => clearTimeout(timer); }, [search]);
useEffect(() => {
if (editing) return;
const timer = setTimeout(() => { try { if (text) localStorage.setItem(draftKey, text); else localStorage.removeItem(draftKey); } catch { /* Private mode can deny storage. */ } }, 250);
return () => clearTimeout(timer);
}, [text, draftKey, editing]);
useEffect(() => {
const timer = setTimeout(() => { void api.typing(person.id, active && visible && !!text.trim() && !editing && Date.now() - typedAt.current < 6000).catch(() => undefined); }, 400);
return () => clearTimeout(timer);
}, [text, active, visible, editing, person.id, api]);
useEffect(() => () => { void api.typing(person.id, false).catch(() => undefined); }, [api, person.id]);
const meta = useQuery({ queryKey: [...queryKey, 'typing', person.id], queryFn: () => api.chatMeta(person.id), enabled: active && visible, refetchInterval: active && visible ? 2000 : false });
const messages = useInfiniteQuery({ queryKey: [...queryKey, 'chat', person.id, q], initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) => api.messages(person.id, pageParam, q), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined, enabled: active, refetchInterval: active && visible ? 3000 : false });
const list = [...(messages.data?.pages ?? [])].reverse().flat(); const last = list.at(-1)?.seq;
const firstUnread = list.find(m => m.sender_id !== userId && !m.read)?.id;
useEffect(() => {
if (last && firstUnread && visible && active && !q) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey })).catch(() => undefined);
}, [last, firstUnread, visible, active, q, api, person.id, cache, messages.dataUpdatedAt]);
const end = useRef<HTMLDivElement>(null);
useEffect(() => { if (active && !q) end.current?.scrollIntoView?.({ block: 'nearest' }); }, [last, active, q]);
const refresh = () => cache.invalidateQueries({ queryKey });
const update = useMutation({ mutationFn: (input: { id: string; body: ChatMessageUpdate }) => api.updateMessage(person.id, input.id, input.body), onSuccess: refresh });
const send = useMutation({ mutationFn: async () => {
if (editing) { await api.updateMessage(person.id, editing.id, { action: 'edit', text }); return; }
if (!pending.current || pending.current.text !== text || pending.current.reply_to !== reply?.id) pending.current = { id: uuidv7(), text, ...(reply ? { reply_to: reply.id } : {}) };
await api.send(person.id, { ...pending.current, attachment_kind: 'none' });
}, onSuccess: async () => { setText(editing ? beforeEdit.current : ''); setEditing(null); setReply(null); pending.current = null; await refresh(); } });
const cancelEdit = () => { setText(beforeEdit.current); setEditing(null); };
const mutate = (id: string, body: ChatMessageUpdate) => { if (!update.isPending) update.mutate({ id, body }); };
return <section className={s.chat} aria-label={t('social.chat')}>
{!compact && <header><strong>{person.display_name}</strong><p className={s.muted}>{t('social.chat_hint')}</p></header>}
<div className={s.chatSearch}><Field label={t('social.chat_search')} value={search} onChange={setSearch} type="search" maxLength={100} /></div>
<FormError>{messages.error || send.error || update.error ? errorText(messages.error ?? send.error ?? update.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(q ? 'social.chat_no_results' : 'social.first_message')}</p>}
{list.map((m, i) => <Fragment key={m.id}>
{(i === 0 || new Date(list[i - 1]!.created_at).toDateString() !== new Date(m.created_at).toDateString()) && <time className={s.date}>{new Date(m.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'long', year: 'numeric' })}</time>}
{m.id === firstUnread && !q && <div className={s.date}>{t('social.new_messages')}</div>}
<article id={`message-${m.id}`} className={`${s.bubble} ${m.sender_id === userId ? s.mine : ''} ${m.deleted ? s.deleted : ''}`}>
{m.reply_to && <blockquote className={s.quote}>{m.reply_preview || t('social.message_deleted')}</blockquote>}
<div className={s.messageText}>{m.deleted ? t('social.message_deleted') : m.text}</div>
{!m.deleted && 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>}
{!m.deleted && !!m.reactions?.length && <div className={s.reactions}>{m.reactions.map(r => <button key={r.emoji} type="button" aria-pressed={r.mine} disabled={update.isPending} onClick={() => mutate(m.id, { action: 'react', emoji: r.emoji as typeof CHAT_REACTIONS[number] })}>{r.emoji} {r.count}</button>)}</div>}
<div className={s.messageFoot}><small className={s.muted}>{new Date(m.created_at).toLocaleTimeString(i18n.language, { hour: '2-digit', minute: '2-digit' })}{m.edited_at && !m.deleted ? ` · ${t('social.edited')}` : ''}{m.sender_id === userId ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</small>
{!m.deleted && <Menu portal label={t('social.message_actions')} trigger="•••" items={[
{ label: t('social.reply'), onSelect: () => { if (editing) cancelEdit(); setReply(m); } },
...CHAT_REACTIONS.map(emoji => ({ label: emoji, onSelect: () => mutate(m.id, { action: 'react', emoji }) })),
...(m.sender_id === userId ? [{ label: t('social.edit_message'), onSelect: () => { beforeEdit.current = text; setEditing(m); setReply(null); setText(m.text); } }, { label: t('social.delete_message'), danger: true, onSelect: () => mutate(m.id, { action: 'delete' }) }] : []),
]} />}
</div>
</article>
</Fragment>)}
{send.isPending && !editing && !list.some(m => m.id === pending.current?.id) && <article className={`${s.bubble} ${s.mine} ${s.sending}`} role="status">{text} · …</article>}
<div ref={end} />
</div>
<div className={s.typing} role="status" aria-live="polite">{active && meta.data?.typing ? `${person.display_name} ${t('social.typing')}` : ''}</div>
{(reply || editing) && <div className={s.replyBar}><div><small>{t(editing ? 'social.editing' : 'social.replying')}</small><p>{(editing ?? reply)?.text}</p></div><Button variant="ghost" onClick={() => editing ? cancelEdit() : setReply(null)} aria-label={t(editing ? 'social.cancel_edit' : 'social.cancel_reply')}>×</Button></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 => { typedAt.current = Date.now(); setText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); } }} />
<Button variant="ghost" aria-label={t('social.emoji')} aria-expanded={emojis} onClick={() => setEmojis(v => !v)}>☺</Button>
{emojis && <div className={s.reactions}>{CHAT_REACTIONS.map(emoji => <button key={emoji} type="button" onClick={() => { setText(v => (v + emoji).slice(0, 4000)); setEmojis(false); }}>{emoji}</button>)}</div>}
<Button type="submit" variant="primary" busy={send.isPending} disabled={!text.trim()}>{t(editing ? 'playlists.save' : 'social.send')}</Button>
</form>
</section>;
}
@@ -0,0 +1,61 @@
import { socialApi, uuidv7, type ListeningPreferences, type SocialPerson } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useDesktop } from '../../desktop';
import { usePlayer, usePlayerState } from '../../player/context';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import s from './social.module.css';
function useListening() {
const session = useSession(); const user = useUser(); const server = useDesktop()?.server ?? '';
const api = useMemo(() => socialApi(session), [session]);
const key = ['listening', server, user.id] as const;
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
return { api, key, query, user };
}
export function ListeningPanel() {
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
const { api, key, query, user } = useListening();
const prefs = query.data?.preferences;
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const action = useMutation({ mutationFn: (input: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: uuidv7(), action: input.action, target_id: input.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <div className={s.stack}>
<details className={s.privacy}><summary>{t('social.together')}</summary><p className={s.muted}>{t('social.stream_prefs_hint')}</p>
{(['requests', 'auto_accept'] as const).map(field => <fieldset key={field} className={s.privacy} disabled={!prefs || save.isPending}><legend>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={(prefs?.[field] ?? 'nobody') === v} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => prefs && save.mutate({ ...prefs, [field]: v })}>{t(`social.visibility_${v}`)}</Button>)}</div></fieldset>)}
<label><input type="checkbox" checked={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onChange={e => prefs && save.mutate({ ...prefs, autoplay: e.target.checked })} /> {t('social.stream_autoplay')}</label>
</details>
{query.data?.requests.map(r => <article key={r.id} className={s.person}><strong>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</strong><div className={s.actions}>{r.recipient_id === user.id ? <><Button disabled={action.isPending} onClick={() => action.mutate({ action: 'accept', target: r.id })}>{t('social.accept')}</Button><Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'decline', target: r.id })}>{t('social.decline')}</Button></> : <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'cancel', target: r.id })}>{t('form.cancel')}</Button>}</div></article>)}
<FormError>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</FormError>
</div>;
}
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
const { t } = useTranslation(); const [open, setOpen] = useState(false);
return <><Button variant="ghost" onClick={() => setOpen(true)}>{t('social.together')}</Button>{open && <ListenTogetherDialog person={person} close={() => setOpen(false)} />}</>;
}
function ListenTogetherDialog({ person, close }: { person: SocialPerson; close: () => void }) {
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
const { api, key } = useListening(); const state = usePlayerState();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
const [id] = useState(uuidv7);
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend>
<label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label>
<label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label>
</fieldset><p className={s.muted}>{t('social.stream_hint')}</p>
<FormError>{action.error ? errorText(action.error) : null}</FormError>
{action.isSuccess ? <p role="status">{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</p> : <Button variant="primary" busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onClick={() => action.mutate()}>{t('social.send')}</Button>}
</Dialog>;
}
export function ListeningBanner() {
const { t } = useTranslation(); const state = usePlayerState(); const player = usePlayer();
const session = useSession(); const errorText = useErrorText(); const room = state.listening?.room;
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: uuidv7(), action: room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'end' : 'leave', target_id: room.id }) });
if (!room) return null;
return <section className={s.stream} aria-label={t('social.together')}><div><strong>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</strong><p className={s.muted}>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</p><details><summary>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</summary>{room.participants.map(p => <div key={p.id}>{p.name}</div>)}</details></div>
<div className={s.actions}>{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button onClick={() => player.beginListeningAudio()}>{t('social.stream_start_audio')}</Button>}<Button busy={action.isPending} variant="ghost" onClick={() => action.mutate()}>{t(room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'social.stream_end' : 'social.stream_leave')}</Button></div>
<FormError>{action.error ? errorText(action.error) : null}</FormError>
</section>;
}
+50 -51
View File
@@ -1,6 +1,6 @@
import { attachmentPath, socialApi, uuidv7, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { attachmentPath, socialApi, uuidv7, type ActivityVisibility, 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 { Link, useNavigate, useRouterState } 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';
@@ -8,6 +8,8 @@ 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';
import { ListeningPanel, ListenTogetherButton } from './Listening';
import { ChatConversation } from './ChatConversation';
const scopes = new WeakMap<object, number>();
let nextScope = 0;
@@ -25,12 +27,35 @@ function useSocial() {
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
export function FriendRecommendations() {
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <div><label><input type="checkbox" checked={inbox.data?.friends_recommendations ?? true} disabled={!inbox.data || save.isPending} onChange={e => save.mutate(e.target.checked)} /> {t('social.friends_recommendations')}</label><p className={s.muted}>{t('social.friends_recommendations_hint')}</p><FormError>{save.error ? errorText(save.error) : null}</FormError></div>;
}
type ShareValue = { attachment: MusicAttachment; url: string };
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
const ChatContext = createContext<(id: string) => void>(() => {});
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>;
const [chat, setChat] = useState<{ peer: string | null; open: boolean }>({ peer: null, open: false });
return <ShareContext.Provider value={setShare}><ChatContext.Provider value={id => setChat({ peer: id, open: true })}>{children}<ChatDock chat={chat} change={setChat} />{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ChatContext.Provider></ShareContext.Provider>;
}
function ChatDock({ chat, change }: { chat: { peer: string | null; open: boolean }; change: (value: { peer: string | null; open: boolean }) => void }) {
const { inbox } = useSocial(); const { t } = useTranslation();
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
const selected = friends.find(p => p.id === chat.peer);
const unread = friends.reduce((sum, p) => sum + p.unread, 0);
const pathname = useRouterState({ select: state => state.location.pathname });
useEffect(() => { if (window.matchMedia?.('(max-width: 1023px)').matches) change({ ...chat, open: false }); }, [pathname]);
return <aside className={`${s.dock} ${chat.open ? s.dockOpen : ''}`} aria-label={t('social.messages')}>
<div className={s.dockHeader}><button type="button" aria-expanded={chat.open} onClick={() => change({ ...chat, open: !chat.open })}>{selected?.display_name ?? t('social.messages')}{unread ? ` · ${unread}` : ''}</button>{chat.open && <><button type="button" onClick={() => change({ peer: null, open: true })} aria-label={t('social.people')}>☰</button><button type="button" onClick={() => change({ ...chat, open: false })} aria-label={t('social.minimize')}>−</button></>}</div>
<div hidden={!chat.open}>
{selected ? <Chat key={selected.id} person={selected} compact active={chat.open} /> : <div className={s.dockList}>{friends.map(p => <button key={p.id} type="button" onClick={() => change({ peer: p.id, open: true })}><span className={s.avatar} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><span>{p.display_name}</span>{p.unread ? <small>{p.unread}</small> : null}</button>)}{!friends.length && <p className={s.empty}>{t('social.no_friends_share')}</p>}</div>}
</div>
</aside>;
}
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
const { t } = useTranslation();
@@ -70,6 +95,7 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const navigate = useNavigate();
const showChat = useContext(ChatContext);
const errorText = useErrorText();
const [tab, setTab] = useState<'people' | 'notifications'>('people');
const [search, setSearch] = useState('');
@@ -77,11 +103,18 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
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 visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
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 } }); };
const open = (id: string) => { setTab('people'); setSearch(''); setQ(''); if (inbox.data?.people.some(p => p.id === id && p.relationship === 'friend')) showChat(id); else 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>
<fieldset className={s.privacy} disabled={!inbox.data || visibility.isPending}>
<legend>{t('social.activity_visibility')}</legend>
<div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={(inbox.data?.activity_visibility ?? 'nobody') === v} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => visibility.mutate(v)}>{t(`social.visibility_${v}`)}</Button>)}</div>
<p className={s.muted}>{t('social.activity_privacy_hint')}</p>
<FormError>{visibility.error ? errorText(visibility.error) : null}</FormError>
</fieldset>
<ListeningPanel />
<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>
@@ -94,14 +127,13 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
<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}>
</div> : <div>
<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>;
}
@@ -109,8 +141,16 @@ function Person({ person: p, busy, open, act, selected }: { person: SocialPerson
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>
{p.activity && <div className={s.activity}>
<span className={s.listening}>{t('social.listening')}</span>
<Link to="/track/$id" params={{ id: p.activity.track_id }}>{p.activity.track_title}</Link>
{p.activity.artist && <span className={s.muted}>{p.activity.artist}</span>}
{p.activity.context === 'wave' && <span className={s.muted}>{t('wave.title')}</span>}
{p.activity.context === 'playlist' && p.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: p.id }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Link>}
</div>}
<div className={s.actions}>
{p.relationship === 'friend' && <Button onClick={open}>{t('social.chat')}{p.unread ? ` · ${p.unread}` : ''}</Button>}
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
{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>}
@@ -118,48 +158,7 @@ function Person({ person: p, busy, open, act, selected }: { person: SocialPerson
</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>;
function Chat({ person, compact = false, active = true }: { person: SocialPerson; compact?: boolean; active?: boolean }) {
const { api,key,user }=useSocial();
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} compact={compact} active={active} />;
}
@@ -4,6 +4,53 @@
.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; }
.privacy { border: 1px solid var(--line); border-radius: 16px; padding: 16px; margin: 0; min-width: 0; }
.privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
.privacy p { margin-bottom: 0; }
.activity { display: grid; gap: 5px; border-left: 2px solid var(--ok); padding-left: 12px; overflow-wrap: anywhere; }
.activity a { color: var(--accent2); text-decoration: none; }
.activity a:hover { text-decoration: underline; }
.listening { font-size: 12px; color: var(--ok); }
.stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; }
.choice { display: flex; gap: 8px; padding: 10px 0; align-items: center; }
.dock { position: fixed; right: 24px; bottom: 104px; z-index: 45; width: 220px; border-radius: 16px; border: 1px solid var(--line2); background: var(--elev); box-shadow: var(--shadow); overflow: hidden; }
.dockOpen { width: min(390px, calc(100vw - 32px)); max-height: calc(100dvh - 124px); }
.dockHeader { display: flex; align-items: center; min-height: 48px; padding: 0 8px; background: var(--surface2); gap: 4px; }
.dockHeader button { border: 0; background: transparent; color: var(--text); font: inherit; padding: 12px; cursor: pointer; }
.dockHeader button:first-child { flex: 1; text-align: left; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dock .chat { border: 0; border-radius: 0; display: flex; flex-direction: column; max-height: calc(100dvh - 174px); }
.dock .messages { flex: 1; min-height: 60px; max-height: min(40vh, 360px); padding: 14px; }
.dock .composer { padding: 12px; flex-wrap: wrap; }
.dock .composer textarea { flex-basis: 100%; }
.dockList { max-height: 50vh; overflow-y: auto; padding: 8px; }
.dockList button { display: flex; gap: 12px; align-items: center; border: 0; background: transparent; color: var(--text); padding: 12px; width: 100%; cursor: pointer; border-radius: 10px; text-align: left; font: inherit; }
.dockList button:hover { background: var(--hover); }
.dockList small { margin-left: auto; color: var(--accent2); }
.dockOpen { animation: chatReveal .22s cubic-bezier(.2,.8,.2,1); transform-origin: bottom right; }
.bubble { animation: messageIn .18s ease-out; }
.chatSearch { padding: 10px 14px; border-bottom: 1px solid var(--line); }
.chatSearch label { font-size: 11px; }
.date { align-self: center; color: var(--text3); font-size: 11px; margin: 6px 0; }
.quote { margin: 0; padding-left: 10px; border-left: 2px solid var(--accent2); color: var(--text2); font-size: 12px; max-height: 60px; overflow: hidden; }
.messageText { user-select: text; }
.messageFoot { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
.messageFoot button { font-size: 12px; min-width: 24px; }
.deleted { font-style: italic; opacity: .7; }
.sending { opacity: .6; }
.reactions { display: flex; flex-wrap: wrap; gap: 5px; }
.reactions button { border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); color: var(--text); padding: 5px 9px; cursor: pointer; transition: transform .15s, background .15s; }
.reactions button:hover { transform: translateY(-2px); }
.reactions button[aria-pressed='true'] { background: var(--accent-soft); border-color: var(--accent2); }
.typing { min-height: 19px; padding: 0 14px; color: var(--text3); font-size: 11px; }
.replyBar { display: flex; gap: 10px; align-items: center; padding: 8px 14px; background: var(--accent-soft); }
.replyBar > div { flex: 1; min-width: 0; }
.replyBar small { color: var(--accent2); }
.replyBar p { margin: 3px 0; font-size: 12px; max-height: 35px; overflow: hidden; }
@keyframes chatReveal { from { opacity: 0; transform: translateY(16px) scale(.97); } to { opacity: 1; transform: none; } }
@keyframes messageIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media(prefers-reduced-motion: reduce) { .dockOpen, .bubble { animation: none; } .reactions button { transition: none; } .reactions button:hover { transform: none; } }
@media(max-width: 760px) { .dock { bottom: 162px; right: 12px; width: 160px; } .dockOpen { width: calc(100vw - 24px); } .dock .messages { max-height: 32vh; } }
@media(max-width: 760px) { .dockOpen { max-height: calc(100dvh - 182px); } .dock .chat { max-height: calc(100dvh - 232px); } }
.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); }
+16 -3
View File
@@ -1,5 +1,5 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { useState, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
@@ -57,7 +57,20 @@ export function PlaylistPage({ id, shared = false }: { id: string; shared?: bool
</div>;
}
function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
export function ActivityPlaylistPage({ userId }: { userId: string }) {
const session = useSession();
const errorText = useErrorText();
const { t } = useTranslation();
const query = useQuery({ queryKey: ['activity-playlist', session.state.status === 'signed_in' ? session.state.user.id : '', userId], queryFn: async () => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })), staleTime: 0, refetchInterval: 15_000 });
return <div className={s.page}>
<Link to="/friends">← {t('social.title')}</Link>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <PageSkeleton />}
{query.data && !query.isError && <PlaylistDetail key={query.data.id} playlist={query.data} readOnly activity />}
</div>;
}
function PlaylistDetail({ playlist, readOnly = false, shareId, activity = false }: { playlist: Playlist; readOnly?: boolean; shareId?: string; activity?: boolean }) {
const openShare = useMusicShare();
const { t } = useTranslation();
const session = useSession();
@@ -108,7 +121,7 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
<div className={s.toolbar}>
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
<Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>
{!activity && <Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>}
{!readOnly && <>
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
+2
View File
@@ -13,6 +13,7 @@ import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css';
import { Onboarding } from './Onboarding';
import { FriendRecommendations } from '../features/social/Social';
import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
@@ -371,6 +372,7 @@ function RecommendationSettings() {
const errorText = useErrorText();
const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 });
return <section className={s.section}>
<FriendRecommendations />
{editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} />
: <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>}
<FormError>{options.error ? errorText(options.error) : null}</FormError>
+67 -2
View File
@@ -1,5 +1,9 @@
import {
EventBuffer,
createActivityPublisher,
type ActivityPublisher,
ListeningCoordinator,
type ListeningRoom,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveOutcome,
@@ -35,6 +39,10 @@ const PRELOAD_BEFORE_MS = 20_000;
* Media Session и сохранение «трек и момент» (локально и на сервере).
*/
export class Player {
private activity: ActivityPublisher | null = null;
private listening: ListeningCoordinator | null = null;
private applyingSync = false;
private remoteRoom: string | null = null;
readonly offline: DesktopOffline | null;
private retryEvents: (() => Promise<void>) | null = null;
private readonly engine: AudioEngine;
@@ -93,6 +101,17 @@ export class Player {
/** Запуск фоновой отправки событий (парный вызов — dispose). */
start() {
this.activity ??= createActivityPublisher(this.session);
this.activity.update(this.state);
if (!this.listening) {
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
read: this.getState,
pause: () => { this.restoreVersion++; this.leaveWave(); this.audio.pause(); },
apply: (room, position, current) => this.syncListening(room, position, current),
changed: listening => this.update({ listening }),
});
this.listening.start();
}
this.offline?.activate();
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
if (this.retryEvents) {
@@ -116,6 +135,8 @@ export class Player {
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
this.activity?.update(this.state);
this.listening?.update();
for (const fn of this.listeners) fn();
this.media.syncDesktopMedia();
}
@@ -135,6 +156,7 @@ export class Player {
/** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */
playList(tracks: Track[], index: number, context: PlayContext) {
if (this.listening?.locked && !this.applyingSync) return;
const playable = tracks.filter((t) => t.available && t.stream_url);
const target = tracks[index];
if (!target || !playable.length) return;
@@ -151,6 +173,7 @@ export class Player {
/** Запустить волну (или перезапустить с новым контекстом). */
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
if (this.listening?.locked) return;
this.restoreVersion++;
this.finishAttempt('skip');
this.leaveWave();
@@ -190,6 +213,7 @@ export class Player {
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
*/
async setWaveContext(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
const wave = this.state.wave;
if (!wave) return this.startWave(activity, mood);
this.update({ wave: { ...wave, activity, mood } });
@@ -215,6 +239,7 @@ export class Player {
}
stopWave() {
if (this.listening?.locked) return;
this.leaveWave();
this.pause();
}
@@ -247,11 +272,13 @@ export class Player {
}
toggle() {
if (this.listening?.locked && !this.applyingSync) return;
if (this.state.playing) this.pause();
else this.play();
}
play() {
if (this.listening?.locked && !this.applyingSync) return;
if (!this.queue.current) return;
if (!this.audio.src || !this.attempt) {
void this.load(true, this.state.positionMs);
@@ -263,10 +290,12 @@ export class Player {
}
pause() {
if (this.listening?.locked && !this.applyingSync) return;
this.audio.pause();
}
next() {
if (this.listening?.locked) return;
this.finishAttempt('skip');
if (this.queue.next()) {
this.syncQueue();
@@ -278,6 +307,7 @@ export class Player {
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
prev() {
if (this.listening?.locked) return;
if (this.audio.currentTime > 3) {
this.seek(0);
return;
@@ -290,6 +320,7 @@ export class Player {
}
jump(index: number) {
if (this.listening?.locked) return;
this.finishAttempt('skip');
this.queue.jump(index);
this.syncQueue();
@@ -297,6 +328,7 @@ export class Player {
}
seek(ms: number) {
if (this.listening?.locked && !this.applyingSync) return;
const track = this.queue.current;
if (!track) return;
const to = Math.min(Math.max(ms, 0), track.duration_ms);
@@ -323,12 +355,14 @@ export class Player {
}
toggleShuffle() {
if (this.listening?.locked) return;
this.queue.setShuffle(!this.queue.shuffle);
this.syncQueue();
this.saveLocal();
}
cycleRepeat() {
if (this.listening?.locked) return;
const order: RepeatMode[] = ['off', 'all', 'one'];
this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!;
this.syncQueue();
@@ -363,13 +397,13 @@ export class Player {
// --- загрузка трека ---
private async load(autoplay: boolean, startMs = 0) {
private async load(autoplay: boolean, startMs = 0, current = () => true) {
const track = this.queue.current;
if (!track?.stream_url) return;
const version = this.restoreVersion;
if (this.offline) {
await this.offline.ready.catch(() => undefined);
if (this.queue.current !== track || version !== this.restoreVersion) return;
if (!current() || this.queue.current !== track || version !== this.restoreVersion) return;
}
this.offline?.keep([track.id]);
const source = this.offline?.local(track).stream_url ?? track.stream_url;
@@ -396,6 +430,7 @@ export class Player {
try {
const resumed = this.engine.resume();
if (resumed) await resumed;
if (!current() || version !== this.restoreVersion) return;
await this.audio.play();
} catch {
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
@@ -649,6 +684,10 @@ export class Player {
}
dispose() {
this.listening?.dispose();
this.listening = null;
this.activity?.dispose();
this.activity = null;
this.restoreVersion++;
this.wavePolicy.activate(null);
this.fading = false;
@@ -661,4 +700,30 @@ export class Player {
this.media.dispose();
void this.events.flush();
}
beginListeningAudio() {
if (this.state.listening?.room?.playing) {
this.applyingSync = true; try { this.play(); } finally { this.applyingSync = false; }
}
this.listening?.beginAudio();
}
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
if (!room.track_id) { this.audio.pause(); return; }
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
if (!current()) return;
this.remoteRoom = room.id;
this.restoreVersion++; this.finishAttempt('skip'); this.leaveWave(); this.context = { type: 'queue' };
this.queue.set([track], 0); this.queue.repeat = 'off'; this.queue.shuffle = false; this.syncQueue();
await this.load(room.playing, Math.min(position, Math.max(0, track.duration_ms - 100)), current);
return;
}
if (!current()) return;
this.applyingSync = true;
try {
if (Math.abs(this.state.positionMs - position) > 1500) this.seek(Math.min(position, Math.max(0, this.state.durationMs - 100)));
if (room.playing && !this.state.playing) this.play();
if (!room.playing && this.state.playing) this.pause();
} finally { this.applyingSync = false; }
}
}
+1
View File
@@ -1,6 +1,7 @@
import type { WaveInfo, Track, PlayContext, RepeatMode } from '@pipu/core';
export interface PlayerState {
listening?: import('@pipu/core').ListeningClientState;
wave: WaveInfo | null;
track: Track | null;
queue: Track[];
+13 -3
View File
@@ -210,6 +210,7 @@ export function Menu({
header,
align = 'end',
triggerClassName,
portal = false,
}: {
label: string;
trigger: ReactNode;
@@ -217,8 +218,10 @@ export function Menu({
header?: ReactNode;
align?: 'start' | 'end';
triggerClassName?: string;
portal?: boolean;
}) {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<CSSProperties>();
const ref = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -246,7 +249,7 @@ export function Menu({
els[next]?.focus();
};
const menu = (
<div ref={menuRef} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
<div ref={menuRef} style={portal && !window.matchMedia?.('(max-width: 767px)').matches ? position : undefined} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
{header}
{items.map((it) => (
<button
@@ -273,11 +276,18 @@ export function Menu({
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
onClick={e => {
if (portal) {
const rect = e.currentTarget.getBoundingClientRect();
const bottom = Math.max(12, window.innerHeight - rect.top + 6);
setPosition({ position: 'fixed', zIndex: 60, top: 'auto', bottom, right: Math.max(12, window.innerWidth - rect.right), maxHeight: Math.max(120, window.innerHeight - bottom - 12), overflowY: 'auto' });
}
setOpen(v => !v);
}}
>
{trigger}
</button>
{open && (window.matchMedia?.('(max-width: 767px)').matches
{open && (portal || window.matchMedia?.('(max-width: 767px)').matches
? createPortal(menu, document.body)
: menu)}
</div>
+69 -3
View File
@@ -82,11 +82,21 @@ export function fakeApi(initial: FakeUser[]) {
const blocks = new Set<string>();
const messages: (components['schemas']['DirectMessage'] & { recipient: string })[] = [];
const notifications: (components['schemas']['SocialNotification'] & { recipient: string })[] = [];
const activityVisibility = new Map<string, components['schemas']['ActivityVisibility']>();
const friendRecommendations = new Map<string, boolean>();
const activity = new Map<string, components['schemas']['ListeningActivity'] & { device_id: string; position_ms: number }>();
const listeningPreferences = new Map<string, components['schemas']['ListeningPreferences']>();
const listeningRooms = new Map<string, components['schemas']['ListeningRoom']>();
const listeningMembers = new Map<string, string>();
const listeningRequests = new Map<string, components['schemas']['ListeningRequest'] & { guest_id: string }>();
const typing = new Map<string, number>();
const defaultListening = { requests: 'nobody', auto_accept: 'nobody', autoplay: true } as const;
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,
const visible = activityVisibility.get(p.id) === 'everyone' || activityVisibility.get(p.id) === 'friends' && f?.accepted;
return { id: p.id, username: p.username, display_name: p.display_name ?? p.username, ...(visible && activity.has(p.id) && !isBlocked(me,p.id) ? { activity: activity.get(p.id) } : {}),
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 };
};
@@ -216,7 +226,63 @@ export function fakeApi(initial: FakeUser[]) {
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
if (path === '/api/v1/me/social/activity/visibility' && body) { activityVisibility.set(user.id, body.visibility as components['schemas']['ActivityVisibility']); if (body.visibility === 'nobody') activity.delete(user.id); return new Response(null,{status:204}); }
if (path === '/api/v1/me/social/recommendations' && body) { friendRecommendations.set(user.id,!!body.enabled);return new Response(null,{status:204}); }
if (path === '/api/v1/me/social/activity' && body) {
if (activityVisibility.get(user.id) !== undefined && activityVisibility.get(user.id) !== 'nobody' && body.playing) {
const track = albumTracks.find(t => t.id === body.track_id); const playlist = playlists.get(String(body.playlist_id));
if (track) activity.set(user.id,{track_id:track.id,track_title:track.title,artist:'Кино',context: body.context as 'wave'|'playlist'|'other',device_id:String(body.device_id),position_ms:Number(body.position_ms??0),...(playlist ? {playlist_id:playlist.id,playlist_name:playlist.name} : {})});
} else activity.delete(user.id);
return new Response(null,{status:204});
}
const activityPlaylistPath=/^\/api\/v1\/social\/activity\/([^/]+)\/playlist$/.exec(path);
if (activityPlaylistPath) {
const owner=users.get(activityPlaylistPath[1]!); const a=owner ? socialPerson(user.id,owner).activity : undefined; const p=a?.playlist_id ? playlists.get(a.playlist_id) : undefined;
return p ? json({...p,share_id:null}) : problem(404,'social.activity_unavailable');
}
if (path === '/api/v1/me/social/listening/preferences' && body) { listeningPreferences.set(user.id,body as components['schemas']['ListeningPreferences']);return new Response(null,{status:204}); }
if (path === '/api/v1/me/social/listening') {
const room=listeningRooms.get(listeningMembers.get(user.id) ?? '');
if(room)room.participants=[...listeningMembers].filter(([,id])=>id===room.id).map(([id])=>({id,name:users.get(id)?.display_name??users.get(id)?.username??id}));
return json({preferences:listeningPreferences.get(user.id)??defaultListening,room:room??null,requests:[...listeningRequests.values()].filter(r=>r.recipient_id===user.id||r.sender_id===user.id),server_time:new Date().toISOString()});
}
if (path === '/api/v1/me/social/listening/state' && body) {
const room=listeningRooms.get(String(body.room_id));if(!room||room.owner_id!==user.id||room.device_id!==body.device_id)return problem(403,'social.unavailable');
Object.assign(room,{track_id:body.track_id??null,position_ms:body.position_ms,playing:body.playing,updated_at:new Date().toISOString()});return new Response(null,{status:204});
}
if (path === '/api/v1/me/social/listening/actions' && body) {
const target=String(body.target_id);const op=String(body.action);
if(op==='join'||op==='invite'){
const host=op==='join'?target:user.id; const guest=op==='join'?user.id:target; const prefs=listeningPreferences.get(target)??defaultListening;
const friend=!!friendships.get(pair(user.id,target))?.accepted;
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.unavailable');
let room=[...listeningRooms.values()].find(r=>r.owner_id===host);
const source=activity.get(host);if(!room){room={id:crypto.randomUUID(),owner_id:host,owner_name:users.get(host)?.username??host,device_id:String(op==='join'?source?.device_id:body.device_id),track_id:String(op==='join'?source?.track_id:body.track_id),position_ms:Number(op==='join'?source?.position_ms:body.position_ms??0),playing:true,updated_at:new Date().toISOString(),participants:[]};listeningRooms.set(room.id,room);listeningMembers.set(host,room.id);}
if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id);
else listeningRequests.set(String(body.id),{id:String(body.id),owner_id:host,guest_id:guest,sender_id:user.id,sender_name:user.username,recipient_id:target,kind:op,expires_at:new Date(Date.now()+120000).toISOString()});
}else if(op==='accept'||op==='decline'||op==='cancel'){
const r=listeningRequests.get(target);if(r){if(op==='accept'&&r.recipient_id===user.id){const room=[...listeningRooms.values()].find(room=>room.owner_id===r.owner_id);if(room)listeningMembers.set(r.guest_id,room.id);}listeningRequests.delete(target);}
}else if(op==='leave'||op==='end'){
const room=listeningRooms.get(target);if(room?.owner_id===user.id){listeningRooms.delete(target);for(const [u,r]of listeningMembers)if(r===target)listeningMembers.delete(u);}else listeningMembers.delete(user.id);
}
return new Response(null,{status:204});
}
const chatMeta=/^\/api\/v1\/me\/social\/chats\/([^/]+)\/meta$/.exec(path);
if(chatMeta){const peer=chatMeta[1]!;if(!friendships.get(pair(user.id,peer))?.accepted||isBlocked(user.id,peer))return problem(403,'social.unavailable');if(req.method==='PUT'){typing.set(`${user.id}:${peer}`,body?.typing?Date.now()+6000:0);return new Response(null,{status:204});}return json({typing:(typing.get(`${peer}:${user.id}`)??0)>Date.now()});}
const updateMessage=/^\/api\/v1\/me\/social\/chats\/([^/]+)\/messages\/([^/]+)$/.exec(path);
if(updateMessage&&body){
const peer=updateMessage[1]!;const m=messages.find(m=>m.id===updateMessage[2]&&pair(m.sender_id,m.recipient)===pair(user.id,peer));
if(!m||!friendships.get(pair(user.id,peer))?.accepted||isBlocked(user.id,peer))return problem(403,'social.unavailable');
if(body.action!=='react'&&m.sender_id!==user.id)return problem(403,'social.unavailable');
if(body.action==='edit'){m.text=String(body.text);m.edited_at=new Date().toISOString();}
if(body.action==='delete'){m.deleted=true;m.text='';m.attachment_id=undefined;m.attachment_kind='none';m.reactions=[];}
if(body.action==='react'){const reactions=m.reactions??[];const old=reactions.find(r=>r.emoji===body.emoji);m.reactions=old?reactions.filter(r=>r!==old):[...reactions,{emoji:String(body.emoji),count:1,mine:true}];}
return new Response(null,{status:204});
}
if (path === '/api/v1/me/social') return json({
activity_visibility: activityVisibility.get(user.id) ?? 'nobody',
friends_recommendations: friendRecommendations.get(user.id) ?? true,
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,
@@ -239,7 +305,7 @@ export function fakeApi(initial: FakeUser[]) {
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 === 'GET') {const q=url.searchParams.get('q')?.toLowerCase()??'';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'))) && (!q||!m.deleted&&m.text.toLowerCase().includes(q))).slice(-50).map(m=>({...m,reply_preview:m.reply_to?messages.find(r=>r.id===m.reply_to)?.text:undefined})));}
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];
@@ -442,5 +508,5 @@ export function fakeApi(initial: FakeUser[]) {
return problem(404, 'common.not_found');
};
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, friendships, notifications, messages, signOut: () => (current = null) };
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, friendships, notifications, messages, activityVisibility, activity, friendRecommendations, listeningPreferences, listeningRooms, listeningMembers, listeningRequests, signOut: () => (current = null) };
}
+2
View File
@@ -39,6 +39,7 @@ import { SearchBox } from './SearchBox';
import s from './AppShell.module.css';
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
import { useSocialUnread, SocialProvider } from '../features/social/Social';
import { ListeningBanner } from '../features/social/Listening';
type NavPath =
| '/friends'
@@ -312,6 +313,7 @@ function ShellMain() {
return (
<main id="content" className={s.content} tabIndex={-1}>
<DesktopOfflineBanner />
<ListeningBanner />
<div key={key} className={s.routeIn}>
<Suspense fallback={<PageSkeleton />}>
<Outlet />
+16 -13
View File
@@ -40,6 +40,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const player = usePlayer();
const openShare = useMusicShare();
const st = usePlayerState();
const locked = st.listening?.locked === true;
const narrow = useNarrow();
const [sheetOpen, setSheetOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
@@ -132,7 +133,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
>
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
<Icon src={prevIcon} size={26} />
</button>
<button
@@ -140,11 +141,11 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
className={s.play}
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
aria-busy={st.loading || undefined}
onClick={() => player.toggle()}
disabled={locked} onClick={() => player.toggle()}
>
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={26} />
</button>
<button
@@ -168,7 +169,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
aria-label={t('player.progress')}
aria-valuetext={formatDuration(st.positionMs)}
onChange={(e) => player.seek(Number(e.target.value))}
disabled={locked} onChange={(e) => player.seek(Number(e.target.value))}
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
/>
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
@@ -190,7 +191,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
className={cx(s.icon, st.shuffle && s.accent)}
aria-label={t('player.shuffle')}
aria-pressed={st.shuffle}
onClick={() => player.toggleShuffle()}
disabled={locked} onClick={() => player.toggleShuffle()}
>
<Icon src={shuffleIcon} size={20} />
</button>
@@ -199,7 +200,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
aria-label={repeatLabel}
title={repeatLabel}
onClick={() => player.cycleRepeat()}
disabled={locked} onClick={() => player.cycleRepeat()}
>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button>
@@ -271,6 +272,7 @@ function NowPlayingSheet({
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const locked = st.listening?.locked === true;
const track = st.track;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -321,7 +323,7 @@ function NowPlayingSheet({
step={1000}
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
aria-label={t('player.progress')}
onChange={(e) => player.seek(Number(e.target.value))}
disabled={locked} onChange={(e) => player.seek(Number(e.target.value))}
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
/>
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
@@ -330,13 +332,13 @@ function NowPlayingSheet({
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
<Icon src={prevIcon} size={28} />
</button>
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} disabled={locked} onClick={() => player.toggle()}>
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={28} />
</button>
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
@@ -344,10 +346,10 @@ function NowPlayingSheet({
</button>
</div>
<div className={s.sheetExtra}>
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} disabled={locked} onClick={() => player.toggleShuffle()}>
<Icon src={shuffleIcon} size={22} />
</button>
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} disabled={locked} onClick={() => player.cycleRepeat()}>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
</button>
<button type="button" className={s.icon} aria-label={t('player.share')} onClick={() => void share()}>
@@ -379,6 +381,7 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const locked = st.listening?.locked === true;
const upcoming = st.queue.slice(st.index, st.index + 40);
const close = useRef<HTMLButtonElement>(null);
// Клавиатура: при открытии фокус в панель, Escape закрывает.
@@ -408,7 +411,7 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
type="button"
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
aria-current={k === 0 || undefined}
onClick={() => player.jump(st.index + k)}
disabled={st.listening?.locked} onClick={() => player.jump(st.index + k)}
>
<Cover album={tr.album} size={40} />
<span className={s.meta}>
+52 -1
View File
@@ -31,7 +31,7 @@
В скрытой вкладке и в фоне Android автоматического подтверждения нет.
Сейчас это уведомления **внутри работающего приложения**, с обновлением центра каждые 15 секунд
и открытого чата каждые 8 секунд. Web использует стандартную приостановку фонового polling
и открытого чата каждые 3 секунды. Web использует стандартную приостановку фонового polling
React Query; Android останавливает polling при уходе приложения в фон. Уведомления ОС при
закрытом клиенте (Web Push/FCM/нативный desktop) в эту реализацию не входят.
@@ -56,3 +56,54 @@ React Query; Android останавливает polling при уходе при
`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`.
## Активность и приватность
В разделе друзей настройка «Показывать мой статус» принимает значения «Никому»
(по умолчанию), «Друзьям», «Всем». Сервер проверяет аудиторию, дружбу и блокировки.
При «Никому» сведения о текущем прослушивании не сохраняются. Переключение на это
значение удаляет активность всех устройств. Публикация имеет последовательный номер
и срок жизни 90 секунд; пауза снимает статус, потерянное соединение не оставляет вечный статус.
Карточка показывает трек и контекст: «Моя волна», плейлист или обычное прослушивание.
Трек открывается в каталоге. Плейлист доступен для чтения, пока активность видима:
этот переход не создаёт постоянной ссылки общего доступа и не раскрывает существующую.
## Совместное прослушивание
«Подключиться» отправляет запрос хозяину и оставляет управление у него.
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
Настройки приёма запросов/приглашений и автоматического принятия независимы,
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
Дополнительная настройка управляет запуском звука при подключении.
Только исходное устройство хозяина публикует состояние комнаты. Слушатели получают
трек, паузу и позицию с поправкой на серверное время каждые 2 секунды. Обычные кнопки
управления, очередь и перемотка заблокированы; громкость, реакции и выход доступны.
Это синхронизация собственных потоков из каталога с допуском дрейфа 1,5 секунды,
а не ретрансляция аудиобайта хозяина. Браузер может потребовать нажатия «Включить звук».
После 10 секунд потери связи слушатель ставится на паузу, комната истекает через 30 секунд
без хозяина, отсутствующий слушатель удаляется через 60 секунд, запрос действует 2 минуты.
## Чат
Web и desktop используют выдвижную панель справа снизу, Android — общую нижнюю панель.
Поддерживаются текст и музыка, ответы с цитатой, шесть реакций, редактирование своих
сообщений, удаление для всех, поиск по тексту, история, даты, прочтение и индикатор набора.
Удаление очищает исходный текст и музыкальное вложение. Черновики разделены по серверу,
пользователю и собеседнику. Скрытая панель не отмечает переписку прочитанной.
Анимации учитывают системное уменьшение движения. Фото, файлы и голосовые не входят в версию.
Миграции `00020`–`00022` добавляют активность, комнаты и возможности чата.
Уведомления дополнены запросом, приглашением и принятием совместного прослушивания.
## Сигнал друзей в рекомендациях
В настройках рекомендаций можно отключить влияние друзей. Используется только их
текущая видимая активность с учётом аудитории и блокировок, без скрытой истории.
Go передаёт до 30 допустимых треков; Python добавляет до 10 кандидатов, небольшой бонус
с учётом выбранного контекста и не более одного кандидата с источником `friends` на выдачу.
Дизлайки, недоступные треки и недавние повторы исключаются прежними фильтрами.
При недоступном рекомендателе резервный подбор добавляет не более одного такого трека
в каждую вторую выдачу; тематические волны с seed сохраняют свой сценарий.
+563 -1
View File
@@ -4,6 +4,139 @@
*/
export interface paths {
"/api/v1/me/social/recommendations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["setFriendsRecommendations"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/social/listening": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getListeningState"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/social/listening/preferences": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["setListeningPreferences"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/social/listening/actions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** @description Join requests target the host; invites target a guest and use the sender's own stream. Accept/decline/cancel target a request UUID. Leave/end target the room UUID. Automatic acceptance always respects the receiving user's admission policy. No control transfer. */
post: operations["listeningAction"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/social/listening/state": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** @description Only the room owner's source device may publish. Monotonic sequence prevents stale updates; room expires after 30 seconds without host heartbeat. */
put: operations["publishListeningState"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/social/activity": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** @description Ephemeral playback lease, expires after 90 seconds. Ignored when visibility is nobody. Sequence is monotonic per device; paused updates clear only that device. No offline replay. */
put: operations["publishListeningActivity"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/social/activity/visibility": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** @description Applies to all devices. Nobody clears all current activity immediately. */
put: operations["setActivityVisibility"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/social/activity/{userId}/playlist": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Read-only current playlist subject to live visibility, friendship, blocking and expiry. Does not create or reveal a permanent share token. */
get: operations["getActivityPlaylist"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/social": {
parameters: {
query?: never;
@@ -74,6 +207,41 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/me/social/chats/{peerId}/meta": {
parameters: {
query?: never;
header?: never;
path: {
peerId: string;
};
cookie?: never;
};
get: operations["getChatMeta"];
put: operations["setChatTyping"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/social/chats/{peerId}/messages/{messageId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** @description Friends only. Edit/delete are sender-only. Delete removes content for both participants; react toggles one allowed emoji. Reply targets must belong to the same conversation. */
patch: operations["updateChatMessage"];
trace?: never;
};
"/api/v1/admin/server/updates": {
parameters: {
query?: never;
@@ -1692,6 +1860,111 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
ListeningPreferences: {
requests: components["schemas"]["ActivityVisibility"];
auto_accept: components["schemas"]["ActivityVisibility"];
/** @default true */
autoplay: boolean;
};
ListeningAction: {
/** Format: uuid */
id: string;
/** @enum {string} */
action: "join" | "invite" | "accept" | "decline" | "cancel" | "leave" | "end";
/** Format: uuid */
target_id: string;
/** Format: uuid */
device_id?: string;
/** Format: uuid */
track_id?: string;
position_ms?: number;
playing?: boolean;
};
ListeningRoomInput: {
/** Format: uuid */
room_id: string;
/** Format: uuid */
device_id: string;
/** Format: int64 */
sequence: number;
/** Format: uuid */
track_id?: string;
playing: boolean;
position_ms: number;
};
ListeningRoom: {
/** Format: uuid */
id: string;
/** Format: uuid */
owner_id: string;
owner_name: string;
/** Format: uuid */
device_id: string;
/** Format: uuid */
track_id: string | null;
position_ms: number;
playing: boolean;
/** Format: date-time */
updated_at: string;
participants: {
/** Format: uuid */
id: string;
name: string;
}[];
};
ListeningRequest: {
/** Format: uuid */
id: string;
/** Format: uuid */
sender_id: string;
sender_name: string;
/** Format: uuid */
recipient_id: string;
/** Format: uuid */
owner_id: string;
/** @enum {string} */
kind: "join" | "invite";
/** Format: date-time */
expires_at: string;
};
ListeningState: {
preferences: components["schemas"]["ListeningPreferences"];
room: components["schemas"]["ListeningRoom"] | null;
requests: components["schemas"]["ListeningRequest"][];
/** Format: date-time */
server_time: string;
};
/**
* @default nobody
* @enum {string}
*/
ActivityVisibility: "nobody" | "friends" | "everyone";
ListeningActivityInput: {
/** Format: uuid */
device_id: string;
/** Format: int64 */
sequence: number;
playing: boolean;
/** @default 0 */
position_ms: number;
/** Format: uuid */
track_id?: string;
/** @enum {string} */
context: "other" | "wave" | "playlist";
/** Format: uuid */
playlist_id?: string;
};
ListeningActivity: {
/** Format: uuid */
track_id: string;
track_title: string;
artist: string;
/** @enum {string} */
context: "other" | "wave" | "playlist";
/** Format: uuid */
playlist_id?: string;
playlist_name?: string;
};
SocialPerson: {
/** Format: uuid */
id: string;
@@ -1700,6 +1973,7 @@ export interface components {
/** @enum {string} */
relationship: "none" | "incoming" | "outgoing" | "friend" | "blocked";
unread: number;
activity?: components["schemas"]["ListeningActivity"];
};
SocialNotification: {
/** Format: uuid */
@@ -1708,12 +1982,15 @@ export interface components {
actor_id: string;
actor_name: string;
/** @enum {string} */
kind: "friend_request" | "friend_accepted" | "message" | "music";
kind: "friend_request" | "friend_accepted" | "message" | "music" | "listening_request" | "listening_invite" | "listening_accepted";
/** Format: date-time */
created_at: string;
read: boolean;
};
SocialInbox: {
/** @default true */
friends_recommendations: boolean;
activity_visibility: components["schemas"]["ActivityVisibility"];
people: components["schemas"]["SocialPerson"][];
notifications: components["schemas"]["SocialNotification"][];
unread: number;
@@ -1734,6 +2011,8 @@ export interface components {
attachment_kind: "none" | "track" | "playlist";
/** Format: uuid */
attachment_id?: string;
/** Format: uuid */
reply_to?: string;
};
DirectMessage: {
/** Format: uuid */
@@ -1750,6 +2029,18 @@ export interface components {
/** Format: date-time */
created_at: string;
read: boolean;
/** Format: uuid */
reply_to?: string;
reply_preview?: string;
/** Format: date-time */
edited_at?: string;
deleted?: boolean;
reactions?: components["schemas"]["MessageReaction"][];
};
MessageReaction: {
emoji: string;
count: number;
mine: boolean;
};
ServerSource: {
id: string;
@@ -2917,6 +3208,192 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
setFriendsRecommendations: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
enabled: boolean;
};
};
};
responses: {
/** @description Saved */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
getListeningState: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Listening preferences, current room and pending requests */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ListeningState"];
};
};
default: components["responses"]["Problem"];
};
};
setListeningPreferences: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ListeningPreferences"];
};
};
responses: {
/** @description Saved */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
listeningAction: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ListeningAction"];
};
};
responses: {
/** @description Applied */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
publishListeningState: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ListeningRoomInput"];
};
};
responses: {
/** @description Applied */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
publishListeningActivity: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ListeningActivityInput"];
};
};
responses: {
/** @description Applied */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
setActivityVisibility: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
visibility: components["schemas"]["ActivityVisibility"];
};
};
};
responses: {
/** @description Saved */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
getActivityPlaylist: {
parameters: {
query?: never;
header?: never;
path: {
userId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Read-only playlist */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Playlist"];
};
};
default: components["responses"]["Problem"];
};
};
getSocial: {
parameters: {
query?: never;
@@ -2988,6 +3465,7 @@ export interface operations {
parameters: {
query?: {
before?: number;
q?: string;
};
header?: never;
path: {
@@ -3036,6 +3514,90 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
getChatMeta: {
parameters: {
query?: never;
header?: never;
path: {
peerId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Ephemeral typing indicator, friends only */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
typing: boolean;
};
};
};
default: components["responses"]["Problem"];
};
};
setChatTyping: {
parameters: {
query?: never;
header?: never;
path: {
peerId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
typing: boolean;
};
};
};
responses: {
/** @description Applied */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
updateChatMessage: {
parameters: {
query?: never;
header?: never;
path: {
peerId: string;
messageId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @enum {string} */
action: "edit" | "delete" | "react";
text?: string;
/** @enum {string} */
emoji?: "❤️" | "👍" | "🔥" | "😂" | "🎵" | "👏";
};
};
};
responses: {
/** @description Applied */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
getServerUpdates: {
parameters: {
query?: {
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { ActivityPublisher } from './activity';
const track = { id: '00000000-0000-4000-8000-000000000001' };
const device = '00000000-0000-4000-8000-000000000002';
describe('listening activity leases', () => {
it('publishes actual playback, throttles progress, and clears on pause', async () => {
let now = 1000; const sent: unknown[] = [];
const publisher = new ActivityPublisher(async value => { sent.push(value); }, device, () => now);
publisher.update({ track, playing: false, context: null }); await publisher.idle(); expect(sent).toEqual([]);
publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 123 }); await publisher.idle();
expect(sent[0]).toMatchObject({ context: 'wave', track_id: track.id, sequence: 1, position_ms: 123 });
publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 900 }); await publisher.idle(); expect(sent).toHaveLength(1);
now += 20_001; publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 20123 }); await publisher.idle(); expect(sent).toHaveLength(2);
publisher.update({ track, playing: false, context: null }); await publisher.idle(); expect(sent.at(-1)).toMatchObject({ playing: false, sequence: 3 });
publisher.dispose(); await publisher.idle();
});
it('serializes an in-flight play and a final stop without replaying intermediate tracks', async () => {
let release!: () => void; const waiting = new Promise<void>(r => { release = r; }); const sent: { playing: boolean; sequence: number }[] = [];
const publisher = new ActivityPublisher(async value => { sent.push(value); if (sent.length === 1) await waiting; }, device);
publisher.update({ track, playing: true, context: null });
publisher.update({ track: { id: device }, playing: true, context: null });
publisher.dispose(); release(); await publisher.idle();
expect(sent.map(v => [v.playing, v.sequence])).toEqual([[true, 1], [false, 2]]);
});
it('only treats UUID collection contexts as playlists', async () => {
const sent: { context: string }[] = []; const publisher = new ActivityPublisher(async v => { sent.push(v); }, device);
publisher.update({ track, playing: true, context: { type: 'collection', id: 'likes' } }); await publisher.idle(); expect(sent[0]?.context).toBe('other');
publisher.update({ track, playing: true, context: { type: 'collection', id: device } }); await publisher.idle(); expect(sent[1]?.context).toBe('playlist');
publisher.dispose(); await publisher.idle();
});
});
+73
View File
@@ -0,0 +1,73 @@
import type { components } from '@pipu/api-client';
import { type AuthSession, unwrap } from './auth';
import { uuidv7, type PlayContext } from './player';
export type ActivityVisibility = components['schemas']['ActivityVisibility'];
export type ListeningActivity = components['schemas']['ListeningActivity'];
type Input = components['schemas']['ListeningActivityInput'];
export interface ActivityPlaybackState {
positionMs?: number;
playing: boolean;
track: { id: string } | null;
context: PlayContext | null;
wave?: unknown;
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const publishers = new WeakMap<AuthSession, Set<ActivityPublisher>>();
/** Ephemeral, serialized heartbeats. Never persists or replays offline activity. */
export class ActivityPublisher {
private current: ActivityPlaybackState = { playing: false, track: null, context: null };
private pending: Omit<Input, 'device_id' | 'sequence'> | null = null;
private sequence = 0;
private lastKey = '';
private lastAttempt = 0;
private active = false;
private closed = false;
private flight: Promise<void> | null = null;
constructor(private readonly send: (body: Input) => Promise<void>, readonly deviceId: string, private readonly now = Date.now, private readonly onDispose = () => {}) {}
update(state: ActivityPlaybackState, force = false) {
if (this.closed) return;
this.current = state;
const playing = state.playing && !!state.track;
if (!playing && !this.active) return;
const playlist = playing && !state.wave && state.context?.type === 'collection' && UUID.test(state.context.id ?? '') ? state.context.id : undefined;
const body: Omit<Input, 'device_id' | 'sequence'> = playing
? { position_ms: 0, playing: true, track_id: state.track!.id, context: state.wave || state.context?.type === 'wave' ? 'wave' : playlist ? 'playlist' : 'other', ...(playlist ? { playlist_id: playlist } : {}) }
: { position_ms: 0, playing: false, context: 'other' };
const key = JSON.stringify(body);
if (!force && key === this.lastKey && (!playing || this.now() - this.lastAttempt < 20_000)) return;
this.active ||= playing;
this.lastKey = key;
this.lastAttempt = this.now();
this.pending = { ...body, ...(playing ? { position_ms: Math.max(0, Math.round(state.positionMs ?? 0)) } : {}) };
if (!this.flight) this.flight = this.drain();
}
refresh() { this.update(this.current, true); }
idle() { return this.flight ?? Promise.resolve(); }
private async drain() {
while (this.pending) {
const body = this.pending;
this.pending = null;
try { await this.send({ ...body, device_id: this.deviceId, sequence: ++this.sequence }); }
catch { /* A failed heartbeat expires; it must never interrupt playback. */ }
}
this.flight = null;
}
dispose() {
if (this.closed) return;
this.update({ playing: false, track: null, context: null }, true);
this.closed = true;
this.onDispose();
}
}
export function createActivityPublisher(session: AuthSession, deviceId = uuidv7()) {
let set = publishers.get(session);
if (!set) { set = new Set(); publishers.set(session, set); }
const publisher = new ActivityPublisher(async body => { unwrap(await session.client.PUT('/api/v1/me/social/activity', { body })); }, deviceId, Date.now, () => set!.delete(publisher));
set.add(publisher);
return publisher;
}
export function refreshListeningActivity(session: AuthSession) { publishers.get(session)?.forEach(p => p.refresh()); }
+3
View File
@@ -24,6 +24,9 @@ export {
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 { CHAT_REACTIONS, chatDraftKey, type ChatMessageUpdate } from './social';
export { ActivityPublisher, createActivityPublisher, type ActivityVisibility, type ListeningActivity } from './activity';
export { ListeningCoordinator, refreshListening, type ListeningClientState, type ListeningRoom, type ListeningState, type ListeningPreferences, type ListeningAction } from './listening';
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import type { AuthSession } from './auth';
import { ListeningCoordinator, type ListeningRoom, type ListeningState } from './listening';
const host='00000000-0000-4000-8000-000000000001';const guest='00000000-0000-4000-8000-000000000002';const device='00000000-0000-4000-8000-000000000003';
const room: ListeningRoom={id:'room',owner_id:host,owner_name:'Host',device_id:device,track_id:'track',playing:true,position_ms:1000,updated_at:'2026-10-23T00:00:00Z',participants:[{id:host,name:'Host'},{id:guest,name:'Guest'}]};
function setup(user=guest,sourceDevice='other'){
let now=1000;let state:ListeningState={preferences:{requests:'friends',auto_accept:'nobody',autoplay:true},room:{...room},requests:[],server_time:'2026-10-23T00:00:02Z'};
const put=vi.fn(async()=>({response:new Response(null,{status:204})}));const post=vi.fn(async()=>({response:new Response(null,{status:204})}));
const get=vi.fn(async()=>({data:state,response:new Response()}));
const session={state:{status:'signed_in',user:{id:user}},client:{GET:get,PUT:put,POST:post}} as unknown as AuthSession;
const adapter={read:()=>({track:{id:'track'},playing:true,positionMs:4000,context:null}),apply:vi.fn(async(_r:ListeningRoom,_p:number,_current:()=>boolean)=>{}),pause:vi.fn(),changed:vi.fn()};
const coordinator=new ListeningCoordinator(session,sourceDevice,adapter,()=>now);
return {coordinator,adapter,get,put,post,setState:(v:ListeningState)=>{state=v;},state,advance:(ms:number)=>{now+=ms;}};
}
describe('shared listening',()=>{
it('publishes only from the source device and leaves source playback running',async()=>{
const e=setup(host,device);await e.coordinator.poll(true);expect(e.put).toHaveBeenCalledWith('/api/v1/me/social/listening/state',expect.objectContaining({body:expect.objectContaining({device_id:device,position_ms:4000})}));expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.coordinator.locked).toBe(false);e.coordinator.dispose();expect(e.adapter.pause).not.toHaveBeenCalled();
});
it('locks follower controls and extrapolates the host position from server time',async()=>{
const e=setup();await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(true);expect(e.adapter.apply).toHaveBeenCalledWith(expect.objectContaining({track_id:'track'}),3000,expect.any(Function));expect(e.put).not.toHaveBeenCalled();e.coordinator.dispose();
});
it('prepares silent audio when autoplay is disabled',async()=>{
const e=setup();e.state.preferences.autoplay=false;await e.coordinator.poll(true);expect(e.adapter.apply).toHaveBeenCalledWith(expect.objectContaining({playing:false}),3000,expect.any(Function));expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({locked:true,needsStart:true}));e.coordinator.beginAudio();await vi.waitFor(()=>expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:true}),3000,expect.any(Function)));e.coordinator.dispose();
});
it('invalidates late synchronization and allows local exit even without a connection',async()=>{
const e=setup();let current!:()=>boolean;let release!:()=>void;
e.adapter.apply.mockImplementation(async(_r,_p,live)=>{current=live;await new Promise<void>(r=>{release=r;});});
const pending=e.coordinator.poll(true);await vi.waitFor(()=>expect(current).toBeDefined());expect(current()).toBe(true);e.coordinator.leaveLocally();expect(current()).toBe(false);expect(e.coordinator.locked).toBe(false);release();await pending;e.coordinator.dispose();
});
it('pauses on connection loss and on room termination',async()=>{
const e=setup();await e.coordinator.poll(true);e.get.mockRejectedValueOnce(new TypeError('offline'));e.advance(11000);await e.coordinator.poll(true);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({disconnected:true}));e.setState({...e.state,room:null});await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(false);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
});
});
+119
View File
@@ -0,0 +1,119 @@
import type { components } from '@pipu/api-client';
import { type AuthSession, unwrap } from './auth';
import type { ActivityPlaybackState } from './activity';
export type ListeningRoom = components['schemas']['ListeningRoom'];
export type ListeningState = components['schemas']['ListeningState'];
export type ListeningPreferences = components['schemas']['ListeningPreferences'];
export type ListeningAction = components['schemas']['ListeningAction'];
export interface ListeningClientState { room: ListeningRoom | null; locked: boolean; needsStart: boolean; disconnected: boolean }
export interface ListeningPlaybackState extends ActivityPlaybackState { positionMs: number }
interface Adapter {
read(): ListeningPlaybackState;
apply(room: ListeningRoom, position: number, current: () => boolean): Promise<void>;
pause(): void;
changed(state: ListeningClientState): void;
}
const clients = new WeakMap<AuthSession, Set<ListeningCoordinator>>();
/** Source keeps its own wave/queue. Followers play only the source's current track. */
export class ListeningCoordinator {
private snapshot: ListeningState | null = null;
private generation = 0;
private sequence = 0;
private busy = false;
private closed = false;
private lastPoll = 0;
private lastSuccess = 0;
private key = '';
private enabled = new Set<string>();
private ignored = new Set<string>();
private timer: ReturnType<typeof setInterval> | null = null;
locked = false;
constructor(private readonly session: AuthSession, readonly deviceId: string, private readonly adapter: Adapter, private readonly now = Date.now) {}
start() {
let set = clients.get(this.session); if (!set) { set = new Set(); clients.set(this.session, set); } set.add(this);
this.timer ??= setInterval(() => void this.poll(), 2000);
void this.poll(true);
}
private source(room: ListeningRoom) { return this.session.state.status === 'signed_in' && room.owner_id === this.session.state.user.id && room.device_id === this.deviceId; }
private emit(room: ListeningRoom | null, needsStart = false, disconnected = false) {
this.locked = !!room && !this.source(room);
this.adapter.changed({ room, locked: this.locked, needsStart, disconnected });
}
update() {
const room = this.snapshot?.room;
if (!room || !this.source(room)) return;
const state = this.adapter.read();
const key = `${state.track?.id}:${state.playing}`;
if (key !== this.key) { this.key = key; void this.poll(true); }
}
refresh() { void this.poll(true); }
allowJoining() { this.ignored.clear(); }
beginAudio() { if (this.snapshot?.room) this.enabled.add(this.snapshot.room.id); this.refresh(); }
leaveLocally() {
if (this.snapshot?.room) this.ignored.add(this.snapshot.room.id);
this.generation++;
if (this.locked) this.adapter.pause();
this.emit(null);
}
prepare(action: ListeningAction): ListeningAction {
if (action.action !== 'invite') return action;
const state = this.adapter.read();
return { ...action, device_id: this.deviceId, track_id: state.track?.id, position_ms: Math.max(0, Math.round(state.positionMs)), playing: state.playing };
}
async poll(force = false) {
if (this.closed || this.busy || (!force && !this.snapshot?.room && this.now() - this.lastPoll < 10_000)) return;
if (this.session.state?.status !== 'signed_in') return;
this.busy = true; this.lastPoll = this.now();
try {
const state = unwrap(await this.session.client.GET('/api/v1/me/social/listening'));
if (this.closed) return;
this.lastSuccess = this.now();
const previous = this.snapshot?.room;
this.snapshot = state;
const room = state.room;
if (previous?.id !== room?.id) this.generation++;
if (!room) { if (this.locked) this.adapter.pause(); this.emit(null); return; }
if (this.ignored.has(room.id)) {
this.emit(null);
await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'leave', target_id: room.id } });
return;
}
if (this.source(room)) {
this.emit(room);
const playback = this.adapter.read();
unwrap(await this.session.client.PUT('/api/v1/me/social/listening/state', { body: { room_id: room.id, device_id: this.deviceId, sequence: ++this.sequence, track_id: playback.track?.id, playing: playback.playing && !!playback.track, position_ms: Math.max(0, Math.round(playback.positionMs)) } }));
return;
}
if (!this.locked) this.adapter.pause();
const needsStart = !state.preferences.autoplay && !this.enabled.has(room.id);
this.emit(room, needsStart);
const version = this.generation;
const elapsed = room.playing ? Math.max(0, Date.parse(state.server_time) - Date.parse(room.updated_at)) : 0;
await this.adapter.apply(needsStart ? { ...room, playing: false } : room, room.position_ms + elapsed, () => !this.closed && this.locked && version === this.generation && !this.ignored.has(room.id));
} catch {
if (this.closed) return;
if (this.locked && this.now() - this.lastSuccess > 10_000) { this.adapter.pause(); this.emit(this.snapshot?.room ?? null, false, true); }
} finally { this.busy = false; }
}
dispose() {
if (this.closed) return;
this.closed = true; this.generation++;
if (this.timer) clearInterval(this.timer); this.timer = null;
clients.get(this.session)?.delete(this);
const room = this.snapshot?.room;
if (this.locked) this.adapter.pause();
if (room) void this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: this.source(room) ? 'end' : 'leave', target_id: room.id } }).catch(() => undefined);
}
}
export async function sendListeningAction(session: AuthSession, action: ListeningAction) {
const set = clients.get(session);
if (action.action === 'leave' || action.action === 'end') set?.forEach(c => c.leaveLocally());
const client = set?.values().next().value as ListeningCoordinator | undefined;
unwrap(await session.client.POST('/api/v1/me/social/listening/actions', { body: client?.prepare(action) ?? action }));
if (action.action !== 'leave' && action.action !== 'end') set?.forEach(c => c.allowJoining());
set?.forEach(c => c.refresh());
}
export function refreshListening(session: AuthSession) { clients.get(session)?.forEach(c => c.refresh()); }
+18 -1
View File
@@ -1,5 +1,7 @@
import type { components } from '@pipu/api-client';
import { type AuthSession, unwrap } from './auth';
import { refreshListeningActivity, type ActivityVisibility } from './activity';
import { sendListeningAction, refreshListening, type ListeningAction, type ListeningPreferences } from './listening';
export type SocialPerson = components['schemas']['SocialPerson'];
export type SocialInbox = components['schemas']['SocialInbox'];
@@ -7,14 +9,29 @@ 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 };
export type ChatMessageUpdate = { action: 'edit' | 'delete' | 'react'; text?: string; emoji?: '❤️' | '👍' | '🔥' | '😂' | '🎵' | '👏' };
export const CHAT_REACTIONS = ['❤️', '👍', '🔥', '😂', '🎵', '👏'] as const;
export function chatDraftKey(server: string, user: string, peer: string) { return `pipu.chat-draft.${encodeURIComponent(server)}.${user}.${peer}`; }
/** 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')),
setFriendsRecommendations: async (enabled: boolean) => unwrap(await session.client.PUT('/api/v1/me/social/recommendations', { body: { enabled } })),
listening: async () => unwrap(await session.client.GET('/api/v1/me/social/listening')),
listeningAction: (action: ListeningAction) => sendListeningAction(session, action),
setListeningPreferences: async (body: ListeningPreferences) => { unwrap(await session.client.PUT('/api/v1/me/social/listening/preferences', { body })); refreshListening(session); },
setVisibility: async (visibility: ActivityVisibility) => {
unwrap(await session.client.PUT('/api/v1/me/social/activity/visibility', { body: { visibility } }));
refreshListeningActivity(session);
},
activityPlaylist: async (userId: string) => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })),
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 } } })),
messages: async (peerId: string, before?: number, q?: string) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before, q } } })),
chatMeta: async (peerId: string) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } } })),
typing: async (peerId: string, typing: boolean) => unwrap(await session.client.PUT('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } }, body: { typing } })),
updateMessage: async (peerId: string, messageId: string, body: ChatMessageUpdate) => unwrap(await session.client.PATCH('/api/v1/me/social/chats/{peerId}/messages/{messageId}', { params: { path: { peerId, messageId } }, body })),
send: async (peerId: string, body: SendDirectMessage) => unwrap(await session.client.POST('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId } }, body })),
};
}
+54 -1
View File
@@ -433,6 +433,59 @@
"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."
"attachment_unavailable": "This music is no longer available.",
"activity_visibility": "Show my listening activity",
"visibility_nobody": "Nobody",
"visibility_friends": "Friends",
"visibility_everyone": "Everyone",
"activity_privacy_hint": "Hidden by default. Your audience can see the track and open the playlist while you listen.",
"listening": "Listening now",
"from_playlist": "Playlist “{{name}}”",
"kind_listening_request": "Wants to listen with you",
"kind_listening_invite": "Invites you to their stream",
"kind_listening_accepted": "Accepted listening together",
"together": "Listen together",
"join_stream": "Join their listening stream",
"invite_stream": "Invite to my listening stream",
"stream_choice": "Who leads the listening session",
"stream_hint": "The host controls tracks, pause and seeking. Everyone keeps their own volume and can leave.",
"stream_requests": "Accept requests and invitations from",
"stream_auto": "Automatically accept from",
"stream_autoplay": "Start audio when joining",
"stream_prefs_hint": "Automatic acceptance still follows your allowed audience. An invitation never transfers control of another player.",
"stream_request_sent": "Request sent",
"stream_invite_sent": "Invitation sent",
"stream_incoming_join": "{{name}} wants to join you",
"stream_incoming_invite": "{{name}} invites you to their stream",
"stream_pending": "Waiting for confirmation",
"stream_host": "You are hosting",
"stream_following": "Listening with {{name}}",
"stream_controls": "The host controls playback. Your volume and likes remain available.",
"stream_end": "End stream",
"stream_leave": "Leave session",
"stream_start_audio": "Start listening",
"stream_lost": "Connection to the host was lost. Playback is paused.",
"stream_listeners": "Listeners: {{count}}",
"stream_waiting_audio": "You have joined. Start audio when you are ready.",
"friends_recommendations": "Use friends’ music in My Wave",
"friends_recommendations_hint": "Mix in a little music that friends allow you to see. Your own taste remains the main signal.",
"messages": "Messages",
"minimize": "Minimize chat",
"message_actions": "Message actions",
"reply": "Reply",
"replying": "Replying to a message",
"edit_message": "Edit",
"editing": "Editing message",
"delete_message": "Delete for everyone",
"message_deleted": "Message deleted",
"edited": "edited",
"typing": "typing…",
"chat_search": "Search conversation",
"chat_no_results": "No messages found",
"cancel_edit": "Cancel editing",
"cancel_reply": "Cancel reply",
"emoji": "Add emoji",
"draft": "Draft",
"new_messages": "New messages"
}
}
+5 -1
View File
@@ -128,6 +128,10 @@
"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"
"attachment_unavailable": "This track or playlist link is no longer available",
"activity_unavailable": "This activity or playlist is no longer available",
"listening_busy": "Leave your current listening session first",
"listening_expired": "The request or stream has expired",
"listening_pending": "A request is already pending"
}
}
+54 -1
View File
@@ -433,6 +433,59 @@
"back": "Назад",
"quiet": "Без звуков. Новые события отмечаются счётчиком.",
"share_error": "Не удалось отправить. Попробуйте ещё раз.",
"attachment_unavailable": "Музыка больше недоступна."
"attachment_unavailable": "Музыка больше недоступна.",
"activity_visibility": "Показывать мой статус",
"visibility_nobody": "Никому",
"visibility_friends": "Друзьям",
"visibility_everyone": "Всем",
"activity_privacy_hint": "По умолчанию статус скрыт. Выбранная аудитория увидит трек и сможет открыть плейлист, пока вы его слушаете.",
"listening": "Слушает сейчас",
"from_playlist": "Плейлист «{{name}}»",
"kind_listening_request": "Хочет слушать вместе с вами",
"kind_listening_invite": "Приглашает на своё прослушивание",
"kind_listening_accepted": "Подтвердил(а) совместное прослушивание",
"together": "Слушать вместе",
"join_stream": "Подключиться к другу",
"invite_stream": "Пригласить на своё прослушивание",
"stream_choice": "Кто ведёт прослушивание",
"stream_hint": "Ведущий управляет треками, паузой и перемоткой. Громкость и выход — у каждого свои.",
"stream_requests": "Принимать запросы и приглашения от",
"stream_auto": "Автоматически принимать от",
"stream_autoplay": "Запускать звук при подключении",
"stream_prefs_hint": "Автопринятие работает только для разрешённой аудитории. Приглашение на своё прослушивание не передаёт управление чужим плеером.",
"stream_request_sent": "Запрос отправлен",
"stream_invite_sent": "Приглашение отправлено",
"stream_incoming_join": "{{name}} хочет подключиться к вам",
"stream_incoming_invite": "{{name}} приглашает на своё прослушивание",
"stream_pending": "Ждём подтверждения",
"stream_host": "Вы ведёте прослушивание",
"stream_following": "Слушаете вместе с {{name}}",
"stream_controls": "Плеером управляет ведущий. Громкость и лайки доступны вам.",
"stream_end": "Завершить эфир",
"stream_leave": "Выйти из прослушивания",
"stream_start_audio": "Начать слушать",
"stream_lost": "Связь с ведущим потеряна. Воспроизведение приостановлено.",
"stream_listeners": "Слушатели: {{count}}",
"stream_waiting_audio": "Подключение подтверждено. Звук включается по вашему нажатию.",
"friends_recommendations": "Учитывать музыку друзей в Моей волне",
"friends_recommendations_hint": "Добавлять немного музыки, которую друзья разрешили показывать вам. Ваш вкус остаётся главным.",
"messages": "Сообщения",
"minimize": "Свернуть чат",
"message_actions": "Действия с сообщением",
"reply": "Ответить",
"replying": "Ответ на сообщение",
"edit_message": "Редактировать",
"editing": "Редактирование сообщения",
"delete_message": "Удалить для всех",
"message_deleted": "Сообщение удалено",
"edited": "изменено",
"typing": "печатает…",
"chat_search": "Поиск в переписке",
"chat_no_results": "Сообщений не найдено",
"cancel_edit": "Отменить редактирование",
"cancel_reply": "Отменить ответ",
"emoji": "Добавить эмодзи",
"draft": "Черновик",
"new_messages": "Новые сообщения"
}
}
+5 -1
View File
@@ -128,6 +128,10 @@
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
"rate_limited": "Слишком много действий. Подождите минуту",
"message_conflict": "Это сообщение уже отправлено с другим содержимым",
"attachment_unavailable": "Трек или ссылка на плейлист больше недоступны"
"attachment_unavailable": "Трек или ссылка на плейлист больше недоступны",
"activity_unavailable": "Активность или плейлист больше недоступны",
"listening_busy": "Сначала выйдите из текущего прослушивания",
"listening_expired": "Запрос или эфир завершён",
"listening_pending": "Запрос уже ожидает подтверждения"
}
}
+1 -1
View File
@@ -174,7 +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),
Social: social.NewHandler(pool, pb),
Management: management.NewHandler(management.Mailbox{Dir: cfg.ManagementDir}),
Genius: genius.NewHandler(geniusSvc),
Log: log,
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -122,7 +122,7 @@ 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/") {
if (r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch) && 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)))
@@ -0,0 +1,26 @@
package playback
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
// ReadSocialPlaylist is a projection port for social after live activity authorization.
// DTO signing and reactions still use the requesting viewer's principal from ctx.
func (h *Handler) ReadSocialPlaylist(ctx context.Context, id uuid.UUID) (gen.Playlist, error) {
var owner uuid.UUID
e := h.store.pool.QueryRow(ctx, `SELECT user_id FROM playlists WHERE id=$1`, id).Scan(&owner)
if errors.Is(e, pgx.ErrNoRows) {
return gen.Playlist{}, apperr.NotFound("playlist.not_found", "playlist not found")
}
if e != nil {
return gen.Playlist{}, e
}
p, e := h.readPlaylist(ctx, owner, id, false)
p.ShareId = nil
return p, e
}
+210
View File
@@ -0,0 +1,210 @@
package social
import (
"context"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
func validateActivity(a gen.ListeningActivityInput) error {
if a.DeviceId == uuid.Nil || a.Sequence < 1 {
return invalid()
}
if a.PositionMs != nil && (*a.PositionMs < 0 || *a.PositionMs > 86400000) {
return invalid()
}
if a.Context != "other" && a.Context != "wave" && a.Context != "playlist" {
return invalid()
}
if a.Playing && (a.TrackId == nil || *a.TrackId == uuid.Nil) {
return invalid()
}
if a.Context == "playlist" && (a.PlaylistId == nil || *a.PlaylistId == uuid.Nil) {
return invalid()
}
if a.Context != "playlist" && a.PlaylistId != nil {
return invalid()
}
return nil
}
func (h *Handler) SetFriendsRecommendations(ctx context.Context, r gen.SetFriendsRecommendationsRequestObject) (gen.SetFriendsRecommendationsResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
_, e = h.store.pool.Exec(ctx, `UPDATE users SET friends_recommendations=$2 WHERE id=$1`, id, r.Body.Enabled)
return gen.SetFriendsRecommendations204Response{}, e
}
func (h *Handler) PublishListeningActivity(ctx context.Context, r gen.PublishListeningActivityRequestObject) (gen.PublishListeningActivityResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
if e = validateActivity(*r.Body); e != nil {
return nil, e
}
if e = h.store.publishActivity(ctx, id, *r.Body); e != nil {
return nil, e
}
return gen.PublishListeningActivity204Response{}, nil
}
func (h *Handler) SetActivityVisibility(ctx context.Context, r gen.SetActivityVisibilityRequestObject) (gen.SetActivityVisibilityResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
v := r.Body.Visibility
if v != "nobody" && v != "friends" && v != "everyone" {
return nil, invalid()
}
tx, e := h.store.pool.Begin(ctx)
if e != nil {
return nil, e
}
defer tx.Rollback(ctx)
if _, e = tx.Exec(ctx, `UPDATE users SET activity_visibility=$2 WHERE id=$1`, id, v); e != nil {
return nil, e
}
if v == "nobody" {
if _, e = tx.Exec(ctx, `DELETE FROM listening_activity WHERE user_id=$1`, id); e != nil {
return nil, e
}
}
if e = tx.Commit(ctx); e != nil {
return nil, e
}
return gen.SetActivityVisibility204Response{}, nil
}
func (h *Handler) GetActivityPlaylist(ctx context.Context, r gen.GetActivityPlaylistRequestObject) (gen.GetActivityPlaylistResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
activity, e := h.store.activities(ctx, id, []uuid.UUID{r.UserId})
if e != nil {
return nil, e
}
a := activity[r.UserId]
if a == nil || a.PlaylistId == nil {
return nil, apperr.NotFound("social.activity_unavailable", "activity unavailable")
}
p, e := h.playlists.ReadSocialPlaylist(ctx, *a.PlaylistId)
if e != nil {
return nil, e
}
// Recheck after projecting the playlist: a concurrent privacy change must close the view.
current, e := h.store.activities(ctx, id, []uuid.UUID{r.UserId})
if e != nil {
return nil, e
}
now := current[r.UserId]
if now == nil || now.PlaylistId == nil || *now.PlaylistId != *a.PlaylistId {
return nil, apperr.NotFound("social.activity_unavailable", "activity unavailable")
}
return gen.GetActivityPlaylist200JSONResponse(p), nil
}
func (s *Store) publishActivity(ctx context.Context, id uuid.UUID, a gen.ListeningActivityInput) error {
tx, e := s.pool.Begin(ctx)
if e != nil {
return e
}
defer tx.Rollback(ctx)
var visibility string
if e = tx.QueryRow(ctx, `SELECT activity_visibility FROM users WHERE id=$1 FOR UPDATE`, id).Scan(&visibility); e != nil {
return e
}
if visibility == "nobody" {
return tx.Commit(ctx)
}
var track, playlist *uuid.UUID
kind := "other"
if a.Playing {
var exists bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tracks WHERE id=$1)`, a.TrackId).Scan(&exists); e != nil {
return e
}
if !exists {
return invalid()
}
track = a.TrackId
kind = string(a.Context)
if kind == "playlist" {
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM playlists WHERE id=$2 AND (user_id=$1 OR share_id IS NOT NULL))`, id, a.PlaylistId).Scan(&exists); e != nil {
return e
}
if exists {
playlist = a.PlaylistId
} else {
kind = "other"
}
}
}
position := 0
if a.PositionMs != nil {
position = *a.PositionMs
}
_, e = tx.Exec(ctx, `INSERT INTO listening_activity(user_id,device_id,sequence,track_id,context,playlist_id,position_ms) VALUES($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT(user_id,device_id) DO UPDATE SET sequence=EXCLUDED.sequence,track_id=EXCLUDED.track_id,context=EXCLUDED.context,playlist_id=EXCLUDED.playlist_id,position_ms=EXCLUDED.position_ms,updated_at=now()
WHERE EXCLUDED.sequence>listening_activity.sequence`, id, a.DeviceId, a.Sequence, track, kind, playlist, position)
if e != nil {
return e
}
if _, e = tx.Exec(ctx, `DELETE FROM listening_activity WHERE user_id=$1 AND updated_at<now()-interval '1 day'`, id); e != nil {
return e
}
return tx.Commit(ctx)
}
func (s *Store) activities(ctx context.Context, viewer uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]*gen.ListeningActivity, error) {
out := map[uuid.UUID]*gen.ListeningActivity{}
if len(ids) == 0 {
return out, nil
}
rows, e := s.pool.Query(ctx, `SELECT u.id,t.id,t.title,
COALESCE((SELECT string_agg(ar.name,', ' ORDER BY ta.position) FROM track_artists ta JOIN artists ar ON ar.id=ta.artist_id WHERE ta.track_id=t.id AND ta.role='main'),''),
CASE WHEN a.context='playlist' AND p.id IS NULL THEN 'other' ELSE a.context END,p.id,p.name
FROM users u JOIN LATERAL(SELECT * FROM listening_activity la WHERE la.user_id=u.id AND la.track_id IS NOT NULL AND la.updated_at>now()-interval '90 seconds' ORDER BY la.updated_at DESC,la.device_id LIMIT 1) a ON true
JOIN tracks t ON t.id=a.track_id
LEFT JOIN playlists p ON p.id=a.playlist_id AND (p.user_id=u.id OR p.share_id IS NOT NULL)
WHERE u.id=ANY($2::uuid[]) AND NOT u.is_blocked
AND (u.id=$1 OR u.activity_visibility='everyone' OR (u.activity_visibility='friends' AND EXISTS(SELECT 1 FROM friendships f WHERE f.low_id=LEAST($1::uuid,u.id) AND f.high_id=GREATEST($1::uuid,u.id) AND f.accepted)))
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=u.id) OR (b.user_id=u.id AND b.peer_id=$1))`, viewer, ids)
if e != nil {
return nil, e
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
var a gen.ListeningActivity
if e = rows.Scan(&id, &a.TrackId, &a.TrackTitle, &a.Artist, &a.Context, &a.PlaylistId, &a.PlaylistName); e != nil {
return nil, e
}
out[id] = &a
}
return out, rows.Err()
}
func (s *Store) attachActivity(ctx context.Context, viewer uuid.UUID, people []gen.SocialPerson) error {
ids := make([]uuid.UUID, 0, len(people))
for _, p := range people {
ids = append(ids, p.Id)
}
activities, e := s.activities(ctx, viewer, ids)
if e != nil {
return e
}
for i := range people {
people[i].Activity = activities[people[i].Id]
}
return nil
}
+193
View File
@@ -0,0 +1,193 @@
package social
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
"strings"
"unicode/utf8"
)
func validEmoji(s string) bool {
switch s {
case "❤️", "👍", "🔥", "😂", "🎵", "👏":
return true
}
return false
}
func (h *Handler) GetChatMeta(ctx context.Context, r gen.GetChatMetaRequestObject) (gen.GetChatMetaResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
tx, e := pairTx(ctx, h.store.pool, id, r.PeerId)
if e != nil {
return nil, e
}
defer tx.Rollback(ctx)
if e = allowed(ctx, tx, id, r.PeerId); e != nil {
return nil, e
}
var typing bool
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM chat_typing WHERE user_id=$1 AND peer_id=$2 AND expires_at>now())`, r.PeerId, id).Scan(&typing)
return gen.GetChatMeta200JSONResponse{Typing: typing}, e
}
func (h *Handler) SetChatTyping(ctx context.Context, r gen.SetChatTypingRequestObject) (gen.SetChatTypingResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
tx, e := pairTx(ctx, h.store.pool, id, r.PeerId)
if e != nil {
return nil, e
}
defer tx.Rollback(ctx)
if e = allowed(ctx, tx, id, r.PeerId); e != nil {
return nil, e
}
if r.Body.Typing {
_, e = tx.Exec(ctx, `INSERT INTO chat_typing(user_id,peer_id,expires_at) VALUES($1,$2,now()+interval '6 seconds') ON CONFLICT(user_id,peer_id) DO UPDATE SET expires_at=EXCLUDED.expires_at`, id, r.PeerId)
} else {
_, e = tx.Exec(ctx, `DELETE FROM chat_typing WHERE user_id=$1 AND peer_id=$2`, id, r.PeerId)
}
if e != nil {
return nil, e
}
return gen.SetChatTyping204Response{}, tx.Commit(ctx)
}
func (h *Handler) UpdateChatMessage(ctx context.Context, r gen.UpdateChatMessageRequestObject) (gen.UpdateChatMessageResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
e = h.store.updateMessage(ctx, id, r.PeerId, r.MessageId, *r.Body)
return gen.UpdateChatMessage204Response{}, e
}
func (s *Store) updateMessage(ctx context.Context, id, peer, message uuid.UUID, a gen.UpdateChatMessageJSONRequestBody) error {
tx, e := pairTx(ctx, s.pool, id, peer)
if e != nil {
return e
}
defer tx.Rollback(ctx)
if e = allowed(ctx, tx, id, peer); e != nil {
return e
}
var sender uuid.UUID
var kind string
var deleted bool
e = tx.QueryRow(ctx, `SELECT sender_id,attachment_kind,deleted_at IS NOT NULL FROM direct_messages WHERE id=$1 AND LEAST(sender_id,recipient_id)=LEAST($2::uuid,$3::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($2::uuid,$3::uuid) FOR UPDATE`, message, id, peer).Scan(&sender, &kind, &deleted)
if errors.Is(e, pgx.ErrNoRows) {
return apperr.NotFound("social.message_conflict", "message unavailable")
}
if e != nil {
return e
}
if a.Action != "react" && sender != id {
return denied()
}
if deleted {
if a.Action == "delete" {
return tx.Commit(ctx)
}
return apperr.Conflict("social.message_conflict", "message deleted")
}
switch a.Action {
case "edit":
if a.Text == nil || utf8.RuneCountInString(*a.Text) > 4000 || (kind == "none" && strings.TrimSpace(*a.Text) == "") {
return invalid()
}
_, e = tx.Exec(ctx, `UPDATE direct_messages SET text=$2,edited_at=now() WHERE id=$1`, message, strings.TrimSpace(*a.Text))
case "delete":
_, e = tx.Exec(ctx, `UPDATE direct_messages SET text='[deleted]',attachment_kind='none',attachment_id=NULL,deleted_at=now() WHERE id=$1`, message)
if e == nil {
_, e = tx.Exec(ctx, `DELETE FROM message_reactions WHERE message_id=$1`, message)
}
if e == nil {
_, e = tx.Exec(ctx, `UPDATE social_notifications SET read_at=COALESCE(read_at,now()) WHERE message_seq=(SELECT seq FROM direct_messages WHERE id=$1)`, message)
}
case "react":
if a.Emoji == nil || !validEmoji(string(*a.Emoji)) {
return invalid()
}
result, err := tx.Exec(ctx, `DELETE FROM message_reactions WHERE message_id=$1 AND user_id=$2 AND emoji=$3`, message, id, *a.Emoji)
e = err
if e == nil && result.RowsAffected() == 0 {
_, e = tx.Exec(ctx, `INSERT INTO message_reactions(message_id,user_id,emoji) VALUES($1,$2,$3)`, message, id, *a.Emoji)
}
default:
return invalid()
}
if e != nil {
return e
}
return tx.Commit(ctx)
}
func enrichMessages(ctx context.Context, tx pgx.Tx, viewer uuid.UUID, messages []gen.DirectMessage) error {
if len(messages) == 0 {
return nil
}
ids := make([]uuid.UUID, 0, len(messages))
replies := []uuid.UUID{}
positions := map[uuid.UUID]int{}
for i, m := range messages {
ids = append(ids, m.Id)
positions[m.Id] = i
if m.ReplyTo != nil {
replies = append(replies, *m.ReplyTo)
}
empty := []gen.MessageReaction{}
messages[i].Reactions = &empty
}
rows, e := tx.Query(ctx, `SELECT message_id,emoji,count(*),bool_or(user_id=$2) FROM message_reactions WHERE message_id=ANY($1::uuid[]) GROUP BY message_id,emoji ORDER BY emoji`, ids, viewer)
if e != nil {
return e
}
for rows.Next() {
var id uuid.UUID
var r gen.MessageReaction
if e = rows.Scan(&id, &r.Emoji, &r.Count, &r.Mine); e != nil {
rows.Close()
return e
}
i := positions[id]
*messages[i].Reactions = append(*messages[i].Reactions, r)
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
if len(replies) == 0 {
return nil
}
rows, e = tx.Query(ctx, `SELECT id,CASE WHEN deleted_at IS NULL THEN left(text,160) ELSE '' END FROM direct_messages WHERE id=ANY($1::uuid[])`, replies)
if e != nil {
return e
}
defer rows.Close()
previews := map[uuid.UUID]string{}
for rows.Next() {
var id uuid.UUID
var text string
if e = rows.Scan(&id, &text); e != nil {
return e
}
previews[id] = text
}
for i, m := range messages {
if m.ReplyTo != nil {
p := previews[*m.ReplyTo]
messages[i].ReplyPreview = &p
}
}
return rows.Err()
}
@@ -0,0 +1,153 @@
//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/auth"
"pipu/api/internal/platform/db"
"testing"
)
func TestActivityRoomsAndChatPrivacy(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}
h := &Handler{store: s}
owner, friend, other := uuid.New(), uuid.New(), uuid.New()
users := []uuid.UUID{owner, friend, other}
for _, id := range users {
_, 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)`, users)
artist, album, track, playlist, device := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
_, e = pool.Exec(ctx, `INSERT INTO artists(id,name,sort_name,name_norm) VALUES($1,'Artist','artist',$2)`, artist, artist.String())
require.NoError(t, e)
defer pool.Exec(ctx, `DELETE FROM artists WHERE id=$1`, artist)
_, e = pool.Exec(ctx, `INSERT INTO albums(id,title,title_norm,album_artist_id) VALUES($1,'Album',$2,$3)`, album, album.String(), artist)
require.NoError(t, e)
defer pool.Exec(ctx, `DELETE FROM albums WHERE id=$1`, album)
_, e = pool.Exec(ctx, `INSERT INTO tracks(id,title,title_norm,album_id,duration_ms) VALUES($1,'Track',$2,$3,180000)`, track, track.String(), album)
require.NoError(t, e)
defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=$1`, track)
_, e = pool.Exec(ctx, `INSERT INTO playlists(id,user_id,name) VALUES($1,$2,'Private playlist')`, playlist, owner)
require.NoError(t, e)
activity := gen.ListeningActivityInput{DeviceId: device, Sequence: 1, Playing: true, TrackId: &track, Context: "playlist", PlaylistId: &playlist}
require.NoError(t, s.publishActivity(ctx, owner, activity))
var count int
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM listening_activity WHERE user_id=$1`, owner).Scan(&count))
require.Zero(t, count)
_, e = pool.Exec(ctx, `UPDATE users SET activity_visibility='friends' WHERE id=$1`, owner)
require.NoError(t, e)
require.NoError(t, s.publishActivity(ctx, owner, activity))
hidden, e := s.activities(ctx, friend, []uuid.UUID{owner})
require.NoError(t, e)
require.Empty(t, hidden)
require.NoError(t, s.action(ctx, owner, gen.SocialAction{Action: "request", TargetId: friend}))
require.NoError(t, s.action(ctx, friend, gen.SocialAction{Action: "accept", TargetId: owner}))
visible, e := s.activities(ctx, friend, []uuid.UUID{owner})
require.NoError(t, e)
require.Equal(t, &playlist, visible[owner].PlaylistId)
hidden, e = s.activities(ctx, other, []uuid.UUID{owner})
require.NoError(t, e)
require.Empty(t, hidden)
activity.Sequence = 3
activity.Playing = false
activity.Context = "other"
activity.PlaylistId = nil
activity.TrackId = nil
require.NoError(t, s.publishActivity(ctx, owner, activity))
activity.Sequence = 2
activity.Playing = true
activity.Context = "playlist"
activity.PlaylistId = &playlist
activity.TrackId = &track
require.NoError(t, s.publishActivity(ctx, owner, activity))
hidden, e = s.activities(ctx, friend, []uuid.UUID{owner})
require.NoError(t, e)
require.Empty(t, hidden)
activity.Sequence = 4
require.NoError(t, s.publishActivity(ctx, owner, activity))
join := gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}
require.Error(t, s.listeningAction(ctx, friend, join)) // default nobody
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='everyone' WHERE id=$1`, owner)
require.NoError(t, e)
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}))
require.NoError(t, s.listeningAction(ctx, friend, join))
state, e := s.listeningState(ctx, friend)
require.NoError(t, e)
require.NotNil(t, state.Room)
require.Equal(t, owner, state.Room.OwnerId)
require.Len(t, state.Room.Participants, 2)
hostctx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner})
badctx := auth.WithPrincipal(ctx, auth.Principal{UserID: friend})
input := gen.ListeningRoomInput{RoomId: state.Room.Id, DeviceId: device, Sequence: 1, Playing: false, TrackId: &track, PositionMs: 3000}
_, e = h.PublishListeningState(badctx, gen.PublishListeningStateRequestObject{Body: &input})
require.Error(t, e)
_, e = h.PublishListeningState(hostctx, gen.PublishListeningStateRequestObject{Body: &input})
require.NoError(t, e)
require.NoError(t, s.listeningAction(ctx, owner, gen.ListeningAction{Id: uuid.New(), Action: "end", TargetId: state.Room.Id}))
state, e = s.listeningState(ctx, friend)
require.NoError(t, e)
require.Nil(t, state.Room)
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='everyone',listening_auto_accept='nobody' WHERE id=$1`, other)
require.NoError(t, e)
invite := gen.ListeningAction{Id: uuid.New(), Action: "invite", TargetId: other, DeviceId: &device, TrackId: &track}
require.NoError(t, s.listeningAction(ctx, owner, invite))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Len(t, state.Requests, 1)
require.Nil(t, state.Room)
require.Error(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Equal(t, owner, state.Room.OwnerId)
_, e = pool.Exec(ctx, `UPDATE listening_rooms SET updated_at=now()-interval '31 seconds' WHERE id=$1`, state.Room.Id)
require.NoError(t, e)
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Nil(t, state.Room)
// Chat: ownership, replies, reaction toggles, content removal and conversation search.
message, e := s.send(ctx, owner, friend, gen.SendDirectMessage{Id: uuid.New(), Text: "First message", AttachmentKind: "none"})
require.NoError(t, e)
reply, e := s.send(ctx, friend, owner, gen.SendDirectMessage{Id: uuid.New(), Text: "Reply", AttachmentKind: "none", ReplyTo: &message.Id})
require.NoError(t, e)
edited := "Edited message"
require.Error(t, s.updateMessage(ctx, friend, owner, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "edit", Text: &edited}))
require.NoError(t, s.updateMessage(ctx, owner, friend, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "edit", Text: &edited}))
reaction := gen.UpdateChatMessageJSONBodyEmoji("🔥")
require.NoError(t, s.updateMessage(ctx, friend, owner, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "react", Emoji: &reaction}))
history, e := s.messages(ctx, owner, friend, nil)
require.NoError(t, e)
require.Len(t, *history[0].Reactions, 1)
require.Equal(t, "Edited message", *history[1].ReplyPreview)
matches, e := s.messagePage(ctx, friend, owner, nil, "Edited")
require.NoError(t, e)
require.Len(t, matches, 1)
require.NoError(t, s.updateMessage(ctx, owner, friend, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "delete"}))
history, e = s.messages(ctx, owner, friend, nil)
require.NoError(t, e)
require.True(t, *history[0].Deleted)
require.Empty(t, history[0].Text)
require.Empty(t, *history[0].Reactions)
require.Equal(t, reply.ReplyTo, history[1].ReplyTo)
require.NoError(t, s.action(ctx, owner, gen.SocialAction{Action: "block", TargetId: friend}))
hidden, e = s.activities(ctx, friend, []uuid.UUID{owner})
require.NoError(t, e)
require.Empty(t, hidden)
_, e = h.GetChatMeta(badctx, gen.GetChatMetaRequestObject{PeerId: owner})
require.Error(t, e)
}
+22 -3
View File
@@ -13,9 +13,18 @@ import (
"pipu/api/internal/platform/apperr"
)
type Handler struct{ store *Store }
type ActivityPlaylistReader interface {
ReadSocialPlaylist(context.Context, uuid.UUID) (gen.Playlist, error)
}
func NewHandler(pool *pgxpool.Pool) *Handler { return &Handler{store: &Store{pool: pool}} }
type Handler struct {
store *Store
playlists ActivityPlaylistReader
}
func NewHandler(pool *pgxpool.Pool, playlists ActivityPlaylistReader) *Handler {
return &Handler{store: &Store{pool: pool}, playlists: playlists}
}
func userID(ctx context.Context) (uuid.UUID, error) {
p, ok := auth.PrincipalFrom(ctx)
if !ok {
@@ -71,10 +80,20 @@ func (h *Handler) GetDirectMessages(ctx context.Context, r gen.GetDirectMessages
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)
q := ""
if r.Params.Q != nil {
q = strings.TrimSpace(*r.Params.Q)
}
if utf8.RuneCountInString(q) > 100 {
return nil, invalid()
}
v, e := h.store.messagePage(ctx, id, r.PeerId, r.Params.Before, q)
return gen.GetDirectMessages200JSONResponse(v), e
}
func validateMessage(m gen.SendDirectMessage) error {
if m.ReplyTo != nil && *m.ReplyTo == uuid.Nil {
return invalid()
}
if m.Id == uuid.Nil || utf8.RuneCountInString(m.Text) > 4000 {
return invalid()
}
+345
View File
@@ -0,0 +1,345 @@
package social
import (
"context"
"encoding/json"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
"time"
)
func audienceAllows(policy string, friends bool) bool {
return policy == "everyone" || (policy == "friends" && friends)
}
func validAudience(v gen.ActivityVisibility) bool {
return v == "nobody" || v == "friends" || v == "everyone"
}
func (h *Handler) SetListeningPreferences(ctx context.Context, r gen.SetListeningPreferencesRequestObject) (gen.SetListeningPreferencesResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil || !validAudience(r.Body.Requests) || !validAudience(r.Body.AutoAccept) {
return nil, invalid()
}
_, e = h.store.pool.Exec(ctx, `UPDATE users SET listening_requests=$2,listening_auto_accept=$3,listening_autoplay=$4 WHERE id=$1`, id, r.Body.Requests, r.Body.AutoAccept, r.Body.Autoplay)
return gen.SetListeningPreferences204Response{}, e
}
func (h *Handler) GetListeningState(ctx context.Context, _ gen.GetListeningStateRequestObject) (gen.GetListeningStateResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
v, e := h.store.listeningState(ctx, id)
return gen.GetListeningState200JSONResponse(v), e
}
func (h *Handler) ListeningAction(ctx context.Context, r gen.ListeningActionRequestObject) (gen.ListeningActionResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil || r.Body.Id == uuid.Nil || r.Body.TargetId == uuid.Nil {
return nil, invalid()
}
e = h.store.listeningAction(ctx, id, *r.Body)
return gen.ListeningAction204Response{}, e
}
func (h *Handler) PublishListeningState(ctx context.Context, r gen.PublishListeningStateRequestObject) (gen.PublishListeningStateResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
a := r.Body
if a.DeviceId == uuid.Nil || a.RoomId == uuid.Nil || a.Sequence < 1 || a.PositionMs < 0 || a.PositionMs > 86400000 || (a.Playing && a.TrackId == nil) {
return nil, invalid()
}
result, e := h.store.pool.Exec(ctx, `UPDATE listening_rooms SET track_id=$4,position_ms=$5,playing=$6,sequence=$7,updated_at=now()
WHERE id=$1 AND owner_id=$2 AND device_id=$3 AND sequence<$7 AND updated_at>now()-interval '30 seconds'
AND ($4::uuid IS NULL OR EXISTS(SELECT 1 FROM tracks WHERE id=$4))`, a.RoomId, id, a.DeviceId, a.TrackId, a.PositionMs, a.Playing, a.Sequence)
if e != nil {
return nil, e
}
if result.RowsAffected() == 0 {
var own bool
e = h.store.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_rooms WHERE id=$1 AND owner_id=$2 AND device_id=$3 AND updated_at>now()-interval '30 seconds')`, a.RoomId, id, a.DeviceId).Scan(&own)
if e != nil {
return nil, e
}
if !own {
return nil, denied()
}
}
return gen.PublishListeningState204Response{}, nil
}
func (s *Store) cleanListening(ctx context.Context) error {
_, e := s.pool.Exec(ctx, `DELETE FROM listening_rooms WHERE updated_at<now()-interval '30 seconds'`)
if e != nil {
return e
}
_, e = s.pool.Exec(ctx, `UPDATE listening_requests SET status='cancelled' WHERE status='pending' AND expires_at<=now()`)
if e != nil {
return e
}
_, e = s.pool.Exec(ctx, `DELETE FROM listening_members m USING listening_rooms r,users u WHERE m.room_id=r.id AND u.id=r.owner_id AND m.user_id<>r.owner_id
AND (m.last_seen<now()-interval '60 seconds' OR u.is_blocked OR EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=r.owner_id AND b.peer_id=m.user_id) OR (b.user_id=m.user_id AND b.peer_id=r.owner_id)))`)
return e
}
func (s *Store) listeningState(ctx context.Context, id uuid.UUID) (gen.ListeningState, error) {
out := gen.ListeningState{Requests: []gen.ListeningRequest{}, ServerTime: time.Now().UTC()}
if e := s.cleanListening(ctx); e != nil {
return out, e
}
if e := s.pool.QueryRow(ctx, `SELECT listening_requests,listening_auto_accept,listening_autoplay,now() FROM users WHERE id=$1`, id).Scan(&out.Preferences.Requests, &out.Preferences.AutoAccept, &out.Preferences.Autoplay, &out.ServerTime); e != nil {
return out, e
}
if _, e := s.pool.Exec(ctx, `UPDATE listening_members SET last_seen=now() WHERE user_id=$1`, id); e != nil {
return out, e
}
var room gen.ListeningRoom
var participants []byte
e := s.pool.QueryRow(ctx, `SELECT r.id,r.owner_id,COALESCE(u.display_name,u.username::text),r.device_id,r.track_id,r.position_ms,r.playing,r.updated_at,
COALESCE((SELECT json_agg(json_build_object('id',p.id,'name',COALESCE(p.display_name,p.username::text)) ORDER BY p.username) FROM listening_members lm JOIN users p ON p.id=lm.user_id WHERE lm.room_id=r.id),'[]')
FROM listening_members m JOIN listening_rooms r ON r.id=m.room_id JOIN users u ON u.id=r.owner_id WHERE m.user_id=$1`, id).Scan(&room.Id, &room.OwnerId, &room.OwnerName, &room.DeviceId, &room.TrackId, &room.PositionMs, &room.Playing, &room.UpdatedAt, &participants)
if e == nil {
if e = json.Unmarshal(participants, &room.Participants); e != nil {
return out, e
}
out.Room = &room
} else if !errors.Is(e, pgx.ErrNoRows) {
return out, e
}
rows, e := s.pool.Query(ctx, `SELECT q.id,q.sender_id,COALESCE(u.display_name,u.username::text),q.recipient_id,q.owner_id,q.kind,q.expires_at
FROM listening_requests q JOIN users u ON u.id=q.sender_id WHERE q.status='pending' AND q.expires_at>now() AND (q.sender_id=$1 OR q.recipient_id=$1)
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=q.sender_id AND b.peer_id=q.recipient_id) OR (b.user_id=q.recipient_id AND b.peer_id=q.sender_id))
ORDER BY q.created_at LIMIT 50`, id)
if e != nil {
return out, e
}
defer rows.Close()
for rows.Next() {
var q gen.ListeningRequest
if e = rows.Scan(&q.Id, &q.SenderId, &q.SenderName, &q.RecipientId, &q.OwnerId, &q.Kind, &q.ExpiresAt); e != nil {
return out, e
}
out.Requests = append(out.Requests, q)
}
return out, rows.Err()
}
func listeningPolicy(ctx context.Context, tx pgx.Tx, recipient, sender uuid.UUID) (bool, bool, error) {
var policy, automatic string
var friends, blocked bool
e := tx.QueryRow(ctx, `SELECT listening_requests,listening_auto_accept,
EXISTS(SELECT 1 FROM friendships WHERE low_id=LEAST($1::uuid,$2::uuid) AND high_id=GREATEST($1::uuid,$2::uuid) AND accepted),
is_blocked OR EXISTS(SELECT 1 FROM users WHERE id=$2 AND is_blocked) OR EXISTS(SELECT 1 FROM social_blocks WHERE (user_id=$1 AND peer_id=$2) OR (user_id=$2 AND peer_id=$1))
FROM users WHERE id=$1`, recipient, sender).Scan(&policy, &automatic, &friends, &blocked)
if errors.Is(e, pgx.ErrNoRows) {
return false, false, denied()
}
if e != nil {
return false, false, e
}
return !blocked && audienceAllows(policy, friends), !blocked && audienceAllows(automatic, friends), nil
}
func admitListening(ctx context.Context, tx pgx.Tx, request, room, guest, recipient, sender uuid.UUID) error {
allowed, _, e := listeningPolicy(ctx, tx, recipient, sender)
if e != nil {
return e
}
if !allowed {
return denied()
}
result, e := tx.Exec(ctx, `INSERT INTO listening_members(user_id,room_id) VALUES($1,$2) ON CONFLICT(user_id) DO UPDATE SET last_seen=now() WHERE listening_members.room_id=EXCLUDED.room_id`, guest, room)
if e != nil {
return e
}
if result.RowsAffected() == 0 {
return apperr.Conflict("social.listening_busy", "already in another room")
}
_, e = tx.Exec(ctx, `UPDATE listening_requests SET status='accepted' WHERE id=$1`, request)
if e != nil {
return e
}
return notify(ctx, tx, sender, recipient, "listening_accepted", nil)
}
func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.ListeningAction) error {
if e := s.cleanListening(ctx); e != nil {
return e
}
tx, e := s.pool.Begin(ctx)
if e != nil {
return e
}
defer tx.Rollback(ctx)
switch a.Action {
case "leave", "end":
var owner uuid.UUID
e = tx.QueryRow(ctx, `SELECT owner_id FROM listening_rooms WHERE id=$1 FOR UPDATE`, a.TargetId).Scan(&owner)
if errors.Is(e, pgx.ErrNoRows) {
return nil
}
if e != nil {
return e
}
if a.Action == "end" && owner != id {
return denied()
}
if owner == id {
_, e = tx.Exec(ctx, `DELETE FROM listening_rooms WHERE id=$1`, a.TargetId)
} else {
_, e = tx.Exec(ctx, `DELETE FROM listening_members WHERE room_id=$1 AND user_id=$2`, a.TargetId, id)
}
case "accept", "decline", "cancel":
var room, guest, recipient, sender, owner uuid.UUID
var status string
e = tx.QueryRow(ctx, `SELECT room_id,guest_id,recipient_id,sender_id,owner_id,status FROM listening_requests WHERE id=$1 AND expires_at>now()`, a.TargetId).Scan(&room, &guest, &recipient, &sender, &owner, &status)
if errors.Is(e, pgx.ErrNoRows) {
return apperr.NotFound("social.listening_expired", "request expired")
}
if e != nil {
return e
}
if (a.Action == "cancel" && sender != id) || (a.Action != "cancel" && recipient != id) {
return denied()
}
if status != "pending" {
return nil
}
// Lock both accounts in UUID order before testing admission and membership.
if _, e = tx.Exec(ctx, `SELECT id FROM users WHERE id=ANY($1::uuid[]) ORDER BY id FOR UPDATE`, []uuid.UUID{owner, guest}); e != nil {
return e
}
if e = tx.QueryRow(ctx, `SELECT status FROM listening_requests WHERE id=$1 AND expires_at>now() FOR UPDATE`, a.TargetId).Scan(&status); e != nil {
return e
}
if status != "pending" {
return tx.Commit(ctx)
}
if a.Action == "accept" {
e = admitListening(ctx, tx, a.TargetId, room, guest, recipient, sender)
} else {
next := "declined"
if a.Action == "cancel" {
next = "cancelled"
}
_, e = tx.Exec(ctx, `UPDATE listening_requests SET status=$2 WHERE id=$1`, a.TargetId, next)
}
case "join", "invite":
if id == a.TargetId {
return invalid()
}
owner, guest, recipient := a.TargetId, id, a.TargetId
if a.Action == "invite" {
owner, guest, recipient = id, a.TargetId, a.TargetId
}
if _, e = tx.Exec(ctx, `SELECT id FROM users WHERE id=ANY($1::uuid[]) ORDER BY id FOR UPDATE`, []uuid.UUID{owner, guest}); e != nil {
return e
}
permitted, automatic, err := listeningPolicy(ctx, tx, recipient, id)
if err != nil {
return err
}
if !permitted {
return denied()
}
var repeated bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_requests WHERE id=$1 AND sender_id=$2 AND owner_id=$3 AND guest_id=$4 AND kind=$5)`, a.Id, id, owner, guest, a.Action).Scan(&repeated); e != nil {
return e
}
if repeated {
return tx.Commit(ctx)
}
var busy bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_members m JOIN listening_rooms r ON r.id=m.room_id WHERE (m.user_id=$1 OR m.user_id=$2) AND r.owner_id<>$1)`, owner, guest).Scan(&busy); e != nil {
return e
}
if busy {
return apperr.Conflict("social.listening_busy", "already in another room")
}
var recent int
if e = tx.QueryRow(ctx, `SELECT count(*) FROM listening_requests WHERE sender_id=$1 AND created_at>now()-interval '1 minute'`, id).Scan(&recent); e != nil {
return e
}
if recent >= 10 {
return apperr.TooManyRequests("social.rate_limited", 60)
}
var room uuid.UUID
e = tx.QueryRow(ctx, `SELECT id FROM listening_rooms WHERE owner_id=$1`, owner).Scan(&room)
if errors.Is(e, pgx.ErrNoRows) {
var device, track uuid.UUID
position := 0
playing := true
if a.Action == "invite" {
if a.DeviceId == nil || *a.DeviceId == uuid.Nil || a.TrackId == nil || *a.TrackId == uuid.Nil {
return invalid()
}
device, track = *a.DeviceId, *a.TrackId
if a.PositionMs != nil {
position = *a.PositionMs
}
if position < 0 || position > 86400000 {
return invalid()
}
if a.Playing != nil {
playing = *a.Playing
}
} else {
visible, err := s.activities(ctx, id, []uuid.UUID{owner})
if err != nil {
return err
}
if visible[owner] == nil {
return apperr.NotFound("social.activity_unavailable", "activity unavailable")
}
e = tx.QueryRow(ctx, `SELECT device_id,track_id,position_ms+LEAST(90000,GREATEST(0,(EXTRACT(EPOCH FROM(now()-updated_at))*1000)::int)) FROM listening_activity WHERE user_id=$1 AND track_id IS NOT NULL AND updated_at>now()-interval '90 seconds' ORDER BY updated_at DESC,device_id LIMIT 1`, owner).Scan(&device, &track, &position)
if e != nil {
return e
}
}
var exists bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tracks WHERE id=$1)`, track).Scan(&exists); e != nil {
return e
}
if !exists {
return invalid()
}
room = uuid.New()
_, e = tx.Exec(ctx, `INSERT INTO listening_rooms(id,owner_id,device_id,track_id,position_ms,playing) VALUES($1,$2,$3,$4,$5,$6)`, room, owner, device, track, position, playing)
if e != nil {
return e
}
_, e = tx.Exec(ctx, `INSERT INTO listening_members(user_id,room_id) VALUES($1,$2)`, owner, room)
}
if e != nil {
return e
}
result, err := tx.Exec(ctx, `INSERT INTO listening_requests(id,room_id,owner_id,guest_id,sender_id,recipient_id,kind) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`, a.Id, room, owner, guest, id, recipient, a.Action)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return apperr.Conflict("social.listening_pending", "request already pending")
}
if automatic {
e = admitListening(ctx, tx, a.Id, room, guest, recipient, id)
} else {
kind := "listening_request"
if a.Action == "invite" {
kind = "listening_invite"
}
e = notify(ctx, tx, recipient, id, kind, nil)
}
default:
return invalid()
}
if e != nil {
return e
}
return tx.Commit(ctx)
}
@@ -38,3 +38,14 @@ func TestHandlersRequireIdentityAndRejectSelf(t *testing.T) {
_, e = h.SearchPeople(ctx, gen.SearchPeopleRequestObject{Params: gen.SearchPeopleParams{Q: " "}})
require.Error(t, e)
}
func TestListeningAudienceAndReactionValidation(t *testing.T) {
require.False(t, audienceAllows("nobody", true))
require.False(t, audienceAllows("friends", false))
require.True(t, audienceAllows("friends", true))
require.True(t, audienceAllows("everyone", false))
require.False(t, audienceAllows("unexpected", true))
require.True(t, validEmoji("❤️"))
require.False(t, validEmoji("<script>"))
require.Error(t, validateActivity(gen.ListeningActivityInput{DeviceId: uuid.New(), Sequence: 1, Playing: true, Context: "wave"}))
}
+37 -8
View File
@@ -73,7 +73,11 @@ func (s *Store) people(ctx context.Context, id uuid.UUID, q string) ([]gen.Socia
if e != nil {
return nil, e
}
return scanPeople(rows)
people, e := scanPeople(rows)
if e != nil {
return nil, e
}
return people, s.attachActivity(ctx, id, people)
}
func (s *Store) inbox(ctx context.Context, id uuid.UUID) (gen.SocialInbox, error) {
out := gen.SocialInbox{People: []gen.SocialPerson{}, Notifications: []gen.SocialNotification{}}
@@ -85,6 +89,12 @@ func (s *Store) inbox(ctx context.Context, id uuid.UUID) (gen.SocialInbox, error
if e != nil {
return out, e
}
if e = s.attachActivity(ctx, id, out.People); e != nil {
return out, e
}
if e = s.pool.QueryRow(ctx, `SELECT activity_visibility,friends_recommendations FROM users WHERE id=$1`, id).Scan(&out.ActivityVisibility, &out.FriendsRecommendations); 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))
@@ -215,14 +225,19 @@ func (s *Store) action(ctx context.Context, id uuid.UUID, a gen.SocialAction) er
return tx.Commit(ctx)
}
const messageColumns = `id,seq,sender_id,text,attachment_kind,attachment_id,created_at,read_at IS NOT NULL`
const messageColumns = `id,seq,sender_id,CASE WHEN deleted_at IS NULL THEN text ELSE '' END,attachment_kind,attachment_id,created_at,read_at IS NOT NULL,reply_to,edited_at,deleted_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)
var deleted bool
e := row.Scan(&m.Id, &m.Seq, &m.SenderId, &m.Text, &m.AttachmentKind, &m.AttachmentId, &m.CreatedAt, &m.Read, &m.ReplyTo, &m.EditedAt, &deleted)
m.Deleted = &deleted
return m, e
}
func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64) ([]gen.DirectMessage, error) {
return s.messagePage(ctx, id, peer, before, "")
}
func (s *Store) messagePage(ctx context.Context, id, peer uuid.UUID, before *int64, q string) ([]gen.DirectMessage, error) {
tx, e := pairTx(ctx, s.pool, id, peer)
if e != nil {
return nil, e
@@ -231,7 +246,7 @@ func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64)
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)
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) AND ($4='' OR (deleted_at IS NULL AND strpos(lower(text),lower($4))>0)) ORDER BY seq DESC LIMIT 50`, id, peer, before, q)
if e != nil {
return nil, e
}
@@ -250,7 +265,8 @@ func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64)
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
rows.Close()
return out, enrichMessages(ctx, tx, id, out)
}
func (s *Store) send(ctx context.Context, id, peer uuid.UUID, in gen.SendDirectMessage) (gen.DirectMessage, error) {
var zero gen.DirectMessage
@@ -274,10 +290,13 @@ func (s *Store) send(ctx context.Context, id, peer uuid.UUID, in gen.SendDirectM
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 {
sameReply := (m.ReplyTo == nil && in.ReplyTo == nil) || (m.ReplyTo != nil && in.ReplyTo != nil && *m.ReplyTo == *in.ReplyTo)
if m.SenderId != id || recipient != peer || m.Text != strings.TrimSpace(in.Text) || string(m.AttachmentKind) != string(in.AttachmentKind) || !sameAttachment || !sameReply {
return zero, apperr.Conflict("social.message_conflict", "message ID already used")
}
return m, nil
out := []gen.DirectMessage{m}
e = enrichMessages(ctx, tx, id, out)
return out[0], e
}
if !errors.Is(e, pgx.ErrNoRows) {
return zero, e
@@ -303,7 +322,17 @@ func (s *Store) send(ctx context.Context, id, peer uuid.UUID, in gen.SendDirectM
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 in.ReplyTo != nil {
var valid bool
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM direct_messages WHERE id=$1 AND LEAST(sender_id,recipient_id)=LEAST($2::uuid,$3::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($2::uuid,$3::uuid) AND deleted_at IS NULL)`, in.ReplyTo, id, peer).Scan(&valid)
if e != nil {
return zero, e
}
if !valid {
return zero, apperr.Conflict("social.message_conflict", "reply unavailable")
}
}
m, e = scanMessage(tx.QueryRow(ctx, `INSERT INTO direct_messages(id,sender_id,recipient_id,text,attachment_kind,attachment_id,reply_to) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING `+messageColumns, in.Id, id, peer, strings.TrimSpace(in.Text), in.AttachmentKind, in.AttachmentId, in.ReplyTo))
if e != nil {
return zero, e
}
+48
View File
@@ -0,0 +1,48 @@
package wave
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Only currently visible activity is a social signal. A hidden profile never contributes
// its listening history, and self feedback/recent plays retain precedence.
func (h *Handler) friendCandidates(ctx context.Context, user uuid.UUID, exclude []string) ([]string, error) {
rows, e := h.pool.Query(ctx, `SELECT a.track_id::text FROM listening_activity a
JOIN users friend ON friend.id=a.user_id AND NOT friend.is_blocked AND friend.activity_visibility IN ('friends','everyone')
JOIN users own ON own.id=$1 AND own.friends_recommendations
JOIN friendships f ON f.low_id=LEAST($1::uuid,a.user_id) AND f.high_id=GREATEST($1::uuid,a.user_id) AND f.accepted
JOIN tracks t ON t.id=a.track_id JOIN media_files mf ON mf.id=t.primary_file_id AND mf.missing_since IS NULL
JOIN library_sources src ON src.id=mf.source_id AND src.status='mounted'
WHERE a.updated_at>now()-interval '90 seconds' AND NOT(a.track_id::text=ANY($2::text[]))
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=a.user_id) OR (b.user_id=a.user_id AND b.peer_id=$1))
AND NOT EXISTS(SELECT 1 FROM track_reactions r WHERE r.user_id=$1 AND r.track_id=a.track_id AND r.reaction='dislike')
AND NOT EXISTS(SELECT 1 FROM play_events e WHERE e.user_id=$1 AND e.track_id=a.track_id AND e.type='play_start' AND e.occurred_at>now()-interval '3 hours')
GROUP BY a.track_id ORDER BY count(DISTINCT a.user_id) DESC,max(a.updated_at) DESC LIMIT 30`, user, exclude)
if e != nil {
return nil, e
}
defer rows.Close()
return pgx.CollectRows(rows, pgx.RowTo[string])
}
func blendFriendPick(picks []Pick, ids []string) []Pick {
if len(ids) == 0 || len(picks) < 3 {
return picks
}
for _, id := range ids {
exists := false
for _, p := range picks {
if p.ID == id {
exists = true
break
}
}
if !exists {
out := append([]Pick(nil), picks...)
out[len(out)-1] = Pick{ID: id, Source: "friends", Score: 0.1}
return out
}
}
return picks
}
@@ -0,0 +1,16 @@
package wave
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestFriendMixRemainsMinorityAndDoesNotDuplicate(t *testing.T) {
original := []Pick{{ID: "one"}, {ID: "two"}, {ID: "three"}, {ID: "four"}, {ID: "five"}}
mixed := blendFriendPick(original, []string{"one", "friend"})
require.Len(t, mixed, 5)
require.Equal(t, "friends", mixed[4].Source)
require.Equal(t, "friend", mixed[4].ID)
require.Equal(t, "five", original[4].ID)
require.Equal(t, original, blendFriendPick(original, []string{"one"}))
}
+9
View File
@@ -67,6 +67,7 @@ type Feedback struct {
// BatchRequest — запрос к рекомендателю.
type BatchRequest struct {
FriendTrackIDs []string `json:"friend_track_ids,omitempty"`
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
Activity *string `json:"activity"`
@@ -295,6 +296,11 @@ func (h *Handler) serve(ctx context.Context, s session, fb []Feedback) (gen.Wave
UserID: s.UserID.String(), SessionID: s.ID.String(), Activity: s.Activity, Mood: s.Mood,
ExcludeIDs: served, Feedback: fb, N: BatchSize,
}
friendIDs, friendErr := h.friendCandidates(ctx, s.UserID, served)
if friendErr != nil {
h.log.WarnContext(ctx, "friend music unavailable", "err", friendErr)
}
req.FriendTrackIDs = friendIDs
if s.SeedType != nil && s.SeedID != nil {
req.Seed = &Seed{Type: *s.SeedType, ID: s.SeedID.String()}
}
@@ -311,6 +317,9 @@ func (h *Handler) serve(ctx context.Context, s session, fb []Feedback) (gen.Wave
if picks, err = h.fallback(ctx, s, served); err != nil {
return gen.WaveBatch{}, err
}
if s.SeedType == nil && len(served)%10 >= 5 {
picks = blendFriendPick(picks, friendIDs)
}
}
ids := make([]uuid.UUID, 0, len(picks))
@@ -0,0 +1,21 @@
-- +goose Up
ALTER TABLE users ADD COLUMN activity_visibility text NOT NULL DEFAULT 'nobody'
CHECK(activity_visibility IN ('nobody','friends','everyone'));
ALTER TABLE users ADD COLUMN friends_recommendations boolean NOT NULL DEFAULT true;
CREATE TABLE listening_activity (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id uuid NOT NULL,
sequence bigint NOT NULL CHECK(sequence>0),
position_ms integer NOT NULL DEFAULT 0 CHECK(position_ms>=0),
track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
context text NOT NULL CHECK(context IN ('other','wave','playlist')),
playlist_id uuid REFERENCES playlists(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(user_id,device_id)
);
CREATE INDEX listening_activity_recent_idx ON listening_activity(user_id,updated_at DESC) WHERE track_id IS NOT NULL;
-- +goose Down
DROP TABLE listening_activity;
ALTER TABLE users DROP COLUMN activity_visibility;
ALTER TABLE users DROP COLUMN friends_recommendations;
@@ -0,0 +1,46 @@
-- +goose Up
ALTER TABLE users ADD COLUMN listening_requests text NOT NULL DEFAULT 'nobody' CHECK(listening_requests IN ('nobody','friends','everyone')),
ADD COLUMN listening_auto_accept text NOT NULL DEFAULT 'nobody' CHECK(listening_auto_accept IN ('nobody','friends','everyone')),
ADD COLUMN listening_autoplay boolean NOT NULL DEFAULT true;
CREATE TABLE listening_rooms (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
device_id uuid NOT NULL,
sequence bigint NOT NULL DEFAULT 0,
track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
position_ms integer NOT NULL DEFAULT 0 CHECK(position_ms>=0),
playing boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE listening_members (
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
room_id uuid NOT NULL REFERENCES listening_rooms(id) ON DELETE CASCADE,
last_seen timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX listening_members_room_idx ON listening_members(room_id);
CREATE TABLE listening_requests (
id uuid PRIMARY KEY,
room_id uuid NOT NULL REFERENCES listening_rooms(id) ON DELETE CASCADE,
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
guest_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
sender_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipient_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind text NOT NULL CHECK(kind IN ('join','invite')),
status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined','cancelled')),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT now()+interval '2 minutes',
CHECK(owner_id<>guest_id)
);
CREATE UNIQUE INDEX listening_requests_pending_idx ON listening_requests(owner_id,guest_id) WHERE status='pending';
CREATE INDEX listening_requests_recipient_idx ON listening_requests(recipient_id,expires_at) WHERE status='pending';
ALTER TABLE social_notifications DROP CONSTRAINT social_notifications_kind_check;
ALTER TABLE social_notifications ADD CHECK(kind IN ('friend_request','friend_accepted','message','music','listening_request','listening_invite','listening_accepted'));
-- +goose Down
DELETE FROM social_notifications WHERE kind LIKE 'listening_%';
ALTER TABLE social_notifications DROP CONSTRAINT social_notifications_kind_check;
ALTER TABLE social_notifications ADD CHECK(kind IN ('friend_request','friend_accepted','message','music'));
DROP TABLE listening_requests;
DROP TABLE listening_members;
DROP TABLE listening_rooms;
ALTER TABLE users DROP COLUMN listening_requests, DROP COLUMN listening_auto_accept, DROP COLUMN listening_autoplay;
@@ -0,0 +1,20 @@
-- +goose Up
ALTER TABLE direct_messages ADD COLUMN reply_to uuid REFERENCES direct_messages(id) ON DELETE SET NULL,
ADD COLUMN edited_at timestamptz, ADD COLUMN deleted_at timestamptz;
CREATE TABLE message_reactions (
message_id uuid NOT NULL REFERENCES direct_messages(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji text NOT NULL CHECK(emoji IN ('❤️','👍','🔥','😂','🎵','👏')),
PRIMARY KEY(message_id,user_id,emoji)
);
CREATE TABLE chat_typing (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
peer_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
PRIMARY KEY(user_id,peer_id), CHECK(user_id<>peer_id)
);
-- +goose Down
DROP TABLE chat_typing;
DROP TABLE message_reactions;
ALTER TABLE direct_messages DROP COLUMN reply_to, DROP COLUMN edited_at, DROP COLUMN deleted_at;
+10 -1
View File
@@ -313,6 +313,7 @@ def build_batch(
rng: np.random.Generator | None = None,
ranker: Ranker | None = None,
ctx: Context | None = None,
friend_ids: list[str] | None = None,
) -> list[Pick]:
"""Кандидаты → фильтры → оценка → соответствие контексту → MMR с правилами разнообразия."""
rng = rng or np.random.default_rng()
@@ -363,6 +364,8 @@ def build_batch(
if seeds:
q = index.emb[seeds].mean(axis=0)
add(_top((index.emb @ q).astype(np.float64), allowed & index.has_emb, 100), "seed")
friends = [index.pos[t] for t in (friend_ids or []) if t in index.pos and allowed[index.pos[t]]][:10]
add(friends, "friends")
query = np.maximum(sim_session, sim_taste) if svec is not None or len(centroids) else None
if query is not None:
# «Новое» помечаем раньше audio_sim: неслышанный трек рядом со вкусом считается исследованием.
@@ -446,6 +449,8 @@ def build_batch(
pred = ranker.predict(x)
score = np.argsort(np.argsort(pred)) / max(len(pred) - 1, 1)
# Social influence is small, context-sensitive, and survives the learned ranker.
score = score + 0.06 * np.isin(cand, friends) * f
# 5. MMR + правила: 1 трек исполнителя на пачку, не больше 2 на последние 10; доля «нового».
picks: list[Pick] = []
chosen: list[int] = []
@@ -457,6 +462,8 @@ def build_batch(
best_j, best_v = -1, -np.inf
for j in order:
i = int(cand[j])
if sources[i] == "friends" and any(p.source == "friends" for p in picks):
continue
a = index.artist[i]
if any(index.artist[c] == a for c in chosen) or recent_artists.count(a) >= 2:
continue
@@ -471,7 +478,9 @@ def build_batch(
continue
if chosen:
chosen_artists = {index.artist[c] for c in chosen}
rest = [j for j in order if index.artist[int(cand[j])] in chosen_artists]
social_chosen = any(p.source == "friends" for p in picks)
rest = [j for j in order if index.artist[int(cand[j])] in chosen_artists
and not (sources[int(cand[j])] == "friends" and social_chosen)]
if not rest:
break
best_j = max(rest, key=lambda j: score[j])
@@ -38,6 +38,7 @@ class BatchRequest(BaseModel):
mood: str | None = None
seed: Seed | None = None
exclude_ids: list[str] = Field(default_factory=list, max_length=1000)
friend_track_ids: list[str] = Field(default_factory=list, max_length=30)
feedback: list[Feedback] = Field(default_factory=list, max_length=200)
n: int = Field(default=5, ge=1, le=20)
@@ -93,6 +94,7 @@ class WaveService:
n=req.n,
exclude=set(req.exclude_ids),
seed_ids=seeds,
friend_ids=req.friend_track_ids,
rng=np.random.default_rng(),
ranker=ranker,
ctx=Context(has_activity=bool(req.activity), has_mood=bool(req.mood)),
+17
View File
@@ -45,6 +45,23 @@ def profile(**kw: object) -> Profile:
return Profile(**base) # type: ignore[arg-type]
def test_friend_signal_is_bounded_and_respects_negative_feedback() -> None:
index = library()
blocked = {"t0-0", "t1-0"}
picks = build_batch(index, profile(disliked=blocked), Session(), [], n=5,
friend_ids=index.ids, rng=np.random.default_rng(8))
assert not blocked.intersection(p.id for p in picks)
assert sum(p.source == "friends" for p in picks) <= 1
assert len(picks) == 5
def test_empty_friend_signal_does_not_change_ranking() -> None:
index = library()
first = build_batch(index, profile(), Session(), [], rng=np.random.default_rng(17))
second = build_batch(index, profile(), Session(), [], friend_ids=[], rng=np.random.default_rng(17))
assert [p.id for p in first] == [p.id for p in second]
def cluster(track_id: str) -> int:
return int(track_id[1])