fix(social): complete shared listening request lifecycle
This commit is contained in:
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')} />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } }));
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -89,6 +89,14 @@ React Query; Android останавливает polling при уходе при
|
||||
«Подключиться» отправляет запрос хозяину и оставляет управление у него.
|
||||
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
|
||||
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
|
||||
Комната резервируется на время ожидания подтверждения. До принятия запроса
|
||||
слушатель не становится участником, а баннер совместного прослушивания не показывается.
|
||||
Исходное устройство ведущего поддерживает резервирование, не прерывая свою музыку.
|
||||
После успешной отправки окно закрывается; ожидающий запрос можно отменить в разделе общения.
|
||||
После отмены или истечения последнего запроса исходное устройство освобождает
|
||||
пустую комнату при следующем опросе, не прерывая свою музыку.
|
||||
Повторный запрос уже подключённого слушателя не создаёт нового ожидания.
|
||||
Второе устройство ведущего не включает звук автоматически и при закрытии не завершает стрим.
|
||||
Настройки приёма запросов/приглашений и автоматического принятия независимы,
|
||||
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
|
||||
Дополнительная настройка управляет запуском звука при подключении.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user