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

This commit is contained in:
mrleo1nid committed 2026-10-23 03:19:29 +03:00
1 parent f1528de948
commit 0a05bfe2cd
43 files changed
+2828 -389

No files matched your search

+248
View File
@@ -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?: {
+1
View File
@@ -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';
+11
View File
@@ -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();
});
});
+27
View File
@@ -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;
}
+49
View File
@@ -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."
}
}
+7
View File
@@ -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"
}
}
+49
View File
@@ -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": "Музыка больше недоступна."
}
}
+7
View File
@@ -122,5 +122,12 @@
"downgrade": "Установлена более новая сборка. Откат выполняется вручную.",
"version_unknown": "Не удалось определить commit работающего сервера. Обновите его вручную.",
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
},
"social": {
"invalid": "Проверьте сообщение или действие",
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
"rate_limited": "Слишком много действий. Подождите минуту",
"message_conflict": "Это сообщение уже отправлено с другим содержимым",
"attachment_unavailable": "Трек или ссылка на плейлист больше недоступны"
}
}