global update and global fix
This commit is contained in:
@@ -19,16 +19,19 @@ export function getPrimaryRoleLabel(user: PublicUser): string {
|
||||
|
||||
export function getAdminLandingPath(user: PublicUser): string {
|
||||
if (user.canViewUsers || user.canManageUsers) return '/admin/users';
|
||||
if (user.canManageBots) return '/admin/bots';
|
||||
if (user.canViewOAuth || user.canManageOAuth) return '/admin/oauth';
|
||||
if (user.canManageSettings) return '/admin/settings';
|
||||
if (user.canManageRoles) return '/admin/rbac';
|
||||
return '/admin/oauth';
|
||||
}
|
||||
|
||||
export function canAccessAdminSection(user: PublicUser, section: 'users' | 'oauth' | 'settings' | 'rbac') {
|
||||
export function canAccessAdminSection(user: PublicUser, section: 'users' | 'oauth' | 'settings' | 'rbac' | 'bots') {
|
||||
switch (section) {
|
||||
case 'users':
|
||||
return Boolean(user.canViewUsers || user.canManageUsers);
|
||||
case 'bots':
|
||||
return Boolean(user.canManageBots || user.isSuperAdmin);
|
||||
case 'oauth':
|
||||
return Boolean(user.canViewOAuth || user.canManageOAuth);
|
||||
case 'settings':
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface PublicUser {
|
||||
isVerified?: boolean;
|
||||
verificationIcon?: string | null;
|
||||
canVerifyUsers?: boolean;
|
||||
canManageBots?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
@@ -237,6 +238,10 @@ export interface FamilyInviteCandidate {
|
||||
hasAvatar?: boolean;
|
||||
isVerified?: boolean;
|
||||
verificationIcon?: string | null;
|
||||
isBot?: boolean;
|
||||
botUsername?: string;
|
||||
botId?: string;
|
||||
ownerDisplayName?: string;
|
||||
}
|
||||
|
||||
export async function searchFamilyInviteUsers(groupId: string, query: string, token?: string | null) {
|
||||
@@ -662,6 +667,8 @@ export interface FamilyMember {
|
||||
hasAvatar: boolean;
|
||||
isVerified?: boolean;
|
||||
verificationIcon?: string | null;
|
||||
isBot?: boolean;
|
||||
botUsername?: string;
|
||||
}
|
||||
|
||||
export interface FamilyGroup {
|
||||
@@ -670,6 +677,27 @@ export interface FamilyGroup {
|
||||
name: string;
|
||||
hasAvatar: boolean;
|
||||
members?: FamilyMember[];
|
||||
botFatherAvailable?: boolean;
|
||||
}
|
||||
|
||||
export interface BotChatMessage {
|
||||
id: string;
|
||||
direction: 'in' | 'out';
|
||||
text: string;
|
||||
messageType: string;
|
||||
messageId: number;
|
||||
createdAt: string;
|
||||
replyMarkupJson?: string | null;
|
||||
}
|
||||
|
||||
export interface BotChatMeta {
|
||||
botId?: string;
|
||||
botOwnerId?: string;
|
||||
botUsername?: string;
|
||||
botDisplayName?: string;
|
||||
composerWebAppUrl?: string | null;
|
||||
composerMenuButtonJson?: string | null;
|
||||
manageWebAppUrl?: string;
|
||||
}
|
||||
|
||||
export interface FamilyInvite {
|
||||
@@ -702,6 +730,12 @@ export interface ChatPollOption {
|
||||
votedByMe: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessageReaction {
|
||||
emoji: string;
|
||||
count: number;
|
||||
reactedByMe: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
roomId: string;
|
||||
@@ -712,6 +746,7 @@ export interface ChatMessage {
|
||||
senderVerificationIcon?: string | null;
|
||||
type: string;
|
||||
content?: string;
|
||||
isEncrypted?: boolean;
|
||||
replyToId?: string;
|
||||
storageKey?: string;
|
||||
mimeType?: string;
|
||||
@@ -727,6 +762,7 @@ export interface ChatMessage {
|
||||
isAnonymous: boolean;
|
||||
options: ChatPollOption[];
|
||||
};
|
||||
reactions?: ChatMessageReaction[];
|
||||
}
|
||||
|
||||
export interface FamilyPresenceMember {
|
||||
@@ -758,6 +794,9 @@ export interface ChatRoom {
|
||||
groupId: string;
|
||||
type: string;
|
||||
name: string;
|
||||
peerUserId?: string;
|
||||
botUsername?: string;
|
||||
isE2E?: boolean;
|
||||
hasAvatar: boolean;
|
||||
createdById?: string;
|
||||
updatedAt: string;
|
||||
@@ -785,6 +824,43 @@ export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId
|
||||
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token);
|
||||
}
|
||||
|
||||
export async function addBotFatherToFamily(groupId: string, token?: string | null) {
|
||||
return apiFetch<FamilyMember>(`/family/groups/${groupId}/botfather`, { method: 'POST', body: '{}' }, token);
|
||||
}
|
||||
|
||||
export async function addBotToFamily(groupId: string, botId: string, token?: string | null) {
|
||||
return apiFetch<FamilyMember>(`/family/groups/${groupId}/bots`, { method: 'POST', body: JSON.stringify({ botId }) }, token);
|
||||
}
|
||||
|
||||
export async function fetchBotChatMessages(botRef: string, token?: string | null) {
|
||||
return apiFetch<{
|
||||
messages?: BotChatMessage[];
|
||||
botUsername?: string;
|
||||
botDisplayName?: string;
|
||||
botId?: string;
|
||||
botOwnerId?: string;
|
||||
composerWebAppUrl?: string;
|
||||
composerMenuButtonJson?: string;
|
||||
manageWebAppUrl?: string;
|
||||
}>(`/bots/by-username/${encodeURIComponent(botRef)}/messages`, {}, token);
|
||||
}
|
||||
|
||||
export async function sendBotMessage(botRef: string, text: string, token?: string | null) {
|
||||
return apiFetch<{ updateId?: number; messageId?: number; chatId?: string }>(
|
||||
`/bots/by-username/${encodeURIComponent(botRef)}/messages`,
|
||||
{ method: 'POST', body: JSON.stringify({ text }) },
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitBotCallback(botRef: string, messageId: number, callbackData: string, token?: string | null) {
|
||||
return apiFetch<{ updateId?: number; callbackQueryId?: string; messageId?: number }>(
|
||||
`/bots/by-username/${encodeURIComponent(botRef)}/callback`,
|
||||
{ method: 'POST', body: JSON.stringify({ messageId, callbackData }) },
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchFamilyInvites(token?: string | null) {
|
||||
return apiFetch<{ invites?: FamilyInvite[] }>('/family/invites', {}, token);
|
||||
}
|
||||
@@ -845,6 +921,7 @@ export async function sendChatMessage(
|
||||
body: {
|
||||
type: string;
|
||||
content?: string;
|
||||
isEncrypted?: boolean;
|
||||
replyToId?: string;
|
||||
storageKey?: string;
|
||||
mimeType?: string;
|
||||
@@ -856,6 +933,111 @@ export async function sendChatMessage(
|
||||
return apiFetch<ChatMessage>(`/chat/rooms/${roomId}/messages`, { method: 'POST', body: JSON.stringify(body) }, token);
|
||||
}
|
||||
|
||||
export async function fetchUserE2EPublicKey(userId: string, token?: string | null) {
|
||||
return apiFetch<{ userId?: string; e2ePublicKey?: string }>(`/profile/users/${userId}/e2e-public-key`, {}, token);
|
||||
}
|
||||
|
||||
export async function setUserE2EPublicKey(userId: string, publicKey: string, token?: string | null) {
|
||||
return apiFetch<{ userId?: string; e2ePublicKey?: string }>(
|
||||
`/profile/users/${userId}/e2e-public-key`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ publicKey }) },
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
export interface ManagedBot {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
tokenPrefix?: string;
|
||||
description?: string | null;
|
||||
aboutText?: string | null;
|
||||
botPicUrl?: string | null;
|
||||
menuButtonJson?: string | null;
|
||||
webAppUrl?: string | null;
|
||||
isActive?: boolean;
|
||||
isSystemBot?: boolean;
|
||||
ownerId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
owner?: { id: string; displayName: string; username?: string };
|
||||
messageCount?: number;
|
||||
chatCount?: number;
|
||||
}
|
||||
|
||||
export interface AdminBotMetrics {
|
||||
totalBots?: number;
|
||||
activeBots?: number;
|
||||
blockedBots?: number;
|
||||
totalMessages?: number;
|
||||
totalChats?: number;
|
||||
}
|
||||
|
||||
export async function fetchAdminBots(
|
||||
params: { search?: string; page?: number; limit?: number },
|
||||
token?: string | null
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.search?.trim()) query.set('search', params.search.trim());
|
||||
if (params.page) query.set('page', String(params.page));
|
||||
if (params.limit) query.set('limit', String(params.limit));
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return apiFetch<{ bots?: ManagedBot[]; total?: number }>(`/admin/bots${suffix}`, {}, token);
|
||||
}
|
||||
|
||||
export async function fetchAdminBotMetrics(token?: string | null) {
|
||||
return apiFetch<AdminBotMetrics>('/admin/bots/metrics', {}, token);
|
||||
}
|
||||
|
||||
export async function setAdminBotActive(botId: string, isActive: boolean, token?: string | null) {
|
||||
return apiFetch<ManagedBot>(`/admin/bots/${botId}/active`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive })
|
||||
}, token);
|
||||
}
|
||||
|
||||
export async function createE2ERoom(groupId: string, peerUserId: string, token?: string | null) {
|
||||
return apiFetch<ChatRoom>(`/chat/groups/${groupId}/e2e-rooms`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ peerUserId })
|
||||
}, token);
|
||||
}
|
||||
|
||||
export async function fetchMyBots(token?: string | null) {
|
||||
return apiFetch<{ bots?: ManagedBot[]; total?: number }>('/bots', {}, token);
|
||||
}
|
||||
|
||||
export async function createManagedBot(body: { name: string; username: string }, token?: string | null) {
|
||||
return apiFetch<{ bot?: ManagedBot; token?: string }>('/bots', { method: 'POST', body: JSON.stringify(body) }, token);
|
||||
}
|
||||
|
||||
export async function fetchBot(botId: string, token?: string | null) {
|
||||
return apiFetch<ManagedBot>(`/bots/${botId}`, {}, token);
|
||||
}
|
||||
|
||||
export async function updateManagedBot(botId: string, body: { name?: string; username?: string }, token?: string | null) {
|
||||
return apiFetch<ManagedBot>(`/bots/${botId}`, { method: 'PATCH', body: JSON.stringify(body) }, token);
|
||||
}
|
||||
|
||||
export async function updateManagedBotProfile(
|
||||
botId: string,
|
||||
body: {
|
||||
description?: string;
|
||||
aboutText?: string;
|
||||
botPicUrl?: string;
|
||||
menuButtonUrl?: string;
|
||||
menuButtonText?: string;
|
||||
menuButtonJson?: string;
|
||||
},
|
||||
token?: string | null
|
||||
) {
|
||||
return apiFetch<ManagedBot>(`/bots/${botId}/profile`, { method: 'PATCH', body: JSON.stringify(body) }, token);
|
||||
}
|
||||
|
||||
export async function revokeManagedBotToken(botId: string, token?: string | null) {
|
||||
return apiFetch<{ bot?: ManagedBot; token?: string }>(`/bots/${botId}/revoke-token`, { method: 'POST' }, token);
|
||||
}
|
||||
|
||||
export async function voteChatPoll(messageId: string, optionIds: string[], token?: string | null) {
|
||||
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/vote`, { method: 'POST', body: JSON.stringify({ optionIds }) }, token);
|
||||
}
|
||||
@@ -872,6 +1054,24 @@ export async function deleteChatMessage(messageId: string, token?: string | null
|
||||
return apiFetch<ChatMessage>(`/chat/messages/${messageId}`, { method: 'DELETE' }, token);
|
||||
}
|
||||
|
||||
export async function toggleChatMessageReaction(messageId: string, emoji: string, token?: string | null) {
|
||||
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/reactions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ emoji })
|
||||
}, token);
|
||||
}
|
||||
|
||||
export async function forwardChatMessages(targetRoomId: string, messageIds: string[], token?: string | null) {
|
||||
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${targetRoomId}/forward`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ messageIds })
|
||||
}, token);
|
||||
}
|
||||
|
||||
export async function deleteChatRoom(roomId: string, token?: string | null) {
|
||||
return apiFetch<{ count?: number }>(`/chat/rooms/${roomId}`, { method: 'DELETE' }, token);
|
||||
}
|
||||
|
||||
export async function markChatRoomRead(roomId: string, lastMessageId: string | undefined, token?: string | null) {
|
||||
return apiFetch<{ count: number }>(`/chat/rooms/${roomId}/read`, {
|
||||
method: 'POST',
|
||||
@@ -879,6 +1079,10 @@ export async function markChatRoomRead(roomId: string, lastMessageId: string | u
|
||||
}, token);
|
||||
}
|
||||
|
||||
export async function reportChatTyping(roomId: string, token?: string | null) {
|
||||
return apiFetch<{ success?: boolean }>(`/chat/rooms/${roomId}/typing`, { method: 'POST' }, token);
|
||||
}
|
||||
|
||||
export async function fetchFamilyPresence(groupId: string, token?: string | null) {
|
||||
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, token);
|
||||
}
|
||||
|
||||
46
apps/frontend/lib/bot-menu-button.ts
Normal file
46
apps/frontend/lib/bot-menu-button.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type ComposerMenuButton = {
|
||||
type: 'web_app';
|
||||
text: string;
|
||||
web_app: { url: string };
|
||||
};
|
||||
|
||||
export function parseComposerMenuButtonJson(raw?: string | null): ComposerMenuButton | null {
|
||||
if (!raw?.trim()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
(parsed as ComposerMenuButton).type === 'web_app' &&
|
||||
typeof (parsed as ComposerMenuButton).text === 'string' &&
|
||||
typeof (parsed as ComposerMenuButton).web_app?.url === 'string'
|
||||
) {
|
||||
const button = parsed as ComposerMenuButton;
|
||||
const url = button.web_app.url.trim();
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'web_app', text: button.text.trim() || 'App', web_app: { url } };
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveComposerMenuButton(options: {
|
||||
composerMenuButtonJson?: string | null;
|
||||
composerWebAppUrl?: string | null;
|
||||
}): ComposerMenuButton | null {
|
||||
const fromJson = parseComposerMenuButtonJson(options.composerMenuButtonJson);
|
||||
if (fromJson) {
|
||||
return fromJson;
|
||||
}
|
||||
const url = options.composerWebAppUrl?.trim();
|
||||
if (url) {
|
||||
return { type: 'web_app', text: 'App', web_app: { url } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
95
apps/frontend/lib/bot-reply-markup.ts
Normal file
95
apps/frontend/lib/bot-reply-markup.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export interface InlineKeyboardButton {
|
||||
text: string;
|
||||
callback_data?: string;
|
||||
callbackData?: string;
|
||||
url?: string;
|
||||
web_app?: { url?: string };
|
||||
webAppUrl?: string;
|
||||
}
|
||||
|
||||
export interface InlineKeyboardMarkup {
|
||||
inline_keyboard?: InlineKeyboardButton[][];
|
||||
reply_markup?: {
|
||||
inline_keyboard?: InlineKeyboardButton[][];
|
||||
};
|
||||
kind?: 'inline';
|
||||
rows?: InlineKeyboardButton[][];
|
||||
}
|
||||
|
||||
function normalizeButton(button: InlineKeyboardButton): InlineKeyboardButton {
|
||||
return {
|
||||
text: button.text,
|
||||
callback_data: button.callback_data ?? button.callbackData,
|
||||
url: button.url,
|
||||
web_app: button.web_app ?? (button.webAppUrl ? { url: button.webAppUrl } : undefined)
|
||||
};
|
||||
}
|
||||
|
||||
export function parseInlineKeyboardMarkup(source: unknown): InlineKeyboardButton[][] {
|
||||
if (!source) return [];
|
||||
|
||||
let raw: InlineKeyboardMarkup | null = null;
|
||||
|
||||
if (typeof source === 'string') {
|
||||
try {
|
||||
raw = JSON.parse(source) as InlineKeyboardMarkup;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} else if (typeof source === 'object') {
|
||||
raw = source as InlineKeyboardMarkup;
|
||||
}
|
||||
|
||||
if (!raw) return [];
|
||||
|
||||
const nested = raw.reply_markup?.inline_keyboard;
|
||||
if (Array.isArray(nested) && nested.length) {
|
||||
return nested
|
||||
.filter((row) => Array.isArray(row))
|
||||
.map((row) => row.map(normalizeButton).filter((button) => Boolean(button.text?.trim())));
|
||||
}
|
||||
|
||||
if (Array.isArray(raw.inline_keyboard) && raw.inline_keyboard.length) {
|
||||
return raw.inline_keyboard
|
||||
.filter((row) => Array.isArray(row))
|
||||
.map((row) => row.map(normalizeButton).filter((button) => Boolean(button.text?.trim())));
|
||||
}
|
||||
|
||||
if (raw.kind === 'inline' && Array.isArray(raw.rows)) {
|
||||
return raw.rows
|
||||
.filter((row) => Array.isArray(row))
|
||||
.map((row) => row.map(normalizeButton).filter((button) => Boolean(button.text?.trim())));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function serializeInlineKeyboardMarkup(rows: InlineKeyboardButton[][]): string | null {
|
||||
if (!rows.length) return null;
|
||||
return JSON.stringify({ inline_keyboard: rows.map((row) => row.map(normalizeButton)) });
|
||||
}
|
||||
|
||||
export function extractMessageReplyMarkup(message: {
|
||||
replyMarkupJson?: string | null;
|
||||
replyMarkup?: unknown;
|
||||
telegramReplyMarkup?: unknown;
|
||||
reply_markup?: unknown;
|
||||
}): InlineKeyboardButton[][] {
|
||||
if (message.replyMarkupJson) {
|
||||
const parsed = parseInlineKeyboardMarkup(message.replyMarkupJson);
|
||||
if (parsed.length) return parsed;
|
||||
}
|
||||
if (message.telegramReplyMarkup) {
|
||||
const parsed = parseInlineKeyboardMarkup(message.telegramReplyMarkup);
|
||||
if (parsed.length) return parsed;
|
||||
}
|
||||
if (message.replyMarkup) {
|
||||
const parsed = parseInlineKeyboardMarkup(message.replyMarkup);
|
||||
if (parsed.length) return parsed;
|
||||
}
|
||||
if (message.reply_markup) {
|
||||
const parsed = parseInlineKeyboardMarkup(message.reply_markup);
|
||||
if (parsed.length) return parsed;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
89
apps/frontend/lib/chat-message-utils.ts
Normal file
89
apps/frontend/lib/chat-message-utils.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { ChatMessage } from '@/lib/api';
|
||||
import { emojiIdToNative, resolveNativeEmojiContent } from '@/lib/emoji-native-map';
|
||||
|
||||
export const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '👏'] as const;
|
||||
|
||||
export interface ForwardedFromMeta {
|
||||
messageId: string;
|
||||
roomId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
type: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export function parseForwardedFrom(metadataJson?: string): ForwardedFromMeta | null {
|
||||
if (!metadataJson) return null;
|
||||
try {
|
||||
const meta = JSON.parse(metadataJson) as { forwardedFrom?: ForwardedFromMeta };
|
||||
const forwarded = meta.forwardedFrom;
|
||||
return forwarded?.messageId ? forwarded : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessageCopyText(message: ChatMessage, visibleText?: string) {
|
||||
if (message.isDeleted) return '';
|
||||
if (visibleText?.trim()) return resolveNativeEmojiContent(visibleText.trim());
|
||||
if (message.content?.trim()) {
|
||||
if (message.type === 'EMOJI') {
|
||||
return emojiIdToNative(message.content.trim()) ?? message.content.trim();
|
||||
}
|
||||
return resolveNativeEmojiContent(message.content.trim());
|
||||
}
|
||||
if (message.type === 'POLL' && message.poll) return message.poll.question;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getMessagePreviewText(message: ChatMessage, visibleText?: string) {
|
||||
if (message.type === 'SYSTEM') {
|
||||
return resolveNativeEmojiContent(message.content?.trim() ?? 'Системное сообщение');
|
||||
}
|
||||
if (message.type === 'POLL' && message.poll) {
|
||||
return `📊 Опрос: ${message.poll.question}`;
|
||||
}
|
||||
const text = getMessageCopyText(message, visibleText);
|
||||
if (text) return text;
|
||||
if (message.type === 'IMAGE') return '📷 Фото';
|
||||
if (message.type === 'VOICE') return '🎤 Голосовое';
|
||||
if (message.type === 'AUDIO') return '🎵 Аудио';
|
||||
if (message.type === 'FILE') return '📄 Файл';
|
||||
if (message.isEncrypted) return '🔒 Зашифрованное сообщение';
|
||||
return 'Сообщение';
|
||||
}
|
||||
|
||||
export function getChatListPreviewText(message?: ChatMessage | null, fallback = '') {
|
||||
if (!message || message.isDeleted) return fallback;
|
||||
return getMessagePreviewText(message);
|
||||
}
|
||||
|
||||
export function isSystemMessage(message: ChatMessage) {
|
||||
return message.type === 'SYSTEM';
|
||||
}
|
||||
|
||||
export function isSelectableMessage(message: ChatMessage) {
|
||||
return !message.isDeleted && !isSystemMessage(message);
|
||||
}
|
||||
|
||||
export function isChatInteractiveTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return Boolean(
|
||||
target.closest('button, a, input, textarea, select, label, [contenteditable="true"], [data-chat-interactive="true"]')
|
||||
);
|
||||
}
|
||||
|
||||
export function canEditMessage(message: ChatMessage, userId?: string, isE2E?: boolean) {
|
||||
return (
|
||||
Boolean(userId && message.senderId === userId) &&
|
||||
!message.isDeleted &&
|
||||
!isSystemMessage(message) &&
|
||||
!message.isEncrypted &&
|
||||
!isE2E &&
|
||||
(message.type === 'TEXT' || message.type === 'EMOJI')
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteMessage(message: ChatMessage, userId?: string) {
|
||||
return Boolean(userId && message.senderId === userId && !message.isDeleted && !isSystemMessage(message));
|
||||
}
|
||||
72
apps/frontend/lib/e2e-chat.ts
Normal file
72
apps/frontend/lib/e2e-chat.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { decryptBinary, decryptDirectMessage, encryptBinary, encryptDirectMessage } from '@/lib/e2e-crypto';
|
||||
|
||||
export type E2EMessageKind = 'text' | 'emoji' | 'image' | 'voice' | 'audio' | 'file';
|
||||
|
||||
export interface E2EMessagePayload {
|
||||
kind: E2EMessageKind;
|
||||
text?: string;
|
||||
emojiId?: string;
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
fileSize?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export async function encryptE2EPayload(userId: string, peerPublicKey: string, payload: E2EMessagePayload) {
|
||||
return encryptDirectMessage(userId, peerPublicKey, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export async function decryptE2EPayload(userId: string, peerPublicKey: string, encrypted: string): Promise<E2EMessagePayload | null> {
|
||||
const decrypted = await decryptDirectMessage(userId, peerPublicKey, encrypted);
|
||||
if (decrypted.startsWith('🔒')) return null;
|
||||
try {
|
||||
return JSON.parse(decrypted) as E2EMessagePayload;
|
||||
} catch {
|
||||
return { kind: 'text', text: decrypted };
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptE2EBlob(userId: string, peerPublicKey: string, data: ArrayBuffer) {
|
||||
return encryptBinary(userId, peerPublicKey, data);
|
||||
}
|
||||
|
||||
export async function decryptE2EBlob(userId: string, peerPublicKey: string, encryptedBytes: ArrayBuffer) {
|
||||
return decryptBinary(userId, peerPublicKey, encryptedBytes);
|
||||
}
|
||||
|
||||
export function e2eKindFromMessageType(type: string): E2EMessageKind {
|
||||
switch (type) {
|
||||
case 'EMOJI':
|
||||
return 'emoji';
|
||||
case 'IMAGE':
|
||||
return 'image';
|
||||
case 'VOICE':
|
||||
return 'voice';
|
||||
case 'AUDIO':
|
||||
return 'audio';
|
||||
case 'FILE':
|
||||
return 'file';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
export function readableE2EPayload(payload: E2EMessagePayload | null) {
|
||||
if (!payload) return '🔒 Зашифрованное сообщение';
|
||||
switch (payload.kind) {
|
||||
case 'emoji':
|
||||
return payload.emojiId ?? '';
|
||||
case 'text':
|
||||
return payload.text ?? '';
|
||||
case 'voice':
|
||||
return 'Голосовое сообщение';
|
||||
case 'audio':
|
||||
return 'Аудио';
|
||||
case 'image':
|
||||
return 'Фото';
|
||||
case 'file':
|
||||
return payload.fileName ?? 'Файл';
|
||||
default:
|
||||
return '🔒 Зашифрованное сообщение';
|
||||
}
|
||||
}
|
||||
121
apps/frontend/lib/e2e-crypto.ts
Normal file
121
apps/frontend/lib/e2e-crypto.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
const STORAGE_PREFIX = 'lendry-e2e-private:';
|
||||
const ALGORITHM = { name: 'ECDH', namedCurve: 'P-256' } as const;
|
||||
const AES = { name: 'AES-GCM', length: 256 } as const;
|
||||
|
||||
interface StoredKeyMaterial {
|
||||
privateJwk: JsonWebKey;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
function toBase64(buffer: ArrayBuffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(value: string) {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
async function exportPublicKey(key: CryptoKey) {
|
||||
const raw = await crypto.subtle.exportKey('spki', key);
|
||||
return toBase64(raw);
|
||||
}
|
||||
|
||||
async function importPublicKey(base64: string) {
|
||||
return crypto.subtle.importKey('spki', fromBase64(base64), ALGORITHM, true, []);
|
||||
}
|
||||
|
||||
async function loadStoredKeys(userId: string) {
|
||||
const stored = localStorage.getItem(`${STORAGE_PREFIX}${userId}`);
|
||||
if (!stored) return null;
|
||||
const parsed = JSON.parse(stored) as StoredKeyMaterial;
|
||||
const privateKey = await crypto.subtle.importKey('jwk', parsed.privateJwk, ALGORITHM, true, ['deriveKey']);
|
||||
return { privateKey, publicKey: parsed.publicKey };
|
||||
}
|
||||
|
||||
async function saveStoredKeys(userId: string, privateKey: CryptoKey, publicKey: string) {
|
||||
const privateJwk = await crypto.subtle.exportKey('jwk', privateKey);
|
||||
localStorage.setItem(
|
||||
`${STORAGE_PREFIX}${userId}`,
|
||||
JSON.stringify({ privateJwk, publicKey } satisfies StoredKeyMaterial)
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureE2EKeyPair(userId: string) {
|
||||
const existing = await loadStoredKeys(userId);
|
||||
if (existing) return existing;
|
||||
|
||||
const keyPair = await crypto.subtle.generateKey(ALGORITHM, true, ['deriveKey']);
|
||||
const publicKey = await exportPublicKey(keyPair.publicKey);
|
||||
await saveStoredKeys(userId, keyPair.privateKey, publicKey);
|
||||
return { privateKey: keyPair.privateKey, publicKey };
|
||||
}
|
||||
|
||||
async function deriveAesKey(privateKey: CryptoKey, peerPublicKeyBase64: string) {
|
||||
const peerPublicKey = await importPublicKey(peerPublicKeyBase64);
|
||||
return crypto.subtle.deriveKey({ name: 'ECDH', public: peerPublicKey }, privateKey, AES, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
export async function encryptDirectMessage(userId: string, peerPublicKeyBase64: string, plaintext: string) {
|
||||
const keys = await ensureE2EKeyPair(userId);
|
||||
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, encoded);
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
iv: toBase64(iv.buffer),
|
||||
ciphertext: toBase64(ciphertext)
|
||||
});
|
||||
}
|
||||
|
||||
export async function decryptDirectMessage(userId: string, peerPublicKeyBase64: string, payload: string) {
|
||||
const keys = await loadStoredKeys(userId);
|
||||
if (!keys) {
|
||||
return '🔒 Не удалось расшифровать сообщение';
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as { iv?: string; ciphertext?: string };
|
||||
if (!parsed.iv || !parsed.ciphertext) {
|
||||
return payload;
|
||||
}
|
||||
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||||
const iv = new Uint8Array(fromBase64(parsed.iv));
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, fromBase64(parsed.ciphertext));
|
||||
return new TextDecoder().decode(decrypted);
|
||||
} catch {
|
||||
return '🔒 Не удалось расшифровать сообщение';
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptBinary(userId: string, peerPublicKeyBase64: string, data: ArrayBuffer) {
|
||||
const keys = await ensureE2EKeyPair(userId);
|
||||
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, data);
|
||||
const packed = new Uint8Array(iv.byteLength + ciphertext.byteLength);
|
||||
packed.set(iv, 0);
|
||||
packed.set(new Uint8Array(ciphertext), iv.byteLength);
|
||||
return packed.buffer;
|
||||
}
|
||||
|
||||
export async function decryptBinary(userId: string, peerPublicKeyBase64: string, packed: ArrayBuffer) {
|
||||
const keys = await loadStoredKeys(userId);
|
||||
if (!keys) {
|
||||
throw new Error('Локальный E2E-ключ не найден');
|
||||
}
|
||||
const bytes = new Uint8Array(packed);
|
||||
const iv = bytes.slice(0, 12);
|
||||
const ciphertext = bytes.slice(12);
|
||||
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||||
return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, ciphertext);
|
||||
}
|
||||
167
apps/frontend/lib/emoji-catalog.ts
Normal file
167
apps/frontend/lib/emoji-catalog.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
export type EmojiCategoryId = 'smileys' | 'gestures' | 'hearts' | 'animals' | 'food' | 'travel' | 'objects' | 'symbols';
|
||||
|
||||
export interface EmojiDefinition {
|
||||
id: string;
|
||||
category: EmojiCategoryId;
|
||||
keywords: string[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const EMOJI_CATEGORIES: Array<{ id: EmojiCategoryId; label: string; icon: string }> = [
|
||||
{ id: 'smileys', label: 'Смайлы', icon: 'e-grinning' },
|
||||
{ id: 'gestures', label: 'Жесты', icon: 'e-thumbs-up' },
|
||||
{ id: 'hearts', label: 'Сердца', icon: 'e-red-heart' },
|
||||
{ id: 'animals', label: 'Животные', icon: 'e-dog' },
|
||||
{ id: 'food', label: 'Еда', icon: 'e-pizza' },
|
||||
{ id: 'travel', label: 'Путешествия', icon: 'e-car' },
|
||||
{ id: 'objects', label: 'Предметы', icon: 'e-bulb' },
|
||||
{ id: 'symbols', label: 'Символы', icon: 'e-check' }
|
||||
];
|
||||
|
||||
export const EMOJI_DEFINITIONS: EmojiDefinition[] = [
|
||||
{ id: 'e-grinning', category: 'smileys', label: 'Улыбка', keywords: ['радость', 'смех', 'улыбка'] },
|
||||
{ id: 'e-smile', category: 'smileys', label: 'Улыбка глаза', keywords: ['улыбка', 'радость'] },
|
||||
{ id: 'e-laugh', category: 'smileys', label: 'Смех', keywords: ['смех', 'lol', 'ха'] },
|
||||
{ id: 'e-rofl', category: 'smileys', label: 'Катаюсь', keywords: ['ржу', 'смех'] },
|
||||
{ id: 'e-wink', category: 'smileys', label: 'Подмигивание', keywords: ['подмигнуть'] },
|
||||
{ id: 'e-blush', category: 'smileys', label: 'Смущение', keywords: ['румянец', 'мило'] },
|
||||
{ id: 'e-innocent', category: 'smileys', label: 'Невинность', keywords: ['ангел', 'невинный'] },
|
||||
{ id: 'e-slight-smile', category: 'smileys', label: 'Лёгкая улыбка', keywords: ['улыбка'] },
|
||||
{ id: 'e-thinking', category: 'smileys', label: 'Думаю', keywords: ['думаю', 'хм'] },
|
||||
{ id: 'e-neutral', category: 'smileys', label: 'Нейтрально', keywords: ['нейтрально'] },
|
||||
{ id: 'e-expressionless', category: 'smileys', label: 'Без эмоций', keywords: ['пусто'] },
|
||||
{ id: 'e-sleeping', category: 'smileys', label: 'Сплю', keywords: ['сон', 'спать', 'zzz'] },
|
||||
{ id: 'e-tired', category: 'smileys', label: 'Устал', keywords: ['усталость'] },
|
||||
{ id: 'e-cry', category: 'smileys', label: 'Плач', keywords: ['грусть', 'слёзы'] },
|
||||
{ id: 'e-sob', category: 'smileys', label: 'Рыдаю', keywords: ['слёзы', 'грусть'] },
|
||||
{ id: 'e-angry', category: 'smileys', label: 'Злость', keywords: ['злой', 'гнев'] },
|
||||
{ id: 'e-rage', category: 'smileys', label: 'Ярость', keywords: ['злость'] },
|
||||
{ id: 'e-shock', category: 'smileys', label: 'Шок', keywords: ['удивление', 'шок'] },
|
||||
{ id: 'e-fear', category: 'smileys', label: 'Страх', keywords: ['страх', 'испуг'] },
|
||||
{ id: 'e-cool', category: 'smileys', label: 'Круто', keywords: ['крутой', 'очки'] },
|
||||
{ id: 'e-nerd', category: 'smileys', label: 'Ботан', keywords: ['очки', 'умный'] },
|
||||
{ id: 'e-sick', category: 'smileys', label: 'Болею', keywords: ['больной', 'маска'] },
|
||||
{ id: 'e-mask', category: 'smileys', label: 'Маска', keywords: ['маска', 'больной'] },
|
||||
{ id: 'e-party', category: 'smileys', label: 'Праздник', keywords: ['вечеринка', 'шапка'] },
|
||||
{ id: 'e-star-eyes', category: 'smileys', label: 'Звёзды', keywords: ['восторг', 'звёзды'] },
|
||||
{ id: 'e-heart-eyes', category: 'smileys', label: 'Влюблён', keywords: ['любовь', 'сердце'] },
|
||||
{ id: 'e-kiss', category: 'smileys', label: 'Поцелуй', keywords: ['поцелуй'] },
|
||||
{ id: 'e-tongue', category: 'smileys', label: 'Язык', keywords: ['шутка', 'язык'] },
|
||||
{ id: 'e-sweat', category: 'smileys', label: 'Пот', keywords: ['нервы', 'пот'] },
|
||||
{ id: 'e-dizzy', category: 'smileys', label: 'Головокружение', keywords: ['dizzy'] },
|
||||
|
||||
{ id: 'e-thumbs-up', category: 'gestures', label: 'Лайк', keywords: ['лайк', 'ok', 'да'] },
|
||||
{ id: 'e-thumbs-down', category: 'gestures', label: 'Дизлайк', keywords: ['нет', 'минус'] },
|
||||
{ id: 'e-clap', category: 'gestures', label: 'Аплодисменты', keywords: ['хлопать', 'браво'] },
|
||||
{ id: 'e-wave', category: 'gestures', label: 'Привет', keywords: ['привет', 'рука'] },
|
||||
{ id: 'e-pray', category: 'gestures', label: 'Молитва', keywords: ['спасибо', 'please'] },
|
||||
{ id: 'e-muscle', category: 'gestures', label: 'Сила', keywords: ['сила', 'качок'] },
|
||||
{ id: 'e-ok-hand', category: 'gestures', label: 'Ок', keywords: ['ok', 'хорошо'] },
|
||||
{ id: 'e-victory', category: 'gestures', label: 'Победа', keywords: ['peace', 'v'] },
|
||||
{ id: 'e-crossed-fingers', category: 'gestures', label: 'Удачи', keywords: ['удача'] },
|
||||
{ id: 'e-point-up', category: 'gestures', label: 'Указать', keywords: ['вверх'] },
|
||||
{ id: 'e-point-down', category: 'gestures', label: 'Вниз', keywords: ['вниз'] },
|
||||
{ id: 'e-raised-hand', category: 'gestures', label: 'Рука', keywords: ['стоп', 'рука'] },
|
||||
{ id: 'e-fist', category: 'gestures', label: 'Кулак', keywords: ['кулак', 'сила'] },
|
||||
{ id: 'e-handshake', category: 'gestures', label: 'Рукопожатие', keywords: ['deal', 'договор'] },
|
||||
{ id: 'e-writing', category: 'gestures', label: 'Пишу', keywords: ['писать'] },
|
||||
|
||||
{ id: 'e-red-heart', category: 'hearts', label: 'Сердце', keywords: ['любовь', 'сердце'] },
|
||||
{ id: 'e-orange-heart', category: 'hearts', label: 'Оранжевое', keywords: ['сердце'] },
|
||||
{ id: 'e-yellow-heart', category: 'hearts', label: 'Жёлтое', keywords: ['сердце'] },
|
||||
{ id: 'e-green-heart', category: 'hearts', label: 'Зелёное', keywords: ['сердце'] },
|
||||
{ id: 'e-blue-heart', category: 'hearts', label: 'Синее', keywords: ['сердце'] },
|
||||
{ id: 'e-purple-heart', category: 'hearts', label: 'Фиолетовое', keywords: ['сердце'] },
|
||||
{ id: 'e-black-heart', category: 'hearts', label: 'Чёрное', keywords: ['сердце'] },
|
||||
{ id: 'e-white-heart', category: 'hearts', label: 'Белое', keywords: ['сердце'] },
|
||||
{ id: 'e-broken-heart', category: 'hearts', label: 'Разбитое', keywords: ['грусть'] },
|
||||
{ id: 'e-sparkling-heart', category: 'hearts', label: 'Искры', keywords: ['любовь'] },
|
||||
{ id: 'e-two-hearts', category: 'hearts', label: 'Два сердца', keywords: ['любовь'] },
|
||||
{ id: 'e-revolving-hearts', category: 'hearts', label: 'Вращение', keywords: ['любовь'] },
|
||||
|
||||
{ id: 'e-dog', category: 'animals', label: 'Собака', keywords: ['собака', 'пёс'] },
|
||||
{ id: 'e-cat', category: 'animals', label: 'Кошка', keywords: ['кошка', 'кот'] },
|
||||
{ id: 'e-mouse', category: 'animals', label: 'Мышь', keywords: ['мышь'] },
|
||||
{ id: 'e-rabbit', category: 'animals', label: 'Кролик', keywords: ['кролик'] },
|
||||
{ id: 'e-bear', category: 'animals', label: 'Медведь', keywords: ['медведь'] },
|
||||
{ id: 'e-panda', category: 'animals', label: 'Панда', keywords: ['панда'] },
|
||||
{ id: 'e-lion', category: 'animals', label: 'Лев', keywords: ['лев'] },
|
||||
{ id: 'e-tiger', category: 'animals', label: 'Тигр', keywords: ['тигр'] },
|
||||
{ id: 'e-fox', category: 'animals', label: 'Лиса', keywords: ['лиса'] },
|
||||
{ id: 'e-wolf', category: 'animals', label: 'Волк', keywords: ['волк'] },
|
||||
{ id: 'e-monkey', category: 'animals', label: 'Обезьяна', keywords: ['обезьяна'] },
|
||||
{ id: 'e-chicken', category: 'animals', label: 'Курица', keywords: ['курица'] },
|
||||
{ id: 'e-penguin', category: 'animals', label: 'Пингвин', keywords: ['пингвин'] },
|
||||
{ id: 'e-unicorn', category: 'animals', label: 'Единорог', keywords: ['единорог'] },
|
||||
{ id: 'e-butterfly', category: 'animals', label: 'Бабочка', keywords: ['бабочка'] },
|
||||
|
||||
{ id: 'e-pizza', category: 'food', label: 'Пицца', keywords: ['пицца', 'еда'] },
|
||||
{ id: 'e-burger', category: 'food', label: 'Бургер', keywords: ['бургер'] },
|
||||
{ id: 'e-fries', category: 'food', label: 'Фри', keywords: ['картошка'] },
|
||||
{ id: 'e-hotdog', category: 'food', label: 'Хот-дог', keywords: ['хотдог'] },
|
||||
{ id: 'e-cake', category: 'food', label: 'Торт', keywords: ['торт', 'день рождения'] },
|
||||
{ id: 'e-cookie', category: 'food', label: 'Печенье', keywords: ['печенье'] },
|
||||
{ id: 'e-coffee', category: 'food', label: 'Кофе', keywords: ['кофе'] },
|
||||
{ id: 'e-beer', category: 'food', label: 'Пиво', keywords: ['пиво'] },
|
||||
{ id: 'e-wine', category: 'food', label: 'Вино', keywords: ['вино'] },
|
||||
{ id: 'e-apple', category: 'food', label: 'Яблоко', keywords: ['яблоко', 'фрукт'] },
|
||||
{ id: 'e-banana', category: 'food', label: 'Банан', keywords: ['банан'] },
|
||||
{ id: 'e-grape', category: 'food', label: 'Виноград', keywords: ['виноград'] },
|
||||
{ id: 'e-watermelon', category: 'food', label: 'Арбуз', keywords: ['арбуз'] },
|
||||
{ id: 'e-egg', category: 'food', label: 'Яйцо', keywords: ['яйцо'] },
|
||||
{ id: 'e-bread', category: 'food', label: 'Хлеб', keywords: ['хлеб'] },
|
||||
|
||||
{ id: 'e-car', category: 'travel', label: 'Авто', keywords: ['машина', 'авто'] },
|
||||
{ id: 'e-bus', category: 'travel', label: 'Автобус', keywords: ['автобус'] },
|
||||
{ id: 'e-train', category: 'travel', label: 'Поезд', keywords: ['поезд'] },
|
||||
{ id: 'e-airplane', category: 'travel', label: 'Самолёт', keywords: ['самолёт'] },
|
||||
{ id: 'e-rocket', category: 'travel', label: 'Ракета', keywords: ['космос', 'ракета'] },
|
||||
{ id: 'e-ship', category: 'travel', label: 'Корабль', keywords: ['корабль'] },
|
||||
{ id: 'e-bike', category: 'travel', label: 'Велосипед', keywords: ['велосипед'] },
|
||||
{ id: 'e-house', category: 'travel', label: 'Дом', keywords: ['дом'] },
|
||||
{ id: 'e-mountain', category: 'travel', label: 'Гора', keywords: ['гора'] },
|
||||
{ id: 'e-beach', category: 'travel', label: 'Пляж', keywords: ['море', 'пляж'] },
|
||||
|
||||
{ id: 'e-bulb', category: 'objects', label: 'Лампочка', keywords: ['идея', 'лампа'] },
|
||||
{ id: 'e-phone', category: 'objects', label: 'Телефон', keywords: ['телефон'] },
|
||||
{ id: 'e-laptop', category: 'objects', label: 'Ноутбук', keywords: ['компьютер'] },
|
||||
{ id: 'e-camera', category: 'objects', label: 'Камера', keywords: ['фото'] },
|
||||
{ id: 'e-gift', category: 'objects', label: 'Подарок', keywords: ['подарок'] },
|
||||
{ id: 'e-balloon', category: 'objects', label: 'Шарик', keywords: ['праздник'] },
|
||||
{ id: 'e-book', category: 'objects', label: 'Книга', keywords: ['книга'] },
|
||||
{ id: 'e-key', category: 'objects', label: 'Ключ', keywords: ['ключ'] },
|
||||
{ id: 'e-lock', category: 'objects', label: 'Замок', keywords: ['замок', 'безопасность'] },
|
||||
{ id: 'e-clock', category: 'objects', label: 'Часы', keywords: ['время'] },
|
||||
{ id: 'e-music', category: 'objects', label: 'Музыка', keywords: ['нота', 'музыка'] },
|
||||
{ id: 'e-game', category: 'objects', label: 'Игра', keywords: ['геймпад', 'игра'] },
|
||||
|
||||
{ id: 'e-check', category: 'symbols', label: 'Галочка', keywords: ['да', 'ok'] },
|
||||
{ id: 'e-cross', category: 'symbols', label: 'Крест', keywords: ['нет', 'ошибка'] },
|
||||
{ id: 'e-exclamation', category: 'symbols', label: 'Внимание', keywords: ['!', 'важно'] },
|
||||
{ id: 'e-question', category: 'symbols', label: 'Вопрос', keywords: ['?', 'вопрос'] },
|
||||
{ id: 'e-fire', category: 'symbols', label: 'Огонь', keywords: ['огонь', 'hot'] },
|
||||
{ id: 'e-star', category: 'symbols', label: 'Звезда', keywords: ['звезда'] },
|
||||
{ id: 'e-sparkles', category: 'symbols', label: 'Искры', keywords: ['блеск', 'новый'] },
|
||||
{ id: 'e-100', category: 'symbols', label: '100', keywords: ['сто', 'идеально'] },
|
||||
{ id: 'e-plus', category: 'symbols', label: 'Плюс', keywords: ['плюс'] },
|
||||
{ id: 'e-minus', category: 'symbols', label: 'Минус', keywords: ['минус'] },
|
||||
{ id: 'e-warning', category: 'symbols', label: 'Предупреждение', keywords: ['warning'] },
|
||||
{ id: 'e-recycle', category: 'symbols', label: 'Recycle', keywords: ['эко'] }
|
||||
];
|
||||
|
||||
export const EMOJI_BY_ID = new Map(EMOJI_DEFINITIONS.map((item) => [item.id, item]));
|
||||
|
||||
export function isEmojiId(value: string) {
|
||||
return EMOJI_BY_ID.has(value);
|
||||
}
|
||||
|
||||
export function searchEmojis(query: string) {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return EMOJI_DEFINITIONS;
|
||||
return EMOJI_DEFINITIONS.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(normalized) ||
|
||||
item.id.includes(normalized) ||
|
||||
item.keywords.some((keyword) => keyword.includes(normalized))
|
||||
);
|
||||
}
|
||||
133
apps/frontend/lib/emoji-native-map.ts
Normal file
133
apps/frontend/lib/emoji-native-map.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export const EMOJI_NATIVE_MAP: Record<string, string> = {
|
||||
'e-grinning': '😀',
|
||||
'e-smile': '😊',
|
||||
'e-laugh': '😄',
|
||||
'e-rofl': '🤣',
|
||||
'e-wink': '😉',
|
||||
'e-blush': '😊',
|
||||
'e-innocent': '😇',
|
||||
'e-slight-smile': '🙂',
|
||||
'e-thinking': '🤔',
|
||||
'e-neutral': '😐',
|
||||
'e-expressionless': '😑',
|
||||
'e-sleeping': '😴',
|
||||
'e-tired': '😫',
|
||||
'e-cry': '😢',
|
||||
'e-sob': '😭',
|
||||
'e-angry': '😠',
|
||||
'e-rage': '😡',
|
||||
'e-shock': '😮',
|
||||
'e-fear': '😨',
|
||||
'e-cool': '😎',
|
||||
'e-nerd': '🤓',
|
||||
'e-sick': '🤒',
|
||||
'e-mask': '😷',
|
||||
'e-party': '🥳',
|
||||
'e-star-eyes': '🤩',
|
||||
'e-heart-eyes': '😍',
|
||||
'e-kiss': '😘',
|
||||
'e-tongue': '😛',
|
||||
'e-sweat': '😅',
|
||||
'e-dizzy': '😵',
|
||||
'e-thumbs-up': '👍',
|
||||
'e-thumbs-down': '👎',
|
||||
'e-clap': '👏',
|
||||
'e-wave': '👋',
|
||||
'e-pray': '🙏',
|
||||
'e-muscle': '💪',
|
||||
'e-ok-hand': '👌',
|
||||
'e-victory': '✌️',
|
||||
'e-crossed-fingers': '🤞',
|
||||
'e-point-up': '☝️',
|
||||
'e-point-down': '👇',
|
||||
'e-raised-hand': '✋',
|
||||
'e-fist': '✊',
|
||||
'e-handshake': '🤝',
|
||||
'e-writing': '✍️',
|
||||
'e-red-heart': '❤️',
|
||||
'e-orange-heart': '🧡',
|
||||
'e-yellow-heart': '💛',
|
||||
'e-green-heart': '💚',
|
||||
'e-blue-heart': '💙',
|
||||
'e-purple-heart': '💜',
|
||||
'e-black-heart': '🖤',
|
||||
'e-white-heart': '🤍',
|
||||
'e-broken-heart': '💔',
|
||||
'e-sparkling-heart': '💖',
|
||||
'e-two-hearts': '💕',
|
||||
'e-revolving-hearts': '💞',
|
||||
'e-dog': '🐶',
|
||||
'e-cat': '🐱',
|
||||
'e-mouse': '🐭',
|
||||
'e-rabbit': '🐰',
|
||||
'e-bear': '🐻',
|
||||
'e-panda': '🐼',
|
||||
'e-lion': '🦁',
|
||||
'e-tiger': '🐯',
|
||||
'e-fox': '🦊',
|
||||
'e-wolf': '🦊',
|
||||
'e-monkey': '🐵',
|
||||
'e-chicken': '🐔',
|
||||
'e-penguin': '🐧',
|
||||
'e-unicorn': '🦄',
|
||||
'e-butterfly': '🦋',
|
||||
'e-pizza': '🍕',
|
||||
'e-burger': '🍔',
|
||||
'e-fries': '🍟',
|
||||
'e-hotdog': '🌭',
|
||||
'e-cake': '🎂',
|
||||
'e-cookie': '🍪',
|
||||
'e-coffee': '☕',
|
||||
'e-beer': '🍺',
|
||||
'e-wine': '🍷',
|
||||
'e-apple': '🍎',
|
||||
'e-banana': '🍌',
|
||||
'e-grape': '🍇',
|
||||
'e-watermelon': '🍉',
|
||||
'e-egg': '🥚',
|
||||
'e-bread': '🍞',
|
||||
'e-car': '🚗',
|
||||
'e-bus': '🚌',
|
||||
'e-train': '🚆',
|
||||
'e-airplane': '✈️',
|
||||
'e-rocket': '🚀',
|
||||
'e-ship': '🚢',
|
||||
'e-bike': '🚲',
|
||||
'e-house': '🏠',
|
||||
'e-mountain': '⛰️',
|
||||
'e-beach': '🏖️',
|
||||
'e-bulb': '💡',
|
||||
'e-phone': '📱',
|
||||
'e-laptop': '💻',
|
||||
'e-camera': '📷',
|
||||
'e-gift': '🎁',
|
||||
'e-balloon': '🎈',
|
||||
'e-book': '📚',
|
||||
'e-key': '🔑',
|
||||
'e-lock': '🔒',
|
||||
'e-clock': '🕐',
|
||||
'e-music': '🎵',
|
||||
'e-game': '🎮',
|
||||
'e-check': '✅',
|
||||
'e-cross': '❌',
|
||||
'e-exclamation': '❗',
|
||||
'e-question': '❓',
|
||||
'e-fire': '🔥',
|
||||
'e-star': '⭐',
|
||||
'e-sparkles': '✨',
|
||||
'e-100': '💯',
|
||||
'e-plus': '➕',
|
||||
'e-minus': '➖',
|
||||
'e-warning': '⚠️',
|
||||
'e-recycle': '♻️'
|
||||
};
|
||||
|
||||
export function emojiIdToNative(id: string): string | null {
|
||||
return EMOJI_NATIVE_MAP[id] ?? null;
|
||||
}
|
||||
|
||||
export function resolveNativeEmojiContent(content?: string | null): string {
|
||||
if (!content) return '';
|
||||
if (EMOJI_NATIVE_MAP[content]) return EMOJI_NATIVE_MAP[content]!;
|
||||
return content.replace(/:([a-z0-9-]+):/g, (match, slug) => EMOJI_NATIVE_MAP[`e-${slug}`] ?? match);
|
||||
}
|
||||
@@ -1,28 +1,64 @@
|
||||
import { ChatRoom, createChatRoom, fetchChatRooms } from '@/lib/api';
|
||||
import { ChatRoom, fetchChatRooms } from '@/lib/api';
|
||||
|
||||
const ACTIVE_ROOM_STORAGE_PREFIX = 'lendry-family-active-room:';
|
||||
|
||||
export function readStoredActiveRoomId(groupId: string) {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(`${ACTIVE_ROOM_STORAGE_PREFIX}${groupId}`);
|
||||
}
|
||||
|
||||
export function storeActiveRoomId(groupId: string, roomId: string | null) {
|
||||
if (typeof window === 'undefined') return;
|
||||
const key = `${ACTIVE_ROOM_STORAGE_PREFIX}${groupId}`;
|
||||
if (!roomId) {
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(key, roomId);
|
||||
}
|
||||
|
||||
export function findMemberRoom(
|
||||
rooms: ChatRoom[],
|
||||
memberUserId: string,
|
||||
currentUserId: string,
|
||||
options?: { isBot?: boolean; e2e?: boolean }
|
||||
) {
|
||||
const targetType = options?.isBot ? 'BOT' : options?.e2e ? 'E2E' : 'DIRECT';
|
||||
return rooms.find(
|
||||
(room) =>
|
||||
room.type === targetType &&
|
||||
room.members.some((member) => member.userId === memberUserId) &&
|
||||
room.members.some((member) => member.userId === currentUserId)
|
||||
);
|
||||
}
|
||||
|
||||
export async function findOrCreateMemberChat(
|
||||
groupId: string,
|
||||
memberUserId: string,
|
||||
memberName: string,
|
||||
_memberName: string,
|
||||
currentUserId: string,
|
||||
token: string
|
||||
): Promise<ChatRoom> {
|
||||
) {
|
||||
const response = await fetchChatRooms(groupId, token);
|
||||
const rooms = response.rooms ?? [];
|
||||
const existing = rooms.find(
|
||||
const member = rooms.find(
|
||||
(room) =>
|
||||
room.type === 'GROUP' &&
|
||||
room.members.length === 2 &&
|
||||
room.members.some((member) => member.userId === memberUserId) &&
|
||||
room.members.some((member) => member.userId === currentUserId)
|
||||
(room.type === 'DIRECT' || room.type === 'BOT') &&
|
||||
room.members.some((item) => item.userId === memberUserId) &&
|
||||
room.members.some((item) => item.userId === currentUserId)
|
||||
);
|
||||
if (existing) return existing;
|
||||
return createChatRoom(groupId, memberName, [memberUserId], token);
|
||||
if (member) return member;
|
||||
throw new Error('Чат ещё не создан. Обновите страницу семьи.');
|
||||
}
|
||||
|
||||
export function roomDisplayLabel(room: ChatRoom, currentUserId: string) {
|
||||
if (room.type === 'GENERAL') return room.name;
|
||||
if (room.members.length === 2) {
|
||||
if (room.type === 'BOT') return room.name;
|
||||
if (room.type === 'E2E') {
|
||||
const other = room.members.find((member) => member.userId !== currentUserId);
|
||||
return other ? `🔒 ${other.displayName}` : '🔒 Секретный чат';
|
||||
}
|
||||
if (room.type === 'DIRECT' || room.members.length === 2) {
|
||||
const other = room.members.find((member) => member.userId !== currentUserId);
|
||||
if (other) return other.displayName;
|
||||
}
|
||||
|
||||
@@ -42,15 +42,21 @@ export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints {
|
||||
|
||||
export function buildAuthorizeUrl(
|
||||
apiBase: string,
|
||||
params: { clientId: string; redirectUri: string; scope: string; userId?: string; state?: string }
|
||||
params: { clientId: string; redirectUri: string; scope: string; userId?: string; state?: string; useStandardParams?: boolean }
|
||||
) {
|
||||
const url = new URL(`${normalizeBaseUrl(apiBase)}/oauth/authorize`);
|
||||
url.searchParams.set('clientId', params.clientId);
|
||||
url.searchParams.set('redirectUri', params.redirectUri);
|
||||
url.searchParams.set('scope', params.scope);
|
||||
url.searchParams.set('userId', params.userId ?? 'USER_ID_AFTER_LOGIN');
|
||||
if (params.state) {
|
||||
url.searchParams.set('state', params.state);
|
||||
if (params.useStandardParams ?? true) {
|
||||
url.searchParams.set('client_id', params.clientId);
|
||||
url.searchParams.set('redirect_uri', params.redirectUri);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', params.scope);
|
||||
if (params.state) url.searchParams.set('state', params.state);
|
||||
} else {
|
||||
url.searchParams.set('clientId', params.clientId);
|
||||
url.searchParams.set('redirectUri', params.redirectUri);
|
||||
url.searchParams.set('scope', params.scope);
|
||||
if (params.userId) url.searchParams.set('userId', params.userId);
|
||||
if (params.state) url.searchParams.set('state', params.state);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user