fix(social): complete shared listening request lifecycle

This commit is contained in:
mrleo1nid committed 2026-10-24 02:16:00 +03:00
1 parent cb22f15912
commit 59983ed1b3
14 files changed
+302 -18

No files matched your search

@@ -38,7 +38,7 @@ export function ListenTogetherButton({ person }: { person: SocialPerson }) {
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 }); } });
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void 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')} />
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test';
for (const scenario of ['pending', 'automatic', 'restricted', 'incoming']) {
test(`listening request lifecycle: ${scenario}`, async ({ page }, info) => {
await page.addInitScript(value => {
localStorage.setItem('pipu.locale', 'ru');
localStorage.setItem('pipu.e2e.listening', value);
}, scenario);
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
const banner = page.getByRole('region', { name: 'Слушать вместе', exact: true });
await expect(banner).toHaveCount(0);
if (scenario === 'incoming') {
await expect(page.getByRole('button', { name: 'Принять', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('pending-invite.png'), fullPage: true });
await page.getByRole('button', { name: 'Принять', exact: true }).click();
} else {
await page.getByRole('button', { name: 'Слушать вместе', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Слушать вместе', exact: true });
await expect(dialog.getByRole('radio', { name: 'Подключиться к другу', exact: true })).toBeChecked();
await dialog.getByRole('button', { name: 'Отправить', exact: true }).click();
if (scenario === 'restricted') {
await expect(dialog.getByRole('alert')).toContainText('Совместное прослушивание недоступно');
await expect(dialog.getByRole('alert')).not.toContainText('Чат доступен только друзьям');
await page.screenshot({ path: info.outputPath('request-restricted.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
await expect(dialog).toHaveCount(0);
if (scenario === 'pending') {
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toBeVisible();
await expect(banner).toHaveCount(0);
await page.getByRole('button', { name: 'Отмена', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
}
await expect(banner).toContainText('Слушаете вместе с moderator');
await expect(page.getByRole('contentinfo', { name: 'Плеер', exact: true }).getByRole('button', { name: 'Следующий', exact: true })).toBeDisabled();
await banner.getByRole('button', { name: 'Выйти из прослушивания', exact: true }).click();
await expect(banner).toHaveCount(0);
});
}
+67
View File
@@ -15,6 +15,73 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup);
describe('social', () => {
it.each([false, true])('closes a join dialog and follows only after admission (automatic=%s)', async (automatic) => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, session } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 1000 });
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: automatic ? 'friends' : 'nobody', autoplay: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
if (!automatic) {
expect(api.listeningMembers.has(alice.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
expect(api.listeningRequests.size).toBe(1);
const request = [...api.listeningRequests.values()][0]!;
const room = [...api.listeningRooms.values()][0]!;
api.listeningMembers.set(alice.id, room.id);
api.listeningRequests.delete(request.id);
refreshListening(session);
}
await screen.findByText('Слушаете вместе с bob');
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
await waitFor(() => expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument());
});
it('closes a pending invitation without announcing a stream and allows cancellation', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, router } = await renderApp([alice, bob], `/track/${albumTracks[0]!.id}`, { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: 'nobody', autoplay: true });
await router.navigate({ to: '/friends' });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
expect(api.listeningMembers.has(bob.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: 'Отмена' }));
await waitFor(() => expect(api.listeningRequests.size).toBe(0));
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
});
it('explains listening restrictions instead of reporting a chat error and can close after rejection', 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 });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Совместное прослушивание недоступно');
expect(within(dialog).getByRole('alert')).not.toHaveTextContent('Чат доступен только друзьям');
expect(api.listeningRequests.size).toBe(0);
expect(api.listeningRooms.size).toBe(0);
await user.click(within(dialog).getByRole('button', { name: 'Закрыть' }));
expect(dialog).not.toBeInTheDocument();
});
it('enables listening together only while a friend is online, independently of music privacy', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
+1 -1
View File
@@ -42,7 +42,7 @@ function ListenTogetherDialog({ person, close }: { person: SocialPerson; close:
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 }); } });
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void 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>
+1 -1
View File
@@ -40,7 +40,7 @@ function fixture() {
expect(overlap.volume).toBeGreaterThan(0);
};
const follow = async () => {
snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [] } };
snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [{ id: 'host', name: 'Host' }, { id: 'guest', name: 'Guest' }] } };
player.start();
await vi.waitFor(() => expect(player.getState()).toMatchObject({ track: { id: 'remote' }, playing: true, listening: { locked: true } }));
};
+16 -1
View File
@@ -1,4 +1,4 @@
import { fakeApi, makeUser } from './fakeApi';
import { albumTracks, fakeApi, makeUser } from './fakeApi';
import { mobileFixtures } from './mobileFixtures';
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
@@ -14,6 +14,21 @@ export function installE2EFetch() {
const alice = [...api.users.values()].find(u => u.username === 'alice')!;
const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
const listeningScenario = localStorage.getItem('pipu.e2e.listening');
if (listeningScenario) {
const device = crypto.randomUUID();
api.presence.set(friend.id, Date.now());
api.activityVisibility.set(friend.id, 'friends');
api.activity.set(friend.id, { device_id: device, track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
api.listeningPreferences.set(friend.id, { requests: listeningScenario === 'restricted' ? 'nobody' : 'friends', auto_accept: listeningScenario === 'automatic' ? 'friends' : 'nobody', autoplay: true });
if (listeningScenario === 'incoming') {
const roomId = crypto.randomUUID();
const requestId = crypto.randomUUID();
api.listeningRooms.set(roomId, { id: roomId, owner_id: friend.id, owner_name: friend.username, device_id: device, track_id: albumTracks[0]!.id, position_ms: 0, playing: false, updated_at: new Date().toISOString(), participants: [] });
api.listeningMembers.set(friend.id, roomId);
api.listeningRequests.set(requestId, { id: requestId, owner_id: friend.id, guest_id: alice.id, sender_id: friend.id, sender_name: friend.username, recipient_id: alice.id, kind: 'invite', expires_at: new Date(Date.now() + 120_000).toISOString() });
}
}
if (localStorage.getItem('pipu.e2e.notifications') === '1') {
const kinds = ['message', 'friend_request', 'friend_accepted', 'music'] as const;
for (let index = 0; index < 28; index++) api.notifications.push({
+6 -1
View File
@@ -260,8 +260,13 @@ export function fakeApi(initial: FakeUser[]) {
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');
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.listening_unavailable');
if ((presence.get(target) ?? 0) <= Date.now() - 45_000) return problem(409, 'social.person_offline');
const guestRoom = listeningRooms.get(listeningMembers.get(guest) ?? '');
const hostRoom = listeningRooms.get(listeningMembers.get(host) ?? '');
if (guestRoom && guestRoom.owner_id !== host || hostRoom && hostRoom.owner_id !== host) return problem(409, 'social.listening_busy');
if (guestRoom?.owner_id === host || listeningRequests.has(String(body.id))) return new Response(null, { status: 204 });
if ([...listeningRequests.values()].some(r => r.owner_id === host && r.guest_id === guest)) return problem(409, 'social.listening_pending');
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);
+8
View File
@@ -89,6 +89,14 @@ React Query; Android останавливает polling при уходе при
«Подключиться» отправляет запрос хозяину и оставляет управление у него.
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
Комната резервируется на время ожидания подтверждения. До принятия запроса
слушатель не становится участником, а баннер совместного прослушивания не показывается.
Исходное устройство ведущего поддерживает резервирование, не прерывая свою музыку.
После успешной отправки окно закрывается; ожидающий запрос можно отменить в разделе общения.
После отмены или истечения последнего запроса исходное устройство освобождает
пустую комнату при следующем опросе, не прерывая свою музыку.
Повторный запрос уже подключённого слушателя не создаёт нового ожидания.
Второе устройство ведущего не включает звук автоматически и при закрытии не завершает стрим.
Настройки приёма запросов/приглашений и автоматического принятия независимы,
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
Дополнительная настройка управляет запуском звука при подключении.
+44
View File
@@ -31,4 +31,48 @@ describe('shared listening',()=>{
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();
});
it('keeps a reserved source room alive without announcing a stream before approval',async()=>{
const e=setup(host,device);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]},requests:[{id:'request',owner_id:host,sender_id:guest,sender_name:'Guest',recipient_id:host,kind:'join',expires_at:'2026-10-23T00:02:00Z'}]});
await e.coordinator.poll(true);
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
expect(e.put).toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();expect(e.adapter.apply).not.toHaveBeenCalled();e.coordinator.dispose();
});
it('releases an empty source reservation after its final request is cancelled or expires',async()=>{
const e=setup(host,device);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]}});
await e.coordinator.poll(true);
expect(e.post).toHaveBeenCalledWith('/api/v1/me/social/listening/actions',expect.objectContaining({body:expect.objectContaining({action:'end',target_id:room.id})}));
expect(e.put).not.toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
e.coordinator.dispose();expect(e.post).toHaveBeenCalledTimes(1);
});
it('does not take over another source device while its request is pending',async()=>{
const e=setup(host);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]}});
await e.coordinator.poll(true);
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();e.coordinator.dispose();expect(e.post).not.toHaveBeenCalled();
});
it('does not start a pending guest request with no admitted room',async()=>{
const e=setup();e.setState({...e.state,room:null,requests:[{id:'request',owner_id:host,sender_id:guest,sender_name:'Guest',recipient_id:host,kind:'join',expires_at:'2026-10-23T00:02:00Z'}]});
await e.coordinator.poll(true);expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.coordinator.locked).toBe(false);e.coordinator.dispose();
});
it('requires explicit audio on a second owner device and does not end the source when it closes',async()=>{
const e=setup(host);await e.coordinator.poll(true);
expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:false}),3000,expect.any(Function));
e.coordinator.beginAudio();await vi.waitFor(()=>expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:true}),3000,expect.any(Function)));
e.coordinator.dispose();expect(e.post).not.toHaveBeenCalled();
});
it('discards a room lookup that arrives after a local exit',async()=>{
const e=setup();let resolve!:(value:{data:ListeningState;response:Response})=>void;
e.get.mockImplementationOnce(()=>new Promise(done=>{resolve=done;}));
const pending=e.coordinator.poll(true);e.coordinator.leaveLocally();resolve({data:e.state,response:new Response()});await pending;
expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
});
it('queues a refresh requested while a state lookup is in flight',async()=>{
const e=setup();let resolve!:(value:{data:ListeningState;response:Response})=>void;
e.get.mockImplementationOnce(()=>new Promise(done=>{resolve=done;}));
const pending=e.coordinator.poll(true);e.coordinator.refresh();
e.setState({...e.state,room:null});resolve({data:{...e.state,room:{...room}},response:new Response()});await pending;
await vi.waitFor(()=>expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null})));
expect(e.get).toHaveBeenCalledTimes(2);e.coordinator.dispose();
});
});
+34 -7
View File
@@ -22,6 +22,7 @@ export class ListeningCoordinator {
private generation = 0;
private sequence = 0;
private busy = false;
private refreshPending = false;
private closed = false;
private lastPoll = 0;
private lastSuccess = 0;
@@ -46,9 +47,12 @@ export class ListeningCoordinator {
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); }
if (key !== this.key) { this.key = key; this.refresh(); }
}
refresh() {
if (this.busy) { this.refreshPending = true; return; }
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() {
@@ -66,9 +70,10 @@ export class ListeningCoordinator {
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();
const requestGeneration = this.generation;
try {
const state = unwrap(await this.session.client.GET('/api/v1/me/social/listening'));
if (this.closed) return;
if (this.closed || requestGeneration !== this.generation) return;
this.lastSuccess = this.now();
const previous = this.snapshot?.room;
this.snapshot = state;
@@ -80,14 +85,32 @@ export class ListeningCoordinator {
await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'leave', target_id: room.id } });
return;
}
// The server reserves a room while a request is pending. Only admitted
// participants turn it into a shared session; keep the source heartbeat
// alive without displaying a stream or taking over another device.
const shared = room.participants.some(person => person.id !== room.owner_id);
if (!shared && !this.source(room)) {
if (this.locked) { this.generation++; this.adapter.pause(); }
this.emit(null);
return;
}
if (this.source(room)) {
this.emit(room);
// A cancelled or expired reservation must not keep an invisible room
// alive forever and prevent its owner from joining another stream.
if (!shared && !state.requests.some(request => request.owner_id === room.owner_id)) {
unwrap(await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'end', target_id: room.id } }));
this.snapshot = { ...state, room: null };
this.emit(null);
return;
}
this.emit(shared ? room : null);
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);
const ownOtherDevice = room.owner_id === this.session.state.user.id;
const needsStart = (!state.preferences.autoplay || ownOtherDevice) && !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;
@@ -95,7 +118,10 @@ export class ListeningCoordinator {
} 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; }
} finally {
this.busy = false;
if (this.refreshPending && !this.closed) { this.refreshPending = false; this.refresh(); }
}
}
dispose() {
if (this.closed) return;
@@ -104,7 +130,8 @@ export class ListeningCoordinator {
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);
const ownAccount = this.session.state.status === 'signed_in' && room?.owner_id === this.session.state.user.id;
if (room && (this.source(room) || !ownAccount)) 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);
}
}
+1
View File
@@ -124,6 +124,7 @@
"target_changed": "The source changed after checking. Check for updates again."
},
"social": {
"listening_unavailable": "Shared listening is unavailable. This person does not accept your requests or invitations, or is no longer available. Request preferences are in the profile menu → Privacy and communication.",
"person_offline": "This person is offline. Try inviting them again when they connect",
"invalid": "Check your message or action",
"unavailable": "Chats are available to friends only. The friendship may have ended or the person is unavailable",
+1
View File
@@ -124,6 +124,7 @@
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
},
"social": {
"listening_unavailable": "Совместное прослушивание недоступно. Пользователь не принимает запросы или приглашения от вас либо больше недоступен. Настройки приёма находятся в меню профиля → «Приватность и общение».",
"person_offline": "Пользователь не в сети. Повторите приглашение, когда он подключится",
"invalid": "Проверьте сообщение или действие",
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
@@ -85,6 +85,18 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
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='nobody' WHERE id=$1`, owner)
require.NoError(t, e)
require.NoError(t, s.listeningAction(ctx, friend, join))
pendingJoin, e := s.listeningState(ctx, friend)
require.NoError(t, e)
require.Nil(t, pendingJoin.Room, "sending a request is not admission")
require.Len(t, pendingJoin.Requests, 1)
pendingHost, e := s.listeningState(ctx, owner)
require.NoError(t, e)
require.Len(t, pendingHost.Room.Participants, 1)
require.NoError(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: join.Id}))
join.Id = uuid.New()
_, 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}))
@@ -94,6 +106,12 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
require.NotNil(t, state.Room)
require.Equal(t, owner, state.Room.OwnerId)
require.Len(t, state.Room.Participants, 2)
// Reopening the dialog must not create another pending request for an
// already admitted listener, even with a new action ID.
require.NoError(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}))
state, e = s.listeningState(ctx, friend)
require.NoError(t, e)
require.Empty(t, state.Requests)
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}
@@ -113,11 +131,50 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
require.NoError(t, e)
require.Len(t, state.Requests, 1)
require.Nil(t, state.Room)
reserved, e := s.listeningState(ctx, owner)
require.NoError(t, e)
require.NotNil(t, reserved.Room)
require.Len(t, reserved.Room.Participants, 1, "pending invite must not admit the guest")
require.NoError(t, s.listeningAction(ctx, owner, invite), "network retry is idempotent")
duplicate := invite
duplicate.Id = uuid.New()
require.ErrorContains(t, s.listeningAction(ctx, owner, duplicate), "listening_pending")
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: invite.Id}))
require.NoError(t, s.listeningAction(ctx, owner, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
_, e = pool.Exec(ctx, `UPDATE listening_requests SET expires_at=now()-interval '1 second' WHERE id=$1`, invite.Id)
require.NoError(t, e)
require.ErrorContains(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}), "listening_expired")
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "decline", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='nobody' WHERE id=$1`, other)
require.NoError(t, e)
require.ErrorContains(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}), "listening_unavailable")
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='everyone' WHERE id=$1`, other)
require.NoError(t, e)
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)
require.Len(t, state.Room.Participants, 2)
require.ErrorContains(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "invite", TargetId: other, DeviceId: &device, TrackId: &track}), "listening_busy")
_, 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)
+16 -6
View File
@@ -17,6 +17,9 @@ func audienceAllows(policy string, friends bool) bool {
func validAudience(v gen.ActivityVisibility) bool {
return v == "nobody" || v == "friends" || v == "everyone"
}
func listeningDenied() error {
return apperr.Forbidden("social.listening_unavailable", "shared listening is not permitted")
}
func (h *Handler) SetListeningPreferences(ctx context.Context, r gen.SetListeningPreferencesRequestObject) (gen.SetListeningPreferencesResponseObject, error) {
id, e := userID(ctx)
if e != nil {
@@ -75,7 +78,7 @@ func (h *Handler) PublishListeningState(ctx context.Context, r gen.PublishListen
return nil, e
}
if !own {
return nil, denied()
return nil, listeningDenied()
}
}
return gen.PublishListeningState204Response{}, nil
@@ -147,7 +150,7 @@ func listeningPolicy(ctx context.Context, tx pgx.Tx, recipient, sender uuid.UUID
COALESCE(social_seen_at>now()-interval '45 seconds',false) AND EXISTS(SELECT 1 FROM users WHERE id=$2 AND social_seen_at>now()-interval '45 seconds')
FROM users WHERE id=$1`, recipient, sender).Scan(&policy, &automatic, &friends, &blocked, &online)
if errors.Is(e, pgx.ErrNoRows) {
return false, false, denied()
return false, false, listeningDenied()
}
if e != nil {
return false, false, e
@@ -164,7 +167,7 @@ func admitListening(ctx context.Context, tx pgx.Tx, request, room, guest, recipi
return e
}
if !allowed {
return denied()
return listeningDenied()
}
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 {
@@ -199,7 +202,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
return e
}
if a.Action == "end" && owner != id {
return denied()
return listeningDenied()
}
if owner == id {
_, e = tx.Exec(ctx, `DELETE FROM listening_rooms WHERE id=$1`, a.TargetId)
@@ -217,7 +220,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
return e
}
if (a.Action == "cancel" && sender != id) || (a.Action != "cancel" && recipient != id) {
return denied()
return listeningDenied()
}
if status != "pending" {
return nil
@@ -257,7 +260,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
return err
}
if !permitted {
return denied()
return listeningDenied()
}
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 {
@@ -273,6 +276,13 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
if busy {
return apperr.Conflict("social.listening_busy", "already in another room")
}
var alreadyJoined 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 AND r.owner_id=$2)`, guest, owner).Scan(&alreadyJoined); e != nil {
return e
}
if alreadyJoined {
return tx.Commit(ctx)
}
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