feat(social): add friends, private chats, inbox and music sharing
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 2m29s
ci / ts / ts (packages + web) (push) Successful in 2m55s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 2m29s
ci / ts / ts (packages + web) (push) Successful in 2m55s
This commit is contained in:
1 parent
f1528de948
commit
0a05bfe2cd
43 files changed
+2828
-389
No files matched your search
@@ -4,6 +4,76 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/v1/me/social": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description Friends, incoming/outgoing requests, own blocks and up to 100 notifications, unread first. No private account fields. */
|
||||
get: operations["getSocial"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social/people": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["searchPeople"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social/actions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description Request is idempotent; accept is recipient-only. Remove cancels, declines or unfriends. Block removes friendship. Unblock never restores it. Read chat acknowledges only messages through up_to_seq. Read notification uses notification ID as target_id. */
|
||||
post: operations["socialAction"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social/chats/{peerId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
peerId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description Last 50 messages in ascending sequence order. Before loads older history. Requires current friendship, never marks messages read. */
|
||||
get: operations["getDirectMessages"];
|
||||
put?: never;
|
||||
/** @description Friends only. Client-generated UUID makes retries idempotent; changed payload for the same ID returns 409. Playlist attachment is its active share UUID; track attachment is a catalog UUID. No arbitrary URLs. */
|
||||
post: operations["sendDirectMessage"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/server/updates": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1622,6 +1692,65 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
SocialPerson: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
/** @enum {string} */
|
||||
relationship: "none" | "incoming" | "outgoing" | "friend" | "blocked";
|
||||
unread: number;
|
||||
};
|
||||
SocialNotification: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
actor_id: string;
|
||||
actor_name: string;
|
||||
/** @enum {string} */
|
||||
kind: "friend_request" | "friend_accepted" | "message" | "music";
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
read: boolean;
|
||||
};
|
||||
SocialInbox: {
|
||||
people: components["schemas"]["SocialPerson"][];
|
||||
notifications: components["schemas"]["SocialNotification"][];
|
||||
unread: number;
|
||||
};
|
||||
SocialAction: {
|
||||
/** @enum {string} */
|
||||
action: "request" | "accept" | "remove" | "block" | "unblock" | "read_chat" | "read_notification";
|
||||
/** Format: uuid */
|
||||
target_id: string;
|
||||
/** Format: int64 */
|
||||
up_to_seq?: number;
|
||||
};
|
||||
SendDirectMessage: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
text: string;
|
||||
/** @enum {string} */
|
||||
attachment_kind: "none" | "track" | "playlist";
|
||||
/** Format: uuid */
|
||||
attachment_id?: string;
|
||||
};
|
||||
DirectMessage: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: int64 */
|
||||
seq: number;
|
||||
/** Format: uuid */
|
||||
sender_id: string;
|
||||
text: string;
|
||||
/** @enum {string} */
|
||||
attachment_kind: "none" | "track" | "playlist";
|
||||
/** Format: uuid */
|
||||
attachment_id?: string;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
read: boolean;
|
||||
};
|
||||
ServerSource: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -2788,6 +2917,125 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
getSocial: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Social inbox */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocialInbox"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
searchPeople: {
|
||||
parameters: {
|
||||
query: {
|
||||
q: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Up to 30 people, excluding blocked accounts and either direction of personal blocking */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocialPerson"][];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
socialAction: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocialAction"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Applied */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getDirectMessages: {
|
||||
parameters: {
|
||||
query?: {
|
||||
before?: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
peerId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Message page */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DirectMessage"][];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
sendDirectMessage: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
peerId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SendDirectMessage"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Stored message */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DirectMessage"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getServerUpdates: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -23,6 +23,7 @@ export {
|
||||
} from './player';
|
||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||
export { playlistShareUrl, trackShareUrl } from './share';
|
||||
export { socialApi, attachmentPath, type SocialPerson, type SocialInbox, type SocialAction, type DirectMessage, type SendDirectMessage, type MusicAttachment } from './social';
|
||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { attachmentPath } from './social';
|
||||
|
||||
describe('music attachments', () => {
|
||||
it('creates only internal catalog links and escapes identifiers', () => {
|
||||
expect(attachmentPath({ attachment_kind: 'track', attachment_id: 'a/b?sig=secret' })).toBe('/track/a%2Fb%3Fsig%3Dsecret');
|
||||
expect(attachmentPath({ attachment_kind: 'playlist', attachment_id: 'a/b' })).toBe('/shared/playlist/a%2Fb');
|
||||
expect(attachmentPath({ attachment_kind: 'none' })).toBeNull();
|
||||
expect(attachmentPath({ attachment_kind: 'track' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { type AuthSession, unwrap } from './auth';
|
||||
|
||||
export type SocialPerson = components['schemas']['SocialPerson'];
|
||||
export type SocialInbox = components['schemas']['SocialInbox'];
|
||||
export type SocialAction = components['schemas']['SocialAction'];
|
||||
export type DirectMessage = components['schemas']['DirectMessage'];
|
||||
export type SendDirectMessage = components['schemas']['SendDirectMessage'];
|
||||
export type MusicAttachment = { kind: 'track' | 'playlist'; id: string; title: string };
|
||||
|
||||
/** All clients use the authenticated transport, including refresh and account isolation. */
|
||||
export function socialApi(session: AuthSession) {
|
||||
return {
|
||||
inbox: async () => unwrap(await session.client.GET('/api/v1/me/social')),
|
||||
search: async (q: string) => unwrap(await session.client.GET('/api/v1/me/social/people', { params: { query: { q } } })),
|
||||
action: async (body: SocialAction) => unwrap(await session.client.POST('/api/v1/me/social/actions', { body })),
|
||||
messages: async (peerId: string, before?: number) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before } } })),
|
||||
send: async (peerId: string, body: SendDirectMessage) => unwrap(await session.client.POST('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId } }, body })),
|
||||
};
|
||||
}
|
||||
|
||||
export function attachmentPath(message: Pick<DirectMessage, 'attachment_kind' | 'attachment_id'>): string | null {
|
||||
if (!message.attachment_id) return null;
|
||||
if (message.attachment_kind === 'track') return `/track/${encodeURIComponent(message.attachment_id)}`;
|
||||
if (message.attachment_kind === 'playlist') return `/shared/playlist/${encodeURIComponent(message.attachment_id)}`;
|
||||
return null;
|
||||
}
|
||||
@@ -385,5 +385,54 @@
|
||||
"move_up": "Move {{name}} up",
|
||||
"delete": "Delete playlist",
|
||||
"no_results": "No playlists found"
|
||||
},
|
||||
"social": {
|
||||
"title": "Friends & messages",
|
||||
"subtitle": "Music worth sharing. Conversations at your own pace.",
|
||||
"people": "Friends",
|
||||
"notifications": "Notifications",
|
||||
"search": "Find by name or username",
|
||||
"loading": "Loading…",
|
||||
"empty": "Find someone you know and send your first request.",
|
||||
"no_results": "No matches. Try another username.",
|
||||
"no_notifications": "All quiet here. New events will appear here.",
|
||||
"choose_chat": "Choose a friend to start a conversation.",
|
||||
"open": "Open",
|
||||
"mark_read": "Mark read",
|
||||
"chat": "Message",
|
||||
"add": "Add friend",
|
||||
"accept": "Accept",
|
||||
"decline": "Decline",
|
||||
"cancel": "Cancel request",
|
||||
"remove": "Unfriend",
|
||||
"block": "Block",
|
||||
"unblock": "Unblock",
|
||||
"rel_none": "Not connected",
|
||||
"rel_incoming": "Incoming request",
|
||||
"rel_outgoing": "Request sent",
|
||||
"rel_friend": "Friend",
|
||||
"rel_blocked": "Blocked",
|
||||
"kind_friend_request": "Sent you a friend request",
|
||||
"kind_friend_accepted": "Accepted your friend request",
|
||||
"kind_message": "New private message",
|
||||
"kind_music": "Shared music with you",
|
||||
"chat_hint": "A private conversation. No sounds or interruptions.",
|
||||
"older": "Earlier messages",
|
||||
"first_message": "Say hello or share music from the player.",
|
||||
"listen_track": "Open track",
|
||||
"listen_playlist": "Open playlist",
|
||||
"read": "Read",
|
||||
"sent": "Sent",
|
||||
"message": "Message",
|
||||
"send": "Send",
|
||||
"copy": "Copy link",
|
||||
"copied": "Link copied",
|
||||
"external": "Share to an app",
|
||||
"send_friend": "Send to a friend",
|
||||
"no_friends_share": "Add friends to share music in one tap.",
|
||||
"back": "Back",
|
||||
"quiet": "No sounds. New events appear in the counter.",
|
||||
"share_error": "Could not send. Please try again.",
|
||||
"attachment_unavailable": "This music is no longer available."
|
||||
}
|
||||
}
|
||||
@@ -122,5 +122,12 @@
|
||||
"downgrade": "A newer build is installed. Downgrades require a manual rollback.",
|
||||
"version_unknown": "Could not identify the running server commit. Update it manually.",
|
||||
"target_changed": "The source changed after checking. Check for updates again."
|
||||
},
|
||||
"social": {
|
||||
"invalid": "Check your message or action",
|
||||
"unavailable": "Chats are available to friends only. The friendship may have ended or the person is unavailable",
|
||||
"rate_limited": "Too many actions. Please wait a minute",
|
||||
"message_conflict": "This message was already sent with different content",
|
||||
"attachment_unavailable": "This track or playlist link is no longer available"
|
||||
}
|
||||
}
|
||||
@@ -385,5 +385,54 @@
|
||||
"move_up": "Переместить «{{name}}» выше",
|
||||
"delete": "Удалить плейлист",
|
||||
"no_results": "Плейлисты не найдены"
|
||||
},
|
||||
"social": {
|
||||
"title": "Друзья и общение",
|
||||
"subtitle": "Музыка, которой хочется делиться. Разговоры — в своём ритме.",
|
||||
"people": "Друзья",
|
||||
"notifications": "Уведомления",
|
||||
"search": "Найти по имени или логину",
|
||||
"loading": "Загрузка…",
|
||||
"empty": "Найдите знакомых и отправьте первую заявку.",
|
||||
"no_results": "Никого не нашли. Попробуйте другой логин.",
|
||||
"no_notifications": "Здесь пока тихо. Новые события появятся здесь.",
|
||||
"choose_chat": "Выберите друга, чтобы начать разговор.",
|
||||
"open": "Открыть",
|
||||
"mark_read": "Прочитано",
|
||||
"chat": "Написать",
|
||||
"add": "В друзья",
|
||||
"accept": "Принять",
|
||||
"decline": "Отклонить",
|
||||
"cancel": "Отменить заявку",
|
||||
"remove": "Удалить из друзей",
|
||||
"block": "Заблокировать",
|
||||
"unblock": "Разблокировать",
|
||||
"rel_none": "Ещё не знакомы",
|
||||
"rel_incoming": "Входящая заявка",
|
||||
"rel_outgoing": "Заявка отправлена",
|
||||
"rel_friend": "Друг",
|
||||
"rel_blocked": "Заблокирован",
|
||||
"kind_friend_request": "Хочет добавить вас в друзья",
|
||||
"kind_friend_accepted": "Принял(а) вашу заявку",
|
||||
"kind_message": "Новое личное сообщение",
|
||||
"kind_music": "Поделился(ась) музыкой",
|
||||
"chat_hint": "Личный разговор. Без звуков и лишних оповещений.",
|
||||
"older": "Ранние сообщения",
|
||||
"first_message": "Начните с приветствия или отправьте музыку из плеера.",
|
||||
"listen_track": "Открыть трек",
|
||||
"listen_playlist": "Открыть плейлист",
|
||||
"read": "Прочитано",
|
||||
"sent": "Отправлено",
|
||||
"message": "Сообщение",
|
||||
"send": "Отправить",
|
||||
"copy": "Скопировать ссылку",
|
||||
"copied": "Ссылка скопирована",
|
||||
"external": "Отправить в приложение",
|
||||
"send_friend": "Отправить другу",
|
||||
"no_friends_share": "Добавьте друзей, чтобы отправлять музыку одним нажатием.",
|
||||
"back": "Назад",
|
||||
"quiet": "Без звуков. Новые события отмечаются счётчиком.",
|
||||
"share_error": "Не удалось отправить. Попробуйте ещё раз.",
|
||||
"attachment_unavailable": "Музыка больше недоступна."
|
||||
}
|
||||
}
|
||||
@@ -122,5 +122,12 @@
|
||||
"downgrade": "Установлена более новая сборка. Откат выполняется вручную.",
|
||||
"version_unknown": "Не удалось определить commit работающего сервера. Обновите его вручную.",
|
||||
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
|
||||
},
|
||||
"social": {
|
||||
"invalid": "Проверьте сообщение или действие",
|
||||
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
|
||||
"rate_limited": "Слишком много действий. Подождите минуту",
|
||||
"message_conflict": "Это сообщение уже отправлено с другим содержимым",
|
||||
"attachment_unavailable": "Трек или ссылка на плейлист больше недоступны"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user