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
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:
1 parent
0a05bfe2cd
commit
11a16cd023
59 files changed
+5022
-542
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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()); }
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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()); }
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user