global update and global fix

This commit is contained in:
lendry
2026-06-25 23:48:57 +03:00
parent 3880c68d59
commit b0ea87e898
121 changed files with 13759 additions and 663 deletions

View File

@@ -0,0 +1,234 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Ban, Bot, CheckCircle2, Loader2, MessageSquare, Search, Users } from 'lucide-react';
import { AdminShell } from '@/components/id/admin-shell';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
AdminBotMetrics,
ManagedBot,
fetchAdminBotMetrics,
fetchAdminBots,
setAdminBotActive
} from '@/lib/api';
import { getAdminLandingPath } from '@/lib/admin-access';
export default function AdminBotsPage() {
const router = useRouter();
const { token, user: currentUser } = useAuth();
const { showToast } = useToast();
const [bots, setBots] = useState<ManagedBot[]>([]);
const [total, setTotal] = useState(0);
const [metrics, setMetrics] = useState<AdminBotMetrics | null>(null);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [actionBotId, setActionBotId] = useState<string | null>(null);
const loadBots = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const [botsResponse, metricsResponse] = await Promise.all([
fetchAdminBots({ search, page, limit: 20 }, token),
fetchAdminBotMetrics(token)
]);
setBots(botsResponse.bots ?? []);
setTotal(botsResponse.total ?? 0);
setMetrics(metricsResponse);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить ботов');
} finally {
setLoading(false);
}
}, [page, search, showToast, token]);
useEffect(() => {
if (!currentUser) return;
if (!currentUser.canManageBots && !currentUser.isSuperAdmin) {
router.replace(getAdminLandingPath(currentUser));
}
}, [currentUser, router]);
useEffect(() => {
if (!currentUser?.canManageBots && !currentUser?.isSuperAdmin) return;
void loadBots();
}, [currentUser?.canManageBots, currentUser?.isSuperAdmin, loadBots]);
async function handleToggleActive(bot: ManagedBot) {
if (!token) return;
setActionBotId(bot.id);
try {
await setAdminBotActive(bot.id, !bot.isActive, token);
showToast(bot.isActive ? 'Бот заблокирован' : 'Бот разблокирован');
await loadBots();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось изменить статус бота');
} finally {
setActionBotId(null);
}
}
const totalPages = Math.max(1, Math.ceil(total / 20));
return (
<AdminShell active="/admin/bots">
<h2 className="mb-4 text-2xl font-medium tracking-tight">Telegram-боты</h2>
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="Всего ботов" value={metrics?.totalBots ?? 0} icon={Bot} />
<MetricCard label="Активных" value={metrics?.activeBots ?? 0} icon={CheckCircle2} accent="text-emerald-600" />
<MetricCard label="Заблокированных" value={metrics?.blockedBots ?? 0} icon={Ban} accent="text-rose-600" />
<MetricCard label="Сообщений" value={metrics?.totalMessages ?? 0} icon={MessageSquare} />
</div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="relative min-w-[240px] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
<Input
className="rounded-xl pl-9"
placeholder="Поиск по названию или @username"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
/>
</div>
<p className="text-sm text-[#667085]">Найдено: {total}</p>
</div>
<div className="overflow-hidden rounded-2xl border border-[#eceef4] bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Бот</TableHead>
<TableHead>Владелец</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : bots.length ? (
bots.map((bot) => (
<TableRow key={bot.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
<Bot className="h-5 w-5" />
</div>
<div>
<p className="font-medium">{bot.name}</p>
<p className="text-sm text-[#667085]">@{bot.username}</p>
{bot.isSystemBot ? <p className="text-xs text-[#3390ec]">Системный</p> : null}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-[#667085]" />
<div>
<p className="text-sm font-medium">{bot.owner?.displayName ?? '—'}</p>
{bot.owner?.username ? <p className="text-xs text-[#667085]">@{bot.owner.username}</p> : null}
</div>
</div>
</TableCell>
<TableCell>
<span
className={
bot.isActive
? 'inline-flex rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700'
: 'inline-flex rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700'
}
>
{bot.isActive ? 'Активен' : 'Заблокирован'}
</span>
</TableCell>
<TableCell className="text-right">
{!bot.isSystemBot ? (
<Button
variant="outline"
size="sm"
className="rounded-xl"
disabled={actionBotId === bot.id}
onClick={() => void handleToggleActive(bot)}
>
{actionBotId === bot.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : bot.isActive ? (
'Заблокировать'
) : (
'Разблокировать'
)}
</Button>
) : (
<span className="text-xs text-[#667085]"></span>
)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
Боты не найдены
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{totalPages > 1 ? (
<div className="mt-4 flex items-center justify-center gap-2">
<Button variant="outline" size="sm" className="rounded-xl" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>
Назад
</Button>
<span className="text-sm text-[#667085]">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
className="rounded-xl"
disabled={page >= totalPages}
onClick={() => setPage((value) => value + 1)}
>
Вперёд
</Button>
</div>
) : null}
</AdminShell>
);
}
function MetricCard({
label,
value,
icon: Icon,
accent
}: {
label: string;
value: number;
icon: typeof Bot;
accent?: string;
}) {
return (
<div className="rounded-2xl border border-[#eceef4] bg-white p-4">
<div className="mb-2 flex items-center gap-2 text-sm text-[#667085]">
<Icon className={`h-4 w-4 ${accent ?? ''}`} />
{label}
</div>
<p className="text-2xl font-semibold">{value.toLocaleString('ru-RU')}</p>
</div>
);
}

View File

@@ -82,6 +82,16 @@ export default function LoginPage() {
const router = useRouter();
const finishLoginRedirect = useCallback(() => {
const redirectAfterLogin =
typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null;
if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) {
router.push(redirectAfterLogin);
return;
}
router.push('/');
}, [router]);
const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth } = useAuth();
const { showToast } = useToast();
@@ -174,7 +184,7 @@ export default function LoginPage() {
} else {
router.push('/');
finishLoginRedirect();
}
@@ -596,7 +606,7 @@ export default function LoginPage() {
await completePin(pendingSessionId, pin);
router.push('/');
finishLoginRedirect();
} catch (error) {

View File

@@ -0,0 +1,150 @@
'use client';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2, ShieldCheck } from 'lucide-react';
import { BrandLogo } from '@/components/id/brand-logo';
import { useAuth } from '@/components/id/auth-provider';
import { Button } from '@/components/ui/button';
function OAuthAuthorizeContent() {
const searchParams = useSearchParams();
const router = useRouter();
const { user, token, isPinLocked, isLoading } = useAuth();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const oauthQuery = useMemo(() => {
const params = new URLSearchParams();
searchParams.forEach((value, key) => params.set(key, value));
return params;
}, [searchParams]);
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId');
const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri');
const scope = searchParams.get('scope') ?? 'openid profile';
useEffect(() => {
if (isLoading) return;
if (!clientId || !redirectUri) return;
if (user && token && !isPinLocked) return;
const returnUrl = `/auth/oauth/authorize?${oauthQuery.toString()}`;
router.replace(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`);
}, [clientId, isLoading, isPinLocked, oauthQuery, redirectUri, router, token, user]);
const approve = useCallback(async () => {
if (!token || !user) return;
setSubmitting(true);
setError(null);
try {
const apiBase = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, '');
const url = new URL(`${apiBase}/oauth/authorize`);
oauthQuery.forEach((value, key) => url.searchParams.set(key, value));
url.searchParams.set('userId', user.id);
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json'
}
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string | string[] } | null;
const message = Array.isArray(payload?.message) ? payload.message.join(', ') : payload?.message;
throw new Error(message || 'Не удалось подтвердить доступ');
}
const data = (await response.json()) as { redirectUrl?: string };
if (!data.redirectUrl) {
throw new Error('Сервер не вернул redirect URL');
}
window.location.href = data.redirectUrl;
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка OAuth авторизации');
} finally {
setSubmitting(false);
}
}, [oauthQuery, token, user]);
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
</div>
);
}
if (!clientId || !redirectUri) {
return (
<div className="mx-auto max-w-md px-4 py-16 text-center">
<h1 className="text-xl font-semibold">Некорректный OAuth запрос</h1>
<p className="mt-3 text-sm text-[#667085]">Отсутствуют обязательные параметры client_id и redirect_uri.</p>
<Link href="/" className="mt-6 inline-block text-[#3390ec] hover:underline">
На главную
</Link>
</div>
);
}
if (!user || !token || isPinLocked) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
</div>
);
}
return (
<div className="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 py-12">
<div className="mb-8 flex justify-center">
<BrandLogo />
</div>
<div className="rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
<ShieldCheck className="h-6 w-6" />
</div>
<h1 className="text-2xl font-semibold">Разрешить доступ?</h1>
<p className="mt-2 text-sm leading-relaxed text-[#667085]">
Приложение <span className="font-medium text-[#1f2430]">{clientId}</span> запрашивает доступ к вашему аккаунту Lendry ID.
</p>
<div className="mt-4 space-y-2 rounded-2xl bg-[#f4f5f8] p-4 text-sm">
<p>
<span className="text-[#667085]">Пользователь:</span> {user.displayName}
</p>
<p>
<span className="text-[#667085]">Scopes:</span> {scope}
</p>
<p className="break-all">
<span className="text-[#667085]">Redirect URI:</span> {redirectUri}
</p>
</div>
{error ? <p className="mt-4 text-sm text-red-600">{error}</p> : null}
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<Button className="flex-1 rounded-xl" disabled={submitting} onClick={() => void approve()}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Разрешить
</Button>
<Button variant="outline" className="flex-1 rounded-xl" disabled={submitting} onClick={() => router.push('/')}>
Отмена
</Button>
</div>
</div>
</div>
);
}
export default function OAuthAuthorizePage() {
return (
<Suspense
fallback={
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
</div>
}
>
<OAuthAuthorizeContent />
</Suspense>
);
}

View File

@@ -5,17 +5,22 @@ import { FamilyGroupView } from '@/components/family/family-group-view';
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
import { IdShell } from '@/components/id/shell';
export default function FamilyGroupPage({ params }: { params: Promise<{ groupId: string }> }) {
const { groupId } = use(params);
function FamilyGroupPageContent({ groupId }: { groupId: string }) {
const { setSelectedGroupId } = useFamilyOverlay();
useEffect(() => {
setSelectedGroupId(groupId);
}, [groupId, setSelectedGroupId]);
return <FamilyGroupView groupId={groupId} />;
}
export default function FamilyGroupPage({ params }: { params: Promise<{ groupId: string }> }) {
const { groupId } = use(params);
return (
<IdShell active="/family" wide>
<FamilyGroupView groupId={groupId} />
<FamilyGroupPageContent groupId={groupId} />
</IdShell>
);
}

View File

@@ -46,6 +46,20 @@ input {
background: var(--muted);
}
@keyframes chat-message-blink {
0%, 100% {
background-color: transparent;
}
25%, 75% {
background-color: rgb(51 144 236 / 18%);
}
}
.blink-highlight {
animation: chat-message-blink 0.55s ease-in-out 2;
border-radius: 18px;
}
.id-shadow {
box-shadow: 0 24px 70px rgb(22 26 43 / 12%);
}

View File

@@ -0,0 +1,181 @@
'use client';
import { useMemo, useState } from 'react';
import { Check, Copy, Loader2, Bot } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { createManagedBot, getApiErrorMessage } from '@/lib/api';
import { cn } from '@/lib/utils';
type Step = 'name' | 'username' | 'done';
export function BotCreateMiniAppContent() {
const { token, user, isPinLocked } = useAuth();
const { showToast } = useToast();
const [step, setStep] = useState<Step>('name');
const [name, setName] = useState('');
const [username, setUsername] = useState('');
const [creating, setCreating] = useState(false);
const [createdBot, setCreatedBot] = useState<{ username: string; token: string; botId: string } | null>(null);
const canUse = Boolean(token && user && !isPinLocked);
const usernamePreview = useMemo(() => `${username.replace(/_bot$/i, '').trim()}_bot`, [username]);
async function handleCreate() {
if (!canUse) return;
const trimmedName = name.trim();
const trimmedUsername = username.replace(/_bot$/i, '').trim();
if (!trimmedName) {
showToast('Укажите название бота');
setStep('name');
return;
}
if (trimmedUsername.length < 5) {
showToast('Username должен содержать минимум 5 символов');
return;
}
setCreating(true);
try {
const response = await createManagedBot({ name: trimmedName, username: trimmedUsername }, token);
setCreatedBot({
username: response.bot?.username ?? usernamePreview,
token: response.token ?? '',
botId: response.bot?.id ?? ''
});
setStep('done');
showToast('Бот успешно создан');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось создать бота') ?? 'Ошибка');
} finally {
setCreating(false);
}
}
async function copyToken() {
if (!createdBot?.token) return;
await navigator.clipboard.writeText(createdBot.token);
showToast('Токен скопирован');
}
if (!canUse) {
return (
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
Войдите в Lendry ID, чтобы создать бота
</div>
);
}
return (
<div className="min-h-screen bg-[#17212b] p-4 text-white">
<div className="mx-auto max-w-md space-y-5 rounded-[24px] bg-[#242f3d] p-5 shadow-xl">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#3390ec]/20 text-[#3390ec]">
<Bot className="h-6 w-6" />
</div>
<div>
<h1 className="text-lg font-semibold">Создание бота</h1>
<p className="text-sm text-[#8b93a7]">Как в BotFather Telegram</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-[#8b93a7]">
{(['name', 'username', 'done'] as Step[]).map((item, index) => (
<div key={item} className="flex items-center gap-2">
<span
className={cn(
'flex h-6 w-6 items-center justify-center rounded-full',
step === item || (step === 'done' && item !== 'done') || (item === 'name' && step !== 'name')
? 'bg-[#3390ec] text-white'
: 'bg-[#17212b] text-[#8b93a7]'
)}
>
{step === 'done' && item !== 'done' ? <Check className="h-3.5 w-3.5" /> : index + 1}
</span>
{index < 2 ? <span className="h-px w-8 bg-[#2a3544]" /> : null}
</div>
))}
</div>
{step === 'name' ? (
<div className="space-y-4">
<p className="text-sm leading-relaxed text-[#c5cad3]">
Alright, a new bot. How are we going to call it? Please choose a name for your bot.
</p>
<p className="text-sm text-[#8b93a7]">Хорошо, новый бот. Как мы его назовём? Выберите название.</p>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Например: Сервис уведомлений"
className="rounded-xl border-[#2a3544] bg-[#17212b] text-white placeholder:text-[#667085]"
autoFocus
/>
<Button className="w-full rounded-xl" disabled={!name.trim()} onClick={() => setStep('username')}>
Далее
</Button>
</div>
) : null}
{step === 'username' ? (
<div className="space-y-4">
<p className="text-sm leading-relaxed text-[#c5cad3]">
Good. Now let&apos;s choose a username for your bot. It must end in `bot`.
</p>
<p className="text-sm text-[#8b93a7]">Username должен заканчиваться на `_bot` (суффикс добавится автоматически).</p>
<div className="flex items-center gap-2">
<span className="text-[#8b93a7]">@</span>
<Input
value={username}
onChange={(event) => setUsername(event.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder="notify_service"
className="rounded-xl border-[#2a3544] bg-[#17212b] text-white placeholder:text-[#667085]"
autoFocus
/>
</div>
<p className="text-xs text-[#8b93a7]">Будет: @{usernamePreview || 'your_bot'}</p>
<div className="flex gap-2">
<Button variant="secondary" className="flex-1 rounded-xl bg-[#17212b] text-white hover:bg-[#1c2733]" onClick={() => setStep('name')}>
Назад
</Button>
<Button className="flex-1 rounded-xl" disabled={creating || username.replace(/_bot$/i, '').trim().length < 5} onClick={() => void handleCreate()}>
{creating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Создать бота
</Button>
</div>
</div>
) : null}
{step === 'done' && createdBot ? (
<div className="space-y-4">
<div className="rounded-2xl bg-[#17212b] p-4">
<p className="text-sm text-[#8b93a7]">Done! Congratulations on your new bot.</p>
<p className="mt-2 text-base font-semibold">@{createdBot.username}</p>
<p className="mt-3 text-sm text-red-300">Сохраните токен он больше не будет показан:</p>
<div className="mt-2 flex items-center gap-2">
<Input value={createdBot.token} readOnly className="rounded-xl border-[#2a3544] bg-[#242f3d] font-mono text-xs text-white" />
<Button type="button" variant="secondary" className="rounded-xl bg-[#3390ec] text-white hover:bg-[#2b7fd4]" onClick={() => void copyToken()}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<Button
className="w-full rounded-xl"
variant="secondary"
onClick={() => {
setStep('name');
setName('');
setUsername('');
setCreatedBot(null);
}}
>
Создать ещё одного бота
</Button>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { Suspense } from 'react';
import { BotCreateMiniAppContent } from './content';
export default function BotCreateMiniAppPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center p-6 text-sm text-[#667085]">Загрузка...</div>
}
>
<BotCreateMiniAppContent />
</Suspense>
);
}

View File

@@ -0,0 +1,371 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Copy, ImageIcon, LayoutGrid, Loader2, RefreshCw, TextQuote, UserCircle2 } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
fetchBot,
getApiErrorMessage,
revokeManagedBotToken,
updateManagedBot,
updateManagedBotProfile
} from '@/lib/api';
import { parseComposerMenuButtonJson } from '@/lib/bot-menu-button';
function FieldTextarea({
label,
hint,
value,
onChange,
maxLength,
rows = 3
}: {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
maxLength?: number;
rows?: number;
}) {
return (
<div className="space-y-2">
<label className="text-sm font-medium">{label}</label>
{hint ? <p className="text-xs text-[#667085]">{hint}</p> : null}
<textarea
value={value}
onChange={(event) => onChange(event.target.value)}
maxLength={maxLength}
rows={rows}
className="w-full resize-y rounded-xl border border-[#dce3ec] bg-white px-3 py-2 text-sm outline-none transition focus:border-[#3390ec] focus:ring-2 focus:ring-[#3390ec]/15"
/>
{maxLength ? <p className="text-right text-xs text-[#a8adbc]">{value.length}/{maxLength}</p> : null}
</div>
);
}
function SectionCard({
icon: Icon,
title,
description,
children
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-4 rounded-[20px] border border-[#eceef4] bg-[#fafbfc] p-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[#3390ec]/10 text-[#3390ec]">
<Icon className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{title}</h2>
<p className="text-sm text-[#667085]">{description}</p>
</div>
</div>
{children}
</section>
);
}
export function BotManageMiniAppContent() {
const searchParams = useSearchParams();
const botId = searchParams.get('botId') ?? '';
const { token, user, isPinLocked } = useAuth();
const { showToast } = useToast();
const [loading, setLoading] = useState(true);
const [savingBasic, setSavingBasic] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [revoking, setRevoking] = useState(false);
const [name, setName] = useState('');
const [username, setUsername] = useState('');
const [description, setDescription] = useState('');
const [aboutText, setAboutText] = useState('');
const [botPicUrl, setBotPicUrl] = useState('');
const [menuButtonUrl, setMenuButtonUrl] = useState('');
const [menuButtonText, setMenuButtonText] = useState('App');
const [tokenPrefix, setTokenPrefix] = useState('');
const [newToken, setNewToken] = useState<string | null>(null);
const canUse = Boolean(token && user && !isPinLocked && botId);
const loadBot = useCallback(async () => {
if (!canUse) return;
setLoading(true);
try {
const bot = await fetchBot(botId, token);
setName(bot.name);
setUsername(bot.username);
setDescription(bot.description ?? '');
setAboutText(bot.aboutText ?? '');
setBotPicUrl(bot.botPicUrl ?? '');
setTokenPrefix(bot.tokenPrefix ?? '');
const menuButton = parseComposerMenuButtonJson(bot.menuButtonJson);
if (menuButton) {
setMenuButtonUrl(menuButton.web_app.url);
setMenuButtonText(menuButton.text);
} else {
setMenuButtonUrl('');
setMenuButtonText('App');
}
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить бота') ?? 'Ошибка');
} finally {
setLoading(false);
}
}, [botId, canUse, showToast, token]);
useEffect(() => {
void loadBot();
}, [loadBot]);
const usernameWithoutSuffix = useMemo(() => username.replace(/_bot$/i, ''), [username]);
async function handleSaveBasic() {
if (!canUse) return;
setSavingBasic(true);
try {
const bot = await updateManagedBot(
botId,
{
name: name.trim(),
username: usernameWithoutSuffix.trim()
},
token
);
setName(bot.name);
setUsername(bot.username);
showToast('Основные настройки сохранены');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось сохранить настройки') ?? 'Ошибка');
} finally {
setSavingBasic(false);
}
}
async function handleSaveProfile() {
if (!canUse) return;
setSavingProfile(true);
try {
const bot = await updateManagedBotProfile(
botId,
{
description: description.trim(),
aboutText: aboutText.trim(),
botPicUrl: botPicUrl.trim(),
menuButtonUrl: menuButtonUrl.trim(),
menuButtonText: menuButtonText.trim() || 'App'
},
token
);
setDescription(bot.description ?? '');
setAboutText(bot.aboutText ?? '');
setBotPicUrl(bot.botPicUrl ?? '');
const menuButton = parseComposerMenuButtonJson(bot.menuButtonJson);
if (menuButton) {
setMenuButtonUrl(menuButton.web_app.url);
setMenuButtonText(menuButton.text);
}
showToast('Профиль бота обновлён');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось обновить профиль') ?? 'Ошибка');
} finally {
setSavingProfile(false);
}
}
async function handleRevokeToken() {
if (!canUse) return;
setRevoking(true);
try {
const response = await revokeManagedBotToken(botId, token);
setNewToken(response.token ?? null);
setTokenPrefix(response.bot?.tokenPrefix ?? tokenPrefix);
showToast('Токен перевыпущен');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось перевыпустить токен') ?? 'Ошибка');
} finally {
setRevoking(false);
}
}
async function copyToken() {
if (!newToken) return;
await navigator.clipboard.writeText(newToken);
showToast('Токен скопирован');
}
if (!botId) {
return <div className="flex min-h-screen items-center justify-center p-6 text-sm text-[#667085]">Не указан botId</div>;
}
if (!canUse) {
return (
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
Войдите в Lendry ID, чтобы управлять ботом
</div>
);
}
return (
<div className="min-h-screen bg-[#f4f5f8] p-4 pb-8">
<div className="mx-auto max-w-lg space-y-4">
<div className="rounded-[24px] bg-white p-5 shadow-sm">
<h1 className="text-lg font-semibold">Настройки бота</h1>
<p className="text-sm text-[#667085]">Интерактивное управление профилем аналог команд BotFather</p>
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 rounded-[24px] bg-white p-8 text-sm text-[#667085] shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка...
</div>
) : (
<>
<div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm">
<SectionCard icon={UserCircle2} title="Основное" description="Название и username бота (/newbot)">
<div className="space-y-2">
<label className="text-sm font-medium">Название</label>
<Input value={name} onChange={(event) => setName(event.target.value)} className="rounded-xl" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Username</label>
<div className="flex items-center gap-2">
<span className="text-sm text-[#667085]">@</span>
<Input
value={usernameWithoutSuffix}
onChange={(event) => setUsername(`${event.target.value.replace(/_bot$/i, '')}_bot`)}
className="rounded-xl"
/>
</div>
</div>
<Button className="w-full rounded-xl" disabled={savingBasic} onClick={() => void handleSaveBasic()}>
{savingBasic ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Сохранить основное
</Button>
</SectionCard>
</div>
<div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm">
<SectionCard
icon={TextQuote}
title="Профиль"
description="Описание и краткий текст — /setdescription и /setabouttext"
>
<FieldTextarea
label="Описание (/setdescription)"
hint="Показывается пользователю перед началом диалога"
value={description}
onChange={setDescription}
maxLength={512}
rows={4}
/>
<FieldTextarea
label="О боте (/setabouttext)"
hint="Краткое описание на странице профиля бота"
value={aboutText}
onChange={setAboutText}
maxLength={120}
rows={2}
/>
</SectionCard>
<SectionCard icon={ImageIcon} title="Аватар" description="URL изображения — /setuserpic">
<div className="space-y-3">
<Input
value={botPicUrl}
onChange={(event) => setBotPicUrl(event.target.value)}
placeholder="https://cdn.example.com/avatar.png"
className="rounded-xl"
/>
{botPicUrl.trim() ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={botPicUrl.trim()}
alt="Аватар бота"
className="h-16 w-16 rounded-full border border-[#dce3ec] object-cover"
onError={(event) => {
event.currentTarget.style.display = 'none';
}}
/>
) : null}
</div>
</SectionCard>
<SectionCard
icon={LayoutGrid}
title="Кнопка меню"
description="Глобальная Web App-кнопка слева от поля ввода — /setmenubutton"
>
<div className="space-y-2">
<label className="text-sm font-medium">URL Mini App</label>
<Input
value={menuButtonUrl}
onChange={(event) => setMenuButtonUrl(event.target.value)}
placeholder="https://app.example.com/webapp"
className="rounded-xl"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Текст кнопки</label>
<Input
value={menuButtonText}
onChange={(event) => setMenuButtonText(event.target.value)}
placeholder="App"
className="rounded-xl"
/>
</div>
<p className="text-xs text-[#667085]">Оставьте URL пустым, чтобы сбросить кнопку меню.</p>
</SectionCard>
<Button className="w-full rounded-xl" disabled={savingProfile} onClick={() => void handleSaveProfile()}>
{savingProfile ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Сохранить профиль
</Button>
</div>
<div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm">
<h2 className="font-semibold">Токен Bot API</h2>
<div className="rounded-2xl bg-[#f4f5f8] px-3 py-3 text-sm text-[#667085]">
Текущий префикс: <span className="font-mono text-[#1f2430]">{tokenPrefix || '—'}</span>
</div>
{newToken ? (
<div className="space-y-2 rounded-2xl border border-[#dce3ec] p-3">
<p className="text-sm font-medium text-red-600">Сохраните новый токен он больше не будет показан</p>
<div className="flex items-center gap-2">
<Input value={newToken} readOnly className="rounded-xl font-mono text-xs" />
<Button type="button" variant="secondary" className="rounded-xl" onClick={() => void copyToken()}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
) : null}
<Button variant="secondary" className="w-full rounded-xl" disabled={revoking} onClick={() => void handleRevokeToken()}>
{revoking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
Перевыпустить токен
</Button>
</div>
<div className="rounded-[20px] border border-dashed border-[#dce3ec] bg-white/70 p-4 text-xs leading-relaxed text-[#667085]">
<p className="mb-2 font-medium text-[#1f2430]">Эквивалент команд BotFather</p>
<ul className="space-y-1">
<li>/setdescription поле «Описание»</li>
<li>/setabouttext поле «О боте»</li>
<li>/setuserpic URL аватара</li>
<li>/setmenubutton URL и текст кнопки меню</li>
</ul>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Suspense } from 'react';
import { Loader2 } from 'lucide-react';
import { BotManageMiniAppContent } from './content';
function BotManageFallback() {
return (
<div className="flex min-h-screen items-center justify-center p-6 text-sm text-[#667085]">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Загрузка Mini App...
</div>
);
}
export default function BotManageMiniAppPage() {
return (
<Suspense fallback={<BotManageFallback />}>
<BotManageMiniAppContent />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import { Copy } from 'lucide-react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger
} from '@/components/ui/context-menu';
interface BotChatMessageContextMenuProps {
text?: string;
onCopy?: () => void;
children: React.ReactNode;
}
export function BotChatMessageContextMenu({ text, onCopy, children }: BotChatMessageContextMenuProps) {
const copyText = text?.trim();
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="min-w-[200px]">
{copyText ? (
<ContextMenuItem
onClick={() => {
void navigator.clipboard.writeText(copyText);
onCopy?.();
}}
>
<Copy className="h-4 w-4" />
Копировать текст
</ContextMenuItem>
) : null}
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import { Loader2 } from 'lucide-react';
import { InlineKeyboardButton, parseInlineKeyboardMarkup } from '@/lib/bot-reply-markup';
import { cn } from '@/lib/utils';
export function buildInlineButtonKey(messageId: number, rowIndex: number, buttonIndex: number) {
return `${messageId}:${rowIndex}:${buttonIndex}`;
}
interface BotInlineKeyboardProps {
markup: unknown;
messageId: number;
loadingButtonKey?: string | null;
disabled?: boolean;
onCallbackClick?: (callbackData: string, buttonKey: string) => void | Promise<void>;
onOpenMiniApp?: (url: string) => void;
onOpenUrl?: (url: string) => void;
}
export function BotInlineKeyboard({
markup,
messageId,
loadingButtonKey = null,
disabled = false,
onCallbackClick,
onOpenMiniApp,
onOpenUrl
}: BotInlineKeyboardProps) {
const rows = parseInlineKeyboardMarkup(markup);
if (!rows.length) return null;
return (
<div className="mt-2 space-y-1.5">
{rows.map((row, rowIndex) => (
<div
key={`row-${rowIndex}`}
className={cn('flex gap-1.5', row.length === 1 ? 'flex-col' : 'flex-row flex-wrap')}
>
{row.map((button, buttonIndex) => (
<InlineKeyboardButtonView
key={`btn-${rowIndex}-${buttonIndex}`}
button={button}
buttonKey={buildInlineButtonKey(messageId, rowIndex, buttonIndex)}
fullWidth={row.length === 1}
loading={loadingButtonKey === buildInlineButtonKey(messageId, rowIndex, buttonIndex)}
disabled={disabled || (loadingButtonKey !== null && loadingButtonKey !== buildInlineButtonKey(messageId, rowIndex, buttonIndex))}
onCallbackClick={onCallbackClick}
onOpenMiniApp={onOpenMiniApp}
onOpenUrl={onOpenUrl}
/>
))}
</div>
))}
</div>
);
}
function InlineKeyboardButtonView({
button,
buttonKey,
fullWidth,
loading,
disabled,
onCallbackClick,
onOpenMiniApp,
onOpenUrl
}: {
button: InlineKeyboardButton;
buttonKey: string;
fullWidth: boolean;
loading: boolean;
disabled: boolean;
onCallbackClick?: (callbackData: string, buttonKey: string) => void | Promise<void>;
onOpenMiniApp?: (url: string) => void;
onOpenUrl?: (url: string) => void;
}) {
const webAppUrl = button.web_app?.url;
const callbackData = button.callback_data ?? button.callbackData;
async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault();
if (disabled || loading) return;
if (webAppUrl) {
if (onOpenMiniApp) {
onOpenMiniApp(webAppUrl);
} else {
window.open(webAppUrl, '_blank', 'noopener,noreferrer');
}
return;
}
if (button.url) {
if (onOpenUrl) {
onOpenUrl(button.url);
} else {
window.open(button.url, '_blank', 'noopener,noreferrer');
}
return;
}
if (callbackData && onCallbackClick) {
await onCallbackClick(callbackData, buttonKey);
}
}
return (
<button
type="button"
disabled={disabled || loading}
className={cn(
'inline-flex min-h-9 items-center justify-center rounded-xl border border-[#dce3ec] bg-[#f4f7fb] px-3 py-2 text-xs font-medium text-[#1f2430] transition',
'hover:bg-[#e8eef6] disabled:cursor-not-allowed disabled:opacity-60',
fullWidth ? 'w-full' : 'min-w-0 flex-1'
)}
onClick={(event) => void handleClick(event)}
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : button.text}
</button>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { BotInlineKeyboard } from '@/components/chat/bot-inline-keyboard';
import { useToast } from '@/components/id/toast-provider';
import { useRealtime } from '@/components/notifications/realtime-provider';
import { submitBotCallback } from '@/lib/api';
const CALLBACK_ANSWER_TIMEOUT_MS = 30_000;
interface BotMessageKeyboardProps {
botRef: string;
messageId: number;
markup: unknown;
token: string | null;
botId?: string;
onOpenMiniApp?: (url: string) => void;
}
export function BotMessageKeyboard({
botRef,
messageId,
markup,
token,
botId,
onOpenMiniApp
}: BotMessageKeyboardProps) {
const { showToast } = useToast();
const { subscribe } = useRealtime();
const [loadingButtonKey, setLoadingButtonKey] = useState<string | null>(null);
const pendingQueriesRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const clearPendingQuery = useCallback((callbackQueryId: string) => {
const timer = pendingQueriesRef.current.get(callbackQueryId);
if (timer) {
clearTimeout(timer);
pendingQueriesRef.current.delete(callbackQueryId);
}
setLoadingButtonKey(null);
}, []);
useEffect(() => {
return () => {
pendingQueriesRef.current.forEach((timer) => clearTimeout(timer));
pendingQueriesRef.current.clear();
};
}, []);
useEffect(() => {
return subscribe((event) => {
if (event.type !== 'bot_callback_answer') return;
const payload = event.payload ?? {};
const callbackQueryId = typeof payload.callbackQueryId === 'string' ? payload.callbackQueryId : null;
if (!callbackQueryId || !pendingQueriesRef.current.has(callbackQueryId)) return;
const eventBotId = typeof payload.botId === 'string' ? payload.botId : null;
if (botId && eventBotId && eventBotId !== botId) return;
clearPendingQuery(callbackQueryId);
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
const showAlert = Boolean(payload.showAlert);
const url = typeof payload.url === 'string' ? payload.url.trim() : '';
if (url && onOpenMiniApp) {
onOpenMiniApp(url);
}
if (text) {
if (showAlert) {
window.alert(text);
} else {
showToast(text);
}
}
});
}, [botId, clearPendingQuery, onOpenMiniApp, showToast, subscribe]);
async function handleCallbackClick(callbackData: string, buttonKey: string) {
if (!token) return;
setLoadingButtonKey(buttonKey);
try {
const response = await submitBotCallback(botRef, messageId, callbackData, token);
const callbackQueryId = response.callbackQueryId;
if (!callbackQueryId) {
setLoadingButtonKey(null);
return;
}
const timer = setTimeout(() => clearPendingQuery(callbackQueryId), CALLBACK_ANSWER_TIMEOUT_MS);
pendingQueriesRef.current.set(callbackQueryId, timer);
} catch (error) {
setLoadingButtonKey(null);
showToast(error instanceof Error ? error.message : 'Не удалось отправить действие');
}
}
return (
<BotInlineKeyboard
markup={markup}
messageId={messageId}
loadingButtonKey={loadingButtonKey}
onCallbackClick={handleCallbackClick}
onOpenMiniApp={onOpenMiniApp}
/>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
interface ChatDeleteMessageDialogProps {
open: boolean;
count: number;
loading?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void | Promise<void>;
}
export function ChatDeleteMessageDialog({ open, count, loading, onOpenChange, onConfirm }: ChatDeleteMessageDialogProps) {
const plural =
count === 1 ? 'это сообщение' : count < 5 ? `${count} сообщения` : `${count} сообщений`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="rounded-[24px] sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>Удалить сообщение?</DialogTitle>
</DialogHeader>
<p className="text-sm text-[#667085]">
{count === 1
? 'Сообщение будет удалено для всех участников чата. Это действие нельзя отменить.'
: `Будут удалены ${plural}. Это действие нельзя отменить.`}
</p>
<div className="mt-6 flex justify-end gap-2">
<Button type="button" variant="secondary" className="rounded-xl" disabled={loading} onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
type="button"
className="rounded-xl bg-red-600 text-white hover:bg-red-700"
disabled={loading}
onClick={() => void onConfirm()}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Удалить'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import { isEmojiId } from '@/lib/emoji-catalog';
import { emojiIdToNative, resolveNativeEmojiContent } from '@/lib/emoji-native-map';
export function ChatEmojiContent({ content, size = 22 }: { content?: string | null; size?: number }) {
if (!content) return null;
if (isEmojiId(content)) {
const native = emojiIdToNative(content);
if (native) {
return (
<span
className="text-[length:var(--emoji-size)] leading-none"
style={{ ['--emoji-size' as string]: `${size + 8}px` }}
>
{native}
</span>
);
}
}
if (/:([a-z0-9-]+):/.test(content)) {
return <span className="whitespace-pre-wrap break-words">{resolveNativeEmojiContent(content)}</span>;
}
return <span className="whitespace-pre-wrap break-words">{content}</span>;
}

View File

@@ -0,0 +1,69 @@
'use client';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
import type { ChatRoom } from '@/lib/api';
import { roomDisplayLabel } from '@/lib/family-chat';
interface ChatForwardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
rooms: ChatRoom[];
viewerUserId: string;
token: string | null;
currentRoomId?: string;
forwarding?: boolean;
onSelectRoom: (roomId: string) => void;
}
export function ChatForwardDialog({
open,
onOpenChange,
rooms,
viewerUserId,
token,
currentRoomId,
forwarding,
onSelectRoom
}: ChatForwardDialogProps) {
const targets = rooms.filter((room) => room.id !== currentRoomId && room.type !== 'BOT');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md rounded-[24px]">
<DialogHeader>
<DialogTitle>Переслать в чат</DialogTitle>
</DialogHeader>
{forwarding ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Пересылка...
</div>
) : (
<div className="max-h-[360px] space-y-1 overflow-y-auto">
{targets.length ? (
targets.map((room) => (
<button
key={room.id}
type="button"
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-left transition hover:bg-[#f4f5f8]"
onClick={() => onSelectRoom(room.id)}
>
<ChatRoomAvatarDisplay room={room} viewerUserId={viewerUserId} token={token} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{roomDisplayLabel(room, viewerUserId)}</p>
<p className="text-xs text-[#667085]">{room.members.length} участников</p>
</div>
</button>
))
) : (
<p className="py-6 text-center text-sm text-[#667085]">Нет доступных чатов для пересылки</p>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { CheckCircle2, Copy, Forward, Pencil, Reply, Trash2 } from 'lucide-react';
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu';
import type { ChatMessage } from '@/lib/api';
import { QUICK_REACTIONS, canDeleteMessage, canEditMessage, getMessageCopyText } from '@/lib/chat-message-utils';
import { cn } from '@/lib/utils';
interface ChatMessageContextMenuProps {
message: ChatMessage;
userId?: string;
isE2E?: boolean;
visibleText?: string;
children: React.ReactNode;
onEdit?: () => void;
onDelete?: () => void;
onReply?: () => void;
onForward?: () => void;
onSelect?: () => void;
onToggleReaction?: (emoji: string) => void;
onCopy?: () => void;
}
export function ChatMessageContextMenu({
message,
userId,
isE2E,
visibleText,
children,
onEdit,
onDelete,
onReply,
onForward,
onSelect,
onToggleReaction,
onCopy
}: ChatMessageContextMenuProps) {
const editable = canEditMessage(message, userId, isE2E);
const deletable = canDeleteMessage(message, userId);
const copyText = getMessageCopyText(message, visibleText);
const disabled = message.isDeleted;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="min-w-[240px]">
{!disabled ? (
<>
<div className="mb-1 flex items-center gap-1 overflow-x-auto px-1 py-1">
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-lg transition hover:bg-[#eef1f6]"
onClick={() => onToggleReaction?.(emoji)}
>
{emoji}
</button>
))}
</div>
<ContextMenuSeparator />
</>
) : null}
{onReply && !disabled ? (
<ContextMenuItem onClick={onReply}>
<Reply className="h-4 w-4" />
Ответить
</ContextMenuItem>
) : null}
{editable ? (
<ContextMenuItem onClick={onEdit}>
<Pencil className="h-4 w-4" />
Изменить
</ContextMenuItem>
) : null}
{copyText ? (
<ContextMenuItem
onClick={() => {
void navigator.clipboard.writeText(copyText);
onCopy?.();
}}
>
<Copy className="h-4 w-4" />
Копировать текст
</ContextMenuItem>
) : null}
{onForward && !disabled && !message.isEncrypted ? (
<ContextMenuItem onClick={onForward}>
<Forward className="h-4 w-4" />
Переслать
</ContextMenuItem>
) : null}
{deletable ? (
<ContextMenuItem className="text-red-600 data-[highlighted]:bg-red-50 data-[highlighted]:text-red-700" onClick={onDelete}>
<Trash2 className="h-4 w-4" />
Удалить
</ContextMenuItem>
) : null}
{onSelect && !disabled ? (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onSelect}>
<CheckCircle2 className="h-4 w-4" />
Выделить
</ContextMenuItem>
</>
) : null}
{!disabled && message.reactions?.length ? (
<div className="px-2 pb-1 pt-2">
<ChatMessageReactions reactions={message.reactions} onToggle={onToggleReaction} compact />
</div>
) : null}
</ContextMenuContent>
</ContextMenu>
);
}
interface SelectableMessageWrapperProps {
selected?: boolean;
selectionMode?: boolean;
align?: 'left' | 'right';
onClick?: () => void;
className?: string;
children: React.ReactNode;
}
export function SelectableMessageWrapper({
selected,
selectionMode,
align = 'left',
onClick,
className,
children
}: SelectableMessageWrapperProps) {
return (
<div
className={cn(
'relative w-fit max-w-[78%] shrink-0',
selectionMode && 'cursor-pointer',
selectionMode && (align === 'right' ? 'mr-7' : 'ml-7'),
className
)}
onClick={selectionMode ? onClick : undefined}
>
{selectionMode ? (
<span
className={cn(
'absolute top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full border text-[10px] shadow-sm transition',
align === 'right' ? 'right-0 translate-x-[calc(100%+8px)]' : 'left-0 -translate-x-[calc(100%+8px)]',
selected ? 'border-[#3390ec] bg-[#3390ec] text-white' : 'border-[#dce3ec] bg-white text-transparent'
)}
>
</span>
) : null}
<div
className={cn(
'rounded-[18px] transition',
selected && 'ring-2 ring-[#3390ec] ring-offset-2 ring-offset-[#eef1f6]'
)}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import { Pencil, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ChatMessageEditBannerProps {
preview?: string;
onCancel: () => void;
}
export function ChatMessageEditBanner({ preview, onCancel }: ChatMessageEditBannerProps) {
return (
<div className="mb-2 flex items-center gap-3 rounded-xl border border-[#3390ec]/20 bg-[#eef4ff] px-3 py-2 animate-in fade-in slide-in-from-bottom-1 duration-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#3390ec]/15 text-[#3390ec]">
<Pencil className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-[#3390ec]">Редактирование сообщения</p>
{preview ? <p className="truncate text-xs text-[#667085]">{preview}</p> : null}
</div>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-lg" aria-label="Отменить редактирование" onClick={onCancel}>
<X className="h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import type { ChatMessageReaction } from '@/lib/api';
import { cn } from '@/lib/utils';
interface ChatMessageReactionsProps {
reactions?: ChatMessageReaction[];
onToggle?: (emoji: string) => void;
compact?: boolean;
}
export function ChatMessageReactions({ reactions, onToggle, compact }: ChatMessageReactionsProps) {
if (!reactions?.length) return null;
return (
<div className={cn('flex flex-wrap gap-1', compact ? 'mt-1' : 'mt-1.5')}>
{reactions.map((reaction) => (
<button
key={reaction.emoji}
type="button"
onClick={() => onToggle?.(reaction.emoji)}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition',
reaction.reactedByMe
? 'border-[#3390ec]/40 bg-[#3390ec]/15 text-[#1f2430]'
: 'border-[#dce3ec] bg-white/80 text-[#667085] hover:bg-white'
)}
>
<span>{reaction.emoji}</span>
<span>{reaction.count}</span>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { Copy, Forward, Loader2, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ChatSelectionToolbarProps {
count: number;
deleting?: boolean;
onCancel: () => void;
onCopy: () => void;
onForward: () => void;
onDelete: () => void;
}
export function ChatSelectionToolbar({ count, deleting, onCancel, onCopy, onForward, onDelete }: ChatSelectionToolbarProps) {
return (
<div className="flex items-center gap-2 border-t border-[#dce3ec] bg-white px-4 py-3 shadow-[0_-8px_24px_rgba(31,36,48,0.08)] animate-in fade-in slide-in-from-bottom-2 duration-200">
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0 rounded-xl" onClick={onCancel}>
<X className="h-4 w-4" />
</Button>
<p className="min-w-0 flex-1 text-sm font-medium">{count} выбрано</p>
<Button type="button" variant="secondary" size="sm" className="rounded-xl" onClick={onCopy}>
<Copy className="mr-1 h-4 w-4" />
Копировать
</Button>
<Button type="button" variant="secondary" size="sm" className="rounded-xl" onClick={onForward}>
<Forward className="mr-1 h-4 w-4" />
Переслать
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="rounded-xl text-red-600 hover:text-red-700"
disabled={deleting}
onClick={onDelete}
>
{deleting ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Trash2 className="mr-1 h-4 w-4" />}
Удалить
</Button>
</div>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { EMOJI_CATEGORIES, EMOJI_DEFINITIONS, searchEmojis, type EmojiCategoryId } from '@/lib/emoji-catalog';
import { emojiIdToNative } from '@/lib/emoji-native-map';
import { cn } from '@/lib/utils';
interface EmojiPickerProps {
onSelect: (nativeEmoji: string) => void;
className?: string;
}
export function EmojiPicker({ onSelect, className }: EmojiPickerProps) {
const [category, setCategory] = useState<EmojiCategoryId>('smileys');
const [query, setQuery] = useState('');
const items = useMemo(() => {
const filtered = searchEmojis(query);
if (query.trim()) return filtered;
return filtered.filter((item) => item.category === category);
}, [category, query]);
return (
<div className={cn('rounded-2xl border border-[#dce3ec] bg-white shadow-lg', className)}>
<div className="border-b border-[#eceef4] p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#a8adbc]" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск смайликов"
className="rounded-xl pl-9"
/>
</div>
</div>
{!query.trim() ? (
<div className="flex gap-1 overflow-x-auto border-b border-[#eceef4] px-2 py-2">
{EMOJI_CATEGORIES.map((item) => {
const native = emojiIdToNative(item.icon) ?? '✨';
return (

View File

@@ -0,0 +1,25 @@
'use client';
import { useEffect } from 'react';
let spriteInjected = false;
export function EmojiSpriteProvider() {
useEffect(() => {
if (spriteInjected || typeof document === 'undefined') return;
fetch('/emojis/lendry-emojis.svg')
.then((response) => response.text())
.then((markup) => {
if (spriteInjected) return;
const container = document.createElement('div');
container.innerHTML = markup;
container.style.display = 'none';
container.setAttribute('aria-hidden', 'true');
document.body.appendChild(container);
spriteInjected = true;
})
.catch(() => {});
}, []);
return null;
}

View File

@@ -0,0 +1,27 @@
'use client';
import { cn } from '@/lib/utils';
export const EMOJI_SPRITE_PATH = '/emojis/lendry-emojis.svg';
interface EmojiSpriteProps {
id: string;
size?: number;
className?: string;
title?: string;
}
export function EmojiSprite({ id, size = 24, className, title }: EmojiSpriteProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 36 36"
className={cn('inline-block shrink-0 align-middle', className)}
role="img"
aria-label={title}
>
<use href={`${EMOJI_SPRITE_PATH}#${id}`} xlinkHref={`${EMOJI_SPRITE_PATH}#${id}`} />
</svg>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Loader2, Pencil } from 'lucide-react';
import { cn } from '@/lib/utils';
interface InlineEditableTitleProps {
value: string;
onSave: (nextValue: string) => Promise<void> | void;
className?: string;
inputClassName?: string;
disabled?: boolean;
}
export function InlineEditableTitle({
value,
onSave,
className,
inputClassName,
disabled = false
}: InlineEditableTitleProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [saving, setSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!editing) setDraft(value);
}, [editing, value]);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
async function commit() {
const trimmed = draft.trim();
if (!trimmed || trimmed === value) {
setDraft(value);
setEditing(false);
return;
}
setSaving(true);
try {
await onSave(trimmed);
setEditing(false);
} finally {
setSaving(false);
}
}
if (editing) {
return (
<div className={cn('relative min-w-0', className)}>
<input
ref={inputRef}
value={draft}
disabled={saving}
onChange={(event) => setDraft(event.target.value)}
onBlur={() => void commit()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void commit();
}
if (event.key === 'Escape') {
setDraft(value);
setEditing(false);
}
}}
className={cn(
'w-full border-0 bg-transparent p-0 text-base font-semibold leading-tight text-[#1f2430] outline-none ring-0',
inputClassName
)}
/>
{saving ? <Loader2 className="absolute -right-5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 animate-spin text-[#667085]" /> : null}
</div>
);
}
return (
<div className={cn('group/title flex min-w-0 items-center gap-1.5', className)}>
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setEditing(true)}
className="min-w-0 truncate text-left text-base font-semibold leading-tight text-[#1f2430] transition hover:text-[#3390ec] disabled:cursor-default disabled:hover:text-[#1f2430]"
>
{value}
</button>
{!disabled ? (
<button
type="button"
aria-label="Изменить название"
onClick={() => setEditing(true)}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-[#667085] opacity-0 transition hover:bg-[#f4f5f8] hover:text-[#3390ec] group-hover/title:opacity-100"
>
<Pencil className="h-3.5 w-3.5" />
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { cn } from '@/lib/utils';
interface RepliedMessagePreviewProps {
senderName: string;
preview: string;
accentClassName?: string;
onClick?: () => void;
className?: string;
}
export function RepliedMessagePreview({
senderName,
preview,
accentClassName = 'bg-[#3390ec]',
onClick,
className
}: RepliedMessagePreviewProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'mb-2 flex w-full items-stretch gap-2 rounded-xl bg-black/[0.04] px-2 py-1.5 text-left transition hover:bg-black/[0.07]',
className
)}
>
<span className={cn('w-0.5 shrink-0 rounded-full', accentClassName)} aria-hidden />
<span className="min-w-0">
<span className="block truncate text-xs font-semibold text-[#3390ec]">{senderName}</span>
<span className="block truncate text-xs text-[#667085]">{preview || 'Сообщение'}</span>
</span>
</button>
);
}
export function scrollToChatMessage(messageId: string) {
const element = document.getElementById(`msg-${messageId}`);
if (!element) return false;
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('blink-highlight');
window.setTimeout(() => element.classList.remove('blink-highlight'), 2000);
return true;
}

View File

@@ -0,0 +1,48 @@
'use client';
import { cn } from '@/lib/utils';
interface TypingIndicatorProps {
roomType?: string;
typers: Array<{ userId: string; userName: string }>;
className?: string;
}
function isGroupRoomType(type?: string) {
return type === 'GROUP' || type === 'GENERAL';
}
export function TypingIndicator({ roomType, typers, className }: TypingIndicatorProps) {
if (!typers.length) return null;
const group = isGroupRoomType(roomType);
let label = 'печатает';
if (group) {
if (typers.length === 1) {
label = `${typers[0]!.userName} печатает`;
} else if (typers.length === 2) {
label = `${typers[0]!.userName} и ${typers[1]!.userName} печатают`;
} else {
label = `${typers.length} участника печатают`;
}
}
return (
<div
className={cn(
'flex items-center gap-2 px-4 py-1 text-xs text-[#667085] transition-all duration-300 animate-in fade-in slide-in-from-bottom-1',
className
)}
>
<span className="inline-flex items-center gap-1">
<span className="inline-flex gap-0.5">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#3390ec]/70 [animation-delay:0ms]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#3390ec]/70 [animation-delay:120ms]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#3390ec]/70 [animation-delay:240ms]" />
</span>
</span>
<span>{group ? label : 'печатает…'}</span>
</div>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import { Bot } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { UserAvatar } from '@/components/id/user-avatar';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ChatRoom, apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
interface ChatRoomAvatarDisplayProps {
room: ChatRoom;
viewerUserId: string;
token: string | null;
className?: string;
size?: 'sm' | 'md';
}
function sizeClass(size: 'sm' | 'md') {
return size === 'sm' ? 'h-9 w-9 text-xs' : 'h-11 w-11 text-sm';
}
export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, size = 'md' }: ChatRoomAvatarDisplayProps) {
const [roomAvatarUrl, setRoomAvatarUrl] = useState<string | null>(null);
const peerMember = useMemo(
() =>
room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT'
? room.members.find((member) => member.userId !== viewerUserId)
: undefined,
[room.members, room.type, viewerUserId]
);
useEffect(() => {
if (!token || !room.hasAvatar || room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT') {
setRoomAvatarUrl(null);
return;
}
apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token)
.then((response) => setRoomAvatarUrl(response.accessUrl))
.catch(() => setRoomAvatarUrl(null));
}, [room.hasAvatar, room.id, room.type, token]);
if (peerMember && (room.type === 'DIRECT' || room.type === 'E2E')) {
return (
<UserAvatar
userId={peerMember.userId}
displayName={peerMember.displayName}
hasAvatar={peerMember.hasAvatar}
token={token}
isVerified={peerMember.isVerified}
verificationIcon={peerMember.verificationIcon}
className={cn(sizeClass(size), className)}
badgeSize="xs"
/>
);
}
if (room.type === 'BOT') {
return (
<Avatar className={cn(sizeClass(size), className)}>
<AvatarFallback className="bg-[#3390ec]/15 text-[#3390ec]">
<Bot className={size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'} />
</AvatarFallback>
</Avatar>
);
}
if (room.hasAvatar && roomAvatarUrl) {
return (
<Avatar className={cn(sizeClass(size), className)}>
<AvatarImage src={roomAvatarUrl} alt={room.name} />
<AvatarFallback>{room.name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);
}
return (
<Avatar className={cn(sizeClass(size), className)}>
<AvatarFallback className={room.type === 'GENERAL' ? 'bg-[#3390ec]/15 text-[#3390ec]' : undefined}>
{room.type === 'GENERAL' ? 'В' : room.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
);
}

View File

@@ -0,0 +1,309 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { LayoutGrid, Loader2, Send, X } from 'lucide-react';
import { BotMessageKeyboard } from '@/components/chat/bot-message-keyboard';
import { BotChatMessageContextMenu } from '@/components/chat/bot-chat-message-context-menu';
import { useRealtime } from '@/components/notifications/realtime-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
BotChatMessage,
BotChatMeta,
fetchBotChatMessages,
sendBotMessage
} from '@/lib/api';
import { extractMessageReplyMarkup, serializeInlineKeyboardMarkup } from '@/lib/bot-reply-markup';
import { resolveComposerMenuButton, type ComposerMenuButton } from '@/lib/bot-menu-button';
import { cn } from '@/lib/utils';
import { useToast } from '@/components/id/toast-provider';
function formatMessageTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
}
function resolveBotRef(botRef: string, payloadBotUsername?: unknown) {
if (typeof payloadBotUsername === 'string' && payloadBotUsername.trim()) {
return payloadBotUsername.trim();
}
return botRef;
}
function markupJsonFromPayload(payload: Record<string, unknown>) {
const rows = extractMessageReplyMarkup({
replyMarkup: payload.replyMarkup,
telegramReplyMarkup: payload.telegramReplyMarkup,
reply_markup: payload.reply_markup
});
return serializeInlineKeyboardMarkup(rows);
}
interface FamilyBotChatProps {
botRef: string;
botName: string;
token: string | null;
className?: string;
inputPlaceholder?: string;
onOpenMiniApp?: (url: string) => void;
onBotMetaChange?: (meta: BotChatMeta) => void;
}
export function FamilyBotChat({
botRef,
botName,
token,
className,
inputPlaceholder = 'Сообщение боту',
onOpenMiniApp,
onBotMetaChange
}: FamilyBotChatProps) {
const [messages, setMessages] = useState<BotChatMessage[]>([]);
const [draft, setDraft] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [composerMenuButton, setComposerMenuButton] = useState<ComposerMenuButton | null>(null);
const [internalMiniAppUrl, setInternalMiniAppUrl] = useState<string | null>(null);
const [botMeta, setBotMeta] = useState<BotChatMeta | null>(null);
const { subscribe } = useRealtime();
const { showToast } = useToast();
const openMiniApp = useCallback(
(url: string) => {
if (onOpenMiniApp) {
onOpenMiniApp(url);
return;
}
setInternalMiniAppUrl(url);
},
[onOpenMiniApp]
);
const loadMessages = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const response = await fetchBotChatMessages(botRef, token);
setMessages(response.messages ?? []);
const menuButton = resolveComposerMenuButton({
composerMenuButtonJson: response.composerMenuButtonJson,
composerWebAppUrl: response.composerWebAppUrl
});
setComposerMenuButton(menuButton);
const meta: BotChatMeta = {
botId: response.botId,
botOwnerId: response.botOwnerId,
botUsername: response.botUsername,
botDisplayName: response.botDisplayName,
composerWebAppUrl: menuButton?.web_app.url ?? (response.composerWebAppUrl?.trim() || null),
composerMenuButtonJson: response.composerMenuButtonJson ?? (menuButton ? JSON.stringify(menuButton) : null),
manageWebAppUrl: response.manageWebAppUrl
};
setBotMeta(meta);
onBotMetaChange?.(meta);
} finally {
setLoading(false);
}
}, [botRef, onBotMetaChange, token]);
useEffect(() => {
void loadMessages();
}, [loadMessages]);
useEffect(() => {
return subscribe((event) => {
const payload = event.payload ?? {};
const eventBotRef = resolveBotRef(botRef, payload.botUsername);
if (eventBotRef !== botRef && eventBotRef.replace(/_bot$/i, '') !== botRef.replace(/_bot$/i, '')) {
return;
}
if (event.type === 'bot_message') {
const messageId = Number(payload.messageId);
if (!Number.isFinite(messageId)) {
void loadMessages();
return;
}
const replyMarkupJson = markupJsonFromPayload(payload);
const outbound: BotChatMessage = {
id: `out-ws-${messageId}`,
direction: 'out',
text: typeof payload.text === 'string' ? payload.text : '',
messageType: typeof payload.messageType === 'string' ? payload.messageType : 'text',
messageId,
createdAt: new Date().toISOString(),
replyMarkupJson
};
setMessages((current) => {
const withoutDuplicate = current.filter((item) => item.messageId !== messageId || item.direction !== 'out');
return [...withoutDuplicate, outbound].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
});
return;
}
if (event.type === 'bot_menu_button_updated') {
const menuButton = resolveComposerMenuButton({
composerMenuButtonJson:
typeof payload.composerMenuButtonJson === 'string' ? payload.composerMenuButtonJson : null,
composerWebAppUrl: typeof payload.composerWebAppUrl === 'string' ? payload.composerWebAppUrl : null
});
setComposerMenuButton(menuButton);
setBotMeta((current) =>
current
? {
...current,
composerWebAppUrl: menuButton?.web_app.url ?? null,
composerMenuButtonJson: menuButton ? JSON.stringify(menuButton) : null
}
: current
);
return;
}
if (event.type === 'bot_message_edited') {
const messageId = Number(payload.messageId);
if (!Number.isFinite(messageId)) {
void loadMessages();
return;
}
const replyMarkupJson = markupJsonFromPayload(payload);
setMessages((current) =>
current.map((item) =>
item.messageId === messageId && item.direction === 'out'
? {
...item,
text: typeof payload.text === 'string' ? payload.text : item.text,
replyMarkupJson: replyMarkupJson ?? item.replyMarkupJson ?? null
}
: item
)
);
}
});
}, [botRef, loadMessages, subscribe]);
async function handleSend() {
if (!token || !draft.trim() || sending) return;
const text = draft.trim();
setSending(true);
setDraft('');
setMessages((current) => [
...current,
{
id: `in-local-${Date.now()}`,
direction: 'in',
text,
messageType: 'text',
messageId: 0,
createdAt: new Date().toISOString()
}
]);
try {
await sendBotMessage(botRef, text, token);
await loadMessages();
} finally {
setSending(false);
}
}
return (
<div className={cn('flex min-h-0 flex-1 flex-col', className)}>
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-4">
{loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка...
</div>
) : messages.length ? (
messages.map((message) => {
const mine = message.direction === 'in';
const hasKeyboard = Boolean(message.replyMarkupJson?.trim());
return (
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
<BotChatMessageContextMenu text={message.text} onCopy={() => showToast('Текст скопирован')}>
<div className={cn('max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm', mine ? 'rounded-br-md bg-[#effdde]' : 'rounded-bl-md bg-white')}>
{!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{botName}</p> : null}
<p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p>
{!mine && hasKeyboard && message.messageId > 0 ? (
<BotMessageKeyboard
botRef={botRef}
botId={botMeta?.botId}
messageId={message.messageId}
markup={message.replyMarkupJson}
token={token}
onOpenMiniApp={openMiniApp}
/>
) : null}
<p className="mt-1 text-[10px] text-[#a8adbc]">{formatMessageTime(message.createdAt)}</p>
</div>
</BotChatMessageContextMenu>
</div>
);
})
) : (
<p className="py-8 text-center text-sm text-[#667085]">Напишите /help, чтобы начать</p>
)}
</div>
<div className="flex items-center gap-2 border-t border-[#dce3ec] bg-white px-4 py-3">
{composerMenuButton ? (
<Button
type="button"
variant="secondary"
className="h-10 shrink-0 rounded-full border border-[#3390ec]/20 bg-[#3390ec]/10 px-3 text-[#3390ec] transition hover:bg-[#3390ec]/15"
aria-label={composerMenuButton.text}
title={composerMenuButton.text}
onClick={() => openMiniApp(composerMenuButton.web_app.url)}
>
<LayoutGrid className="mr-1.5 h-4 w-4" />
<span className="max-w-[96px] truncate text-xs font-medium">{composerMenuButton.text}</span>
</Button>
) : null}
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={inputPlaceholder}
className="rounded-xl"
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}}
/>
<Button className="h-10 w-10 shrink-0 rounded-full p-0" disabled={sending || !draft.trim()} onClick={() => void handleSend()}>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
<BotMiniAppSheet
url={internalMiniAppUrl}
title={composerMenuButton?.text ?? 'Mini App'}
onClose={() => setInternalMiniAppUrl(null)}
/>
</div>
);
}
interface BotMiniAppSheetProps {
url: string | null;
title?: string;
onClose: () => void;
}
export function BotMiniAppSheet({ url, title = 'Mini App', onClose }: BotMiniAppSheetProps) {
if (!url) return null;
return (
<div className="fixed inset-0 z-[70] flex items-end justify-center bg-black/40 p-4 sm:items-center">
<div className="flex h-[min(88vh,720px)] w-full max-w-[520px] flex-col overflow-hidden rounded-[24px] bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-[#eceef4] px-4 py-3">
<p className="font-semibold">{title}</p>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" aria-label="Закрыть" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<iframe src={url} title={title} className="min-h-0 flex-1 border-0" allow="clipboard-read; clipboard-write" />
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,34 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Bot, Loader2 } from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { FamilyInviteCandidate, getApiErrorMessage, searchFamilyInviteUsers, sendFamilyInvite } from '@/lib/api';
import {
FamilyGroup,
FamilyInviteCandidate,
ManagedBot,
addBotFatherToFamily,
addBotToFamily,
fetchMyBots,
getApiErrorMessage,
searchFamilyInviteUsers,
sendFamilyInvite
} from '@/lib/api';
export function FamilyInviteDialog({
groupId,
group,
open,
onOpenChange,
onInvited
}: {
groupId: string;
group?: FamilyGroup | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onInvited?: () => void;
@@ -28,14 +40,39 @@ export function FamilyInviteDialog({
const [inviteSearching, setInviteSearching] = useState(false);
const [selectedInviteUser, setSelectedInviteUser] = useState<FamilyInviteCandidate | null>(null);
const [submitting, setSubmitting] = useState(false);
const [myBots, setMyBots] = useState<ManagedBot[]>([]);
const [myBotsLoading, setMyBotsLoading] = useState(false);
const [addingBotId, setAddingBotId] = useState<string | null>(null);
const familyBotUsernames = useMemo(
() => new Set((group?.members ?? []).map((member) => member.botUsername).filter(Boolean) as string[]),
[group?.members]
);
const availableMyBots = useMemo(
() => myBots.filter((bot) => !familyBotUsernames.has(bot.username)),
[familyBotUsernames, myBots]
);
useEffect(() => {
if (!open) {
setInviteQuery('');
setInviteResults([]);
setSelectedInviteUser(null);
setMyBots([]);
return;
}
if (!token) return;
setMyBotsLoading(true);
fetchMyBots(token)
.then((response) => setMyBots(response.bots ?? []))
.catch(() => setMyBots([]))
.finally(() => setMyBotsLoading(false));
}, [open, token]);
useEffect(() => {
if (!open) return;
if (!token || inviteQuery.trim().length < 2) {
setInviteResults([]);
return;
@@ -52,21 +89,71 @@ export function FamilyInviteDialog({
return () => window.clearTimeout(timer);
}, [groupId, inviteQuery, open, token]);
async function addBotById(botId: string, displayName: string) {
if (!token) return;
setAddingBotId(botId);
try {
await addBotToFamily(groupId, botId, token);
showToast(`${displayName} добавлен в семью`);
onOpenChange(false);
onInvited?.();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось добавить бота') ?? 'Ошибка');
} finally {
setAddingBotId(null);
}
}
async function submitInvite() {
if (!token || !selectedInviteUser) return;
setSubmitting(true);
try {
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
showToast('Приглашение отправлено');
if (selectedInviteUser.isBot) {
if (selectedInviteUser.botId) {
await addBotToFamily(groupId, selectedInviteUser.botId, token);
showToast(`${selectedInviteUser.displayName} добавлен в семью`);
} else {
await addBotFatherToFamily(groupId, token);
showToast('BotFather добавлен в семью');
}
} else {
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
showToast('Приглашение отправлено');
}
onOpenChange(false);
onInvited?.();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить приглашение') ?? 'Ошибка');
showToast(
getApiErrorMessage(
error,
selectedInviteUser.isBot ? 'Не удалось добавить бота' : 'Не удалось отправить приглашение'
) ?? 'Ошибка'
);
} finally {
setSubmitting(false);
}
}
function botCandidateLabel(candidate: FamilyInviteCandidate) {
if (!candidate.isBot) {
return candidate.email ?? candidate.phone ?? candidate.username ?? 'Без контактов';
}
const handle = `@${candidate.botUsername ?? 'bot'}`;
if (candidate.ownerDisplayName) {
return `${handle} · владелец: ${candidate.ownerDisplayName}`;
}
if (candidate.botUsername === 'BotFather_bot') {
return `${handle} — создавайте ботов через чат`;
}
return handle;
}
function submitButtonLabel() {
if (!selectedInviteUser?.isBot) return 'Отправить приглашение';
if (selectedInviteUser.botId) return 'Добавить бота';
return 'Добавить BotFather';
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="rounded-[28px] sm:max-w-[420px]">
@@ -75,33 +162,70 @@ export function FamilyInviteDialog({
</DialogHeader>
{selectedInviteUser ? (
<div className="rounded-2xl bg-[#f4f5f8] px-4 py-3">
<p className="font-medium">{selectedInviteUser.displayName}</p>
<p className="text-sm text-[#667085]">
{selectedInviteUser.email ?? selectedInviteUser.phone ?? selectedInviteUser.username ?? 'Контакт не указан'}
<p className="font-medium">
{selectedInviteUser.displayName}
{selectedInviteUser.isBot ? ' 🤖' : ''}
</p>
<p className="text-sm text-[#667085]">{botCandidateLabel(selectedInviteUser)}</p>
<button type="button" className="mt-2 text-sm text-[#667085] underline" onClick={() => setSelectedInviteUser(null)}>
Выбрать другого пользователя
Выбрать другого
</button>
</div>
) : (
<>
<Input
placeholder="ФИО, почта, телефон или логин"
placeholder="ФИО, почта, телефон, логин или @бот"
value={inviteQuery}
onChange={(event) => setInviteQuery(event.target.value)}
/>
{inviteQuery.trim().length < 2 ? (
<div className="mt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-[#667085]">Мои боты</p>
<div className="max-h-40 overflow-y-auto rounded-2xl border border-[#eceef4]">
{myBotsLoading ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка ботов...
</div>
) : availableMyBots.length ? (
availableMyBots.map((bot) => (
<button
key={bot.id}
type="button"
disabled={addingBotId === bot.id}
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd] disabled:opacity-60"
onClick={() => void addBotById(bot.id, bot.name)}
>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
{addingBotId === bot.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bot className="h-4 w-4" />}
</div>
<div className="min-w-0">
<p className="truncate font-medium">{bot.name}</p>
<p className="truncate text-sm text-[#667085]">@{bot.username}</p>
</div>
</button>
))
) : (
<p className="px-4 py-3 text-sm text-[#667085]">
{myBots.length ? 'Все ваши боты уже в семье' : 'У вас пока нет ботов. Создайте через BotFather.'}
</p>
)}
</div>
<p className="mt-2 text-xs text-[#667085]">Или найдите пользователя / бота другого владельца через поиск выше</p>
</div>
) : null}
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">
{inviteSearching ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Ищем пользователей...
Ищем...
</div>
) : inviteQuery.trim().length < 2 ? (
<p className="px-4 py-3 text-sm text-[#667085]">Введите минимум 2 символа для поиска</p>
<p className="px-4 py-3 text-sm text-[#667085]">Введите минимум 2 символа для поиска пользователей и ботов</p>
) : inviteResults.length ? (
inviteResults.map((candidate) => (
<button
key={candidate.id}
key={`${candidate.id}-${candidate.botId ?? 'user'}`}
type="button"
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd]"
onClick={() => {
@@ -109,31 +233,38 @@ export function FamilyInviteDialog({
setInviteResults([]);
}}
>
<AvatarWithPresence
userId={candidate.id}
displayName={candidate.displayName}
hasAvatar={candidate.hasAvatar}
token={token}
isVerified={candidate.isVerified}
verificationIcon={candidate.verificationIcon}
className="h-9 w-9"
/>
{candidate.isBot ? (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
<Bot className="h-4 w-4" />
</div>
) : (
<AvatarWithPresence
userId={candidate.id}
displayName={candidate.displayName}
hasAvatar={candidate.hasAvatar}
token={token}
isVerified={candidate.isVerified}
verificationIcon={candidate.verificationIcon}
className="h-9 w-9"
/>
)}
<div className="min-w-0">
<p className="truncate font-medium">{candidate.displayName}</p>
<p className="truncate text-sm text-[#667085]">
{candidate.email ?? candidate.phone ?? candidate.username ?? 'Без контактов'}
<p className="truncate font-medium">
{candidate.displayName}
{candidate.isBot ? ' 🤖' : ''}
</p>
<p className="truncate text-sm text-[#667085]">{botCandidateLabel(candidate)}</p>
</div>
</button>
))
) : (
<p className="px-4 py-3 text-sm text-[#667085]">Пользователи не найдены</p>
<p className="px-4 py-3 text-sm text-[#667085]">Ничего не найдено</p>
)}
</div>
</>
)}
<Button className="mt-4 w-full rounded-xl" disabled={!selectedInviteUser || submitting} onClick={() => void submitInvite()}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Отправить приглашение'}
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : submitButtonLabel()}
</Button>
</DialogContent>
</Dialog>

View File

@@ -7,11 +7,11 @@ const SELECTED_FAMILY_STORAGE_KEY = 'id-selected-family-group-id';
type FamilyOverlayContextValue = {
openMiniChat: () => void;
openChatRoom: (roomId: string) => void;
openChatWithMember: (memberUserId: string, memberName: string) => void;
openChatWithMember: (memberUserId: string, memberName: string, options?: { isBot?: boolean; botUsername?: string }) => void;
closeMiniChat: () => void;
miniChatOpen: boolean;
pendingRoomId: string | null;
pendingMember: { userId: string; name: string } | null;
pendingMember: { userId: string; name: string; isBot?: boolean; botUsername?: string } | null;
clearPending: () => void;
selectedGroupId: string | null;
setSelectedGroupId: (groupId: string | null) => void;
@@ -22,9 +22,9 @@ const FamilyOverlayContext = createContext<FamilyOverlayContextValue | null>(nul
export function FamilyOverlayProvider({ children }: { children: React.ReactNode }) {
const [miniChatOpen, setMiniChatOpen] = useState(false);
const [pendingRoomId, setPendingRoomId] = useState<string | null>(null);
const [pendingMember, setPendingMember] = useState<{ userId: string; name: string } | null>(null);
const [pendingMember, setPendingMember] = useState<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null);
const [selectedGroupId, setSelectedGroupIdState] = useState<string | null>(null);
const pendingMemberRef = useRef<{ userId: string; name: string } | null>(null);
const pendingMemberRef = useRef<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null);
const selectionHydratedRef = useRef(false);
useEffect(() => {
@@ -58,13 +58,16 @@ export function FamilyOverlayProvider({ children }: { children: React.ReactNode
setMiniChatOpen(true);
}, []);
const openChatWithMember = useCallback((memberUserId: string, memberName: string) => {
const payload = { userId: memberUserId, name: memberName };
setPendingMember(payload);
pendingMemberRef.current = payload;
setPendingRoomId(null);
setMiniChatOpen(true);
}, []);
const openChatWithMember = useCallback(
(memberUserId: string, memberName: string, options?: { isBot?: boolean; botUsername?: string }) => {
const payload = { userId: memberUserId, name: memberName, ...options };
setPendingMember(payload);
pendingMemberRef.current = payload;
setPendingRoomId(null);
setMiniChatOpen(true);
},
[]
);
const clearPending = useCallback(() => {
setPendingRoomId(null);

View File

@@ -2,14 +2,16 @@
import Link from 'next/link';
import { useState } from 'react';
import { Loader2, Search, UsersRound } from 'lucide-react';
import { Loader2, Plus, Search, UsersRound } from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { FamilyGroupSelector } from '@/components/family/family-group-selector';
import { FamilyInviteDialog } from '@/components/family/family-invite-dialog';
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
import { useAuth } from '@/components/id/auth-provider';
import { useSelectedFamily } from '@/hooks/use-primary-family';
import { addBotFatherToFamily, getApiErrorMessage } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/id/toast-provider';
import { cn } from '@/lib/utils';
export function FamilySidebarPanel() {
@@ -25,7 +27,9 @@ export function FamilySidebarPanel() {
refresh
} = useSelectedFamily(Boolean(user && token));
const { openChatWithMember } = useFamilyOverlay();
const { showToast } = useToast();
const [inviteOpen, setInviteOpen] = useState(false);
const [addingBotFather, setAddingBotFather] = useState(false);
if (!user || !token) return null;
@@ -81,7 +85,12 @@ export function FamilySidebarPanel() {
'flex w-full items-center gap-2 rounded-xl px-2 py-1.5 text-left transition hover:bg-[#f4f5f8]',
isSelf && 'bg-[#fafbfd]'
)}
onClick={() => openChatWithMember(member.userId, member.displayName)}
onClick={() =>
openChatWithMember(member.userId, member.displayName, {
isBot: member.isBot,
botUsername: member.botUsername
})
}
title={isSelf ? 'Вы' : `Написать ${member.displayName}`}
>
<AvatarWithPresence
@@ -97,17 +106,44 @@ export function FamilySidebarPanel() {
<div className="min-w-0 flex-1">
<p className="truncate text-[12px] font-medium leading-tight">
{member.displayName}
{member.isBot ? ' 🤖' : ''}
{isSelf ? ' (Вы)' : ''}
</p>
{!isSelf ? (
<p className={cn('truncate text-[10px]', presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}>
{presence?.online ? 'В сети' : 'Не в сети'}
<p className={cn('truncate text-[10px]', member.isBot ? 'text-[#3390ec]' : presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}>
{member.isBot ? 'Бот' : presence?.online ? 'В сети' : 'Не в сети'}
</p>
) : null}
</div>
</button>
);
})}
{group?.botFatherAvailable ? (
<button
type="button"
disabled={addingBotFather}
className="flex w-full items-center gap-2 rounded-xl border border-dashed border-[#d5dbe8] px-2 py-2 text-left transition hover:bg-[#f4f5f8] disabled:opacity-60"
onClick={() => {
if (!group || !token) return;
setAddingBotFather(true);
void addBotFatherToFamily(group.id, token)
.then(() => {
showToast('BotFather добавлен в семью');
void refresh();
})
.catch((error) => showToast(getApiErrorMessage(error, 'Не удалось добавить BotFather') ?? 'Ошибка'))
.finally(() => setAddingBotFather(false));
}}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
{addingBotFather ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[12px] font-medium leading-tight">Добавить BotFather 🤖</p>
<p className="truncate text-[10px] text-[#667085]">Создавайте ботов через чат</p>
</div>
</button>
) : null}
</div>
) : (
<div className="rounded-xl bg-[#f4f5f8] px-2 py-3 text-[11px] text-[#667085]">
@@ -120,6 +156,7 @@ export function FamilySidebarPanel() {
{hasFamily ? (
<FamilyInviteDialog
groupId={group!.id}
group={group}
open={inviteOpen}
onOpenChange={setInviteOpen}
onInvited={() => void refresh()}

View File

@@ -0,0 +1,62 @@
'use client';
import { Camera, Loader2 } from 'lucide-react';
import { useId } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
interface HoverUploadAvatarProps {
name: string;
imageUrl?: string | null;
uploading?: boolean;
onFileSelect: (file: File) => void;
className?: string;
fallbackClassName?: string;
}
export function HoverUploadAvatar({
name,
imageUrl,
uploading = false,
onFileSelect,
className,
fallbackClassName
}: HoverUploadAvatarProps) {
const inputId = useId();
return (
<label
htmlFor={inputId}
className={cn(
'group/avatar relative block cursor-pointer overflow-hidden rounded-full transition',
uploading && 'pointer-events-none',
className
)}
>
<Avatar className="h-full w-full">
{imageUrl ? <AvatarImage src={imageUrl} alt={name} /> : null}
<AvatarFallback className={fallbackClassName}>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span
className={cn(
'absolute inset-0 flex items-center justify-center bg-black/45 text-white opacity-0 transition-opacity duration-200 group-hover/avatar:opacity-100',
uploading && 'opacity-100'
)}
>
{uploading ? <Loader2 className="h-5 w-5 animate-spin" /> : <Camera className="h-5 w-5" />}
</span>
<input
id={inputId}
type="file"
accept="image/*"
className="sr-only"
disabled={uploading}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onFileSelect(file);
event.target.value = '';
}}
/>
</label>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { Boxes, Settings2, ShieldCheck, Users } from 'lucide-react';
import { Bot, Boxes, Settings2, ShieldCheck, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PublicUser } from '@/lib/api';
@@ -12,6 +12,12 @@ const links = [
icon: Users,
permissions: ['canViewUsers', 'canManageUsers'] as const
},
{
href: '/admin/bots',
label: 'Боты',
icon: Bot,
permissions: ['canManageBots'] as const
},
{
href: '/admin/oauth',
label: 'OAuth',

View File

@@ -100,10 +100,10 @@ export function OAuthClientDetailDialog({
Быстрый старт
</div>
<ol className="list-decimal space-y-1 pl-5 text-xs text-[#667085]">
<li>Авторизуйте пользователя в IdP и получите userId.</li>
<li>Перенаправьте на authorization endpoint с clientId, redirectUri, scope, userId.</li>
<li>На backend обменяйте code через POST /oauth/token с client_secret.</li>
<li>Запросите профиль через GET /oauth/userinfo с access token.</li>
<li>Укажите issuer = PUBLIC_API_URL в OIDC-клиенте (PHP, Keycloak, Grafana и т.д.).</li>
<li>Перенаправьте пользователя на authorization endpoint стандартные параметры client_id, redirect_uri, response_type=code.</li>
<li>IdP покажет вход и экран «Разрешить доступ», затем вернёт code на redirect_uri.</li>
<li>На backend обменяйте code через POST /oauth/token (form-urlencoded или JSON).</li>
</ol>
<a
href={endpoints.openIdConfigurationUrl}

View File

@@ -3,12 +3,14 @@
import { Sidebar } from './sidebar';
import { UserMenu } from './user-menu';
import { NotificationBell } from '@/components/notifications/notification-bell';
import { EmojiSpriteProvider } from '@/components/chat/emoji-sprite-provider';
import { FamilyOverlayProvider } from '@/components/family/family-overlay-provider';
import { MiniFamilyChat } from '@/components/family/mini-family-chat';
export function IdShell({ active, children, wide = false }: { active: string; children: React.ReactNode; wide?: boolean }) {
return (
<FamilyOverlayProvider>
<EmojiSpriteProvider />
<div className="min-h-screen bg-white">
<Sidebar active={active} />
<div className="fixed right-4 top-4 z-40 flex items-center gap-2 lg:right-8 lg:top-6">

View File

@@ -0,0 +1,39 @@
'use client';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { cn } from '@/lib/utils';
export const ContextMenu = ContextMenuPrimitive.Root;
export const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
export const ContextMenuPortal = ContextMenuPrimitive.Portal;
export function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
className={cn(
'z-50 min-w-[220px] overflow-hidden rounded-2xl border border-[#dce3ec] bg-white p-1.5 text-[#1f2430] shadow-[0_12px_40px_rgba(31,36,48,0.12)] animate-in fade-in zoom-in-95 duration-150',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
export function ContextMenuItem({ className, inset, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & { inset?: boolean }) {
return (
<ContextMenuPrimitive.Item
className={cn(
'flex cursor-pointer select-none items-center gap-3 rounded-xl px-3 py-2.5 text-sm outline-none data-[highlighted]:bg-[#f4f5f8] data-[highlighted]:text-[#1f2430]',
inset && 'pl-8',
className
)}
{...props}
/>
);
}
export function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return <ContextMenuPrimitive.Separator className={cn('my-1 h-px bg-[#eceef4]', className)} {...props} />;
}

View File

@@ -0,0 +1,57 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseChatDragSelectionOptions {
selectionMode: boolean;
enterSelectionMode: (messageId: string) => void;
addSelectedMessage: (messageId: string) => void;
}
export function useChatDragSelection({
selectionMode,
enterSelectionMode,
addSelectedMessage
}: UseChatDragSelectionOptions) {
const [isDragging, setIsDragging] = useState(false);
const visitedRef = useRef<Set<string>>(new Set());
const startDragSelection = useCallback(
(messageId: string) => {
setIsDragging(true);
visitedRef.current = new Set([messageId]);
if (!selectionMode) {
enterSelectionMode(messageId);
} else {
addSelectedMessage(messageId);
}
},
[addSelectedMessage, enterSelectionMode, selectionMode]
);
const handleDragEnterMessage = useCallback(
(messageId: string) => {
if (!isDragging || visitedRef.current.has(messageId)) return;
visitedRef.current.add(messageId);
addSelectedMessage(messageId);
},
[addSelectedMessage, isDragging]
);
const endDragSelection = useCallback(() => {
setIsDragging(false);
visitedRef.current.clear();
}, []);
useEffect(() => {
window.addEventListener('mouseup', endDragSelection);
return () => window.removeEventListener('mouseup', endDragSelection);
}, [endDragSelection]);
return {
isDragging,
startDragSelection,
handleDragEnterMessage,
endDragSelection
};
}

View File

@@ -0,0 +1,106 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ChatMessage } from '@/lib/api';
import { isSelectableMessage } from '@/lib/chat-message-utils';
export function useChatMessageSelection(messages: ChatMessage[]) {
const [selectionMode, setSelectionMode] = useState(false);
const [selectedMessageIds, setSelectedMessageIds] = useState<string[]>([]);
const anchorIndexRef = useRef<number | null>(null);
const exitSelectionMode = useCallback(() => {
setSelectionMode(false);
setSelectedMessageIds([]);
anchorIndexRef.current = null;
}, []);
useEffect(() => {
if (!selectionMode) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
exitSelectionMode();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [exitSelectionMode, selectionMode]);
const enterSelectionMode = useCallback((messageId?: string) => {
if (messageId) {
const message = messages.find((item) => item.id === messageId);
if (!message || !isSelectableMessage(message)) return;
}
setSelectionMode(true);
setSelectedMessageIds(messageId ? [messageId] : []);
anchorIndexRef.current = messageId ? messages.findIndex((item) => item.id === messageId) : null;
}, [messages]);
const addSelectedMessage = useCallback((messageId: string) => {
const message = messages.find((item) => item.id === messageId);
if (!message || !isSelectableMessage(message)) return;
setSelectionMode(true);
setSelectedMessageIds((current) => (current.includes(messageId) ? current : [...current, messageId]));
anchorIndexRef.current = messages.findIndex((item) => item.id === messageId);
}, [messages]);
const toggleSelectedMessage = useCallback((messageId: string) => {
setSelectedMessageIds((current) => {
const next = current.includes(messageId) ? current.filter((id) => id !== messageId) : [...current, messageId];
return next;
});
anchorIndexRef.current = messages.findIndex((item) => item.id === messageId);
}, [messages]);
const handleSelectionPointer = useCallback(
(messageId: string, event: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) => {
const index = messages.findIndex((item) => item.id === messageId);
if (index < 0) return false;
const multiKey = Boolean(event.ctrlKey || event.metaKey);
if (multiKey) {
if (!selectionMode) {
enterSelectionMode(messageId);
} else {
toggleSelectedMessage(messageId);
}
return true;
}
if (event.shiftKey) {
if (!selectionMode) {
enterSelectionMode(messageId);
}
const anchor = anchorIndexRef.current ?? index;
const start = Math.min(anchor, index);
const end = Math.max(anchor, index);
const rangeIds = messages
.slice(start, end + 1)
.filter((item) => isSelectableMessage(item))
.map((item) => item.id);
setSelectedMessageIds(rangeIds);
return true;
}
if (selectionMode) {
toggleSelectedMessage(messageId);
return true;
}
return false;
},
[enterSelectionMode, messages, selectionMode, toggleSelectedMessage]
);
return {
selectionMode,
selectedMessageIds,
setSelectedMessageIds,
enterSelectionMode,
exitSelectionMode,
toggleSelectedMessage,
addSelectedMessage,
handleSelectionPointer
};
}

View File

@@ -0,0 +1,44 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
type Typer = { userId: string; userName: string; expiresAt: number };
export function useChatTyping(currentUserId?: string) {
const [typers, setTypers] = useState<Typer[]>([]);
const timerRef = useRef<number | null>(null);
const pruneTypers = useCallback(() => {
const now = Date.now();
setTypers((current) => current.filter((item) => item.expiresAt > now));
}, []);
const registerTyper = useCallback(
(userId: string, userName: string) => {
if (!userId || userId === currentUserId) return;
const expiresAt = Date.now() + 3000;
setTypers((current) => {
const without = current.filter((item) => item.userId !== userId);
return [...without, { userId, userName, expiresAt }];
});
},
[currentUserId]
);
const clearTypers = useCallback(() => {
setTypers([]);
}, []);
useEffect(() => {
timerRef.current = window.setInterval(pruneTypers, 500);
return () => {
if (timerRef.current) window.clearInterval(timerRef.current);
};
}, [pruneTypers]);
return {
typers: typers.map(({ userId, userName }) => ({ userId, userName })),
registerTyper,
clearTypers
};
}

View File

@@ -0,0 +1,145 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChatMessage, ChatRoom, fetchUserE2EPublicKey, setUserE2EPublicKey } from '@/lib/api';
import { ensureE2EKeyPair } from '@/lib/e2e-crypto';
import {
decryptE2EPayload,
encryptE2EPayload,
readableE2EPayload,
type E2EMessagePayload
} from '@/lib/e2e-chat';
export function resolveChatPeerUserId(room: ChatRoom | null | undefined, userId: string | undefined) {
if (!room || !userId) return null;
const fromMembers = room.members.find((member) => member.userId !== userId)?.userId;
if (fromMembers) return fromMembers;
if (room.peerUserId && room.peerUserId !== userId) return room.peerUserId;
return null;
}
export function getE2EMessageText(options: {
message: ChatMessage;
isE2E: boolean;
peerE2eKey: string | null;
peerKeyLoading: boolean;
e2ePayload?: E2EMessagePayload | null;
e2eError?: string;
}) {
const { message, isE2E, peerE2eKey, peerKeyLoading, e2ePayload, e2eError } = options;
if (!message.isEncrypted) {
return message.content ?? '';
}
if (e2ePayload) {
return readableE2EPayload(e2ePayload);
}
if (e2eError) {
return e2eError;
}
if (isE2E && peerKeyLoading) {
return '🔒 Расшифровка...';
}
if (isE2E && !peerE2eKey) {
return '🔒 Собеседник не опубликовал ключ шифрования';
}
return '🔒 Расшифровка...';
}
export function useE2EChat(options: {
room: ChatRoom | null | undefined;
messages: ChatMessage[];
userId?: string;
token?: string | null;
}) {
const isE2E = options.room?.type === 'E2E';
const peerUserId = useMemo(
() => resolveChatPeerUserId(options.room, options.userId),
[options.room, options.userId]
);
const [peerE2eKey, setPeerE2eKey] = useState<string | null>(null);
const [peerKeyLoading, setPeerKeyLoading] = useState(false);
const [e2ePayloads, setE2ePayloads] = useState<Record<string, E2EMessagePayload | null>>({});
const [e2eErrors, setE2eErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (!options.userId || !options.token) return;
void (async () => {
try {
const { publicKey } = await ensureE2EKeyPair(options.userId!);
await setUserE2EPublicKey(options.userId!, publicKey, options.token);
} catch {
// фоновая инициализация E2E
}
})();
}, [options.token, options.userId]);
useEffect(() => {
if (!isE2E || !peerUserId || !options.token) {
setPeerE2eKey(null);
setPeerKeyLoading(false);
return;
}
setPeerKeyLoading(true);
void fetchUserE2EPublicKey(peerUserId, options.token)
.then((response) => setPeerE2eKey(response.e2ePublicKey ?? null))
.catch(() => setPeerE2eKey(null))
.finally(() => setPeerKeyLoading(false));
}, [isE2E, options.token, peerUserId]);
useEffect(() => {
if (!isE2E || !peerE2eKey || !options.userId) {
setE2ePayloads({});
setE2eErrors({});
return;
}
let cancelled = false;
void (async () => {
const nextPayloads: Record<string, E2EMessagePayload | null> = {};
const nextErrors: Record<string, string> = {};
for (const message of options.messages) {
if (!message.isEncrypted || !message.content) continue;
const payload = await decryptE2EPayload(options.userId!, peerE2eKey, message.content);
if (payload) {
nextPayloads[message.id] = payload;
} else {
nextErrors[message.id] = '🔒 Не удалось расшифровать сообщение';
}
}
if (!cancelled) {
setE2ePayloads(nextPayloads);
setE2eErrors(nextErrors);
}
})();
return () => {
cancelled = true;
};
}, [isE2E, options.messages, options.userId, peerE2eKey]);
const encryptOutgoing = useCallback(
async (payload: Parameters<typeof encryptE2EPayload>[2]) => {
if (!isE2E || !options.userId) {
throw new Error('E2E недоступен');
}
if (!peerE2eKey) {
throw new Error('Собеседник ещё не опубликовал ключ шифрования');
}
return encryptE2EPayload(options.userId, peerE2eKey, payload);
},
[isE2E, options.userId, peerE2eKey]
);
return {
isE2E,
peerUserId,
peerE2eKey,
peerKeyLoading,
e2ePayloads,
e2eErrors,
encryptOutgoing
};
}

View File

@@ -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':

View File

@@ -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);
}

View 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;
}

View 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 [];
}

View 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));
}

View 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 '🔒 Зашифрованное сообщение';
}
}

View 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);
}

View 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))
);
}

View 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);
}

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -1,4 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// This file is generated by Next.js and kept for TypeScript compatibility.
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-context-menu": "^2.3.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,119 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const catalogPath = path.join(__dirname, '../lib/emoji-catalog.ts');
const src = fs.readFileSync(catalogPath, 'utf8');
const ids = [...src.matchAll(/id: '(e-[^']+)'/g)].map((match) => match[1]);
function face(id, mouth, extra = '') {
return `<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="18" r="16" fill="#FFCC4D"/><ellipse cx="12" cy="15" rx="2.2" ry="3" fill="#664500"/><ellipse cx="24" cy="15" rx="2.2" ry="3" fill="#664500"/>${mouth}${extra}</symbol>`;
}
function heart(id, color) {
return `<symbol id="${id}" viewBox="0 0 36 36"><path d="M18 30s-10-6.5-10-14a5.5 5.5 0 0 1 10-3 5.5 5.5 0 0 1 10 3c0 7.5-10 14-10 14z" fill="${color}"/></symbol>`;
}
function circle(id, color, label) {
return `<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="18" r="14" fill="${color}"/><text x="18" y="22" text-anchor="middle" font-size="10" fill="#fff" font-family="Arial,sans-serif">${label}</text></symbol>`;
}
const mouthSmile =
'<path d="M10 21c2.5 3 13.5 3 16 0" stroke="#664500" stroke-width="2" fill="none" stroke-linecap="round"/>';
const mouthLaugh = '<path d="M10 19c2 5 14 5 16 0" fill="#664500"/>';
const mouthSad =
'<path d="M10 24c2.5-3 13.5-3 16 0" stroke="#664500" stroke-width="2" fill="none" stroke-linecap="round"/>';
const mouthOpen = '<ellipse cx="18" cy="22" rx="5" ry="4" fill="#664500"/>';
const mouthLine = '<path d="M12 22h12" stroke="#664500" stroke-width="2" stroke-linecap="round"/>';
const parts = ['<svg xmlns="http://www.w3.org/2000/svg" style="display:none">'];
for (const id of ids) {
if (id.includes('grinning') || id.includes('smile') || id.includes('blush') || id.includes('slight')) {
parts.push(face(id, mouthSmile));
} else if (id.includes('laugh') || id.includes('rofl') || id.includes('party')) {
parts.push(face(id, mouthLaugh));
} else if (id.includes('cry') || id.includes('sob') || id.includes('tired')) {
parts.push(face(id, mouthSad));
} else if (id.includes('shock') || id.includes('fear') || id.includes('rage') || id.includes('angry') || id.includes('sick') || id.includes('mask')) {
parts.push(face(id, mouthOpen));
} else if (id.includes('heart')) {
const colors = {
red: '#EF4444',
orange: '#F97316',
yellow: '#EAB308',
green: '#22C55E',
blue: '#3B82F6',
purple: '#A855F7',
black: '#111827',
white: '#F9FAFB',
broken: '#9CA3AF',
sparkling: '#EC4899',
two: '#F43F5E',
revolving: '#FB7185'
};
const key = Object.keys(colors).find((item) => id.includes(item)) || 'red';
parts.push(heart(id, colors[key]));
} else if (id.includes('thumbs-up')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="10" y="8" width="8" height="18" rx="4" fill="#FBBF24" transform="rotate(-20 14 17)"/><rect x="18" y="14" width="10" height="8" rx="3" fill="#FBBF24"/></symbol>`);
} else if (id.includes('thumbs-down')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="10" y="10" width="8" height="18" rx="4" fill="#FBBF24" transform="rotate(20 14 19)"/><rect x="18" y="14" width="10" height="8" rx="3" fill="#FBBF24"/></symbol>`);
} else if (id.includes('fire')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><path d="M18 4c2 6 6 8 6 14a6 6 0 1 1-12 0c0-4 3-6 6-14z" fill="#F97316"/><path d="M18 14c1 3 3 4 3 7a3 3 0 1 1-6 0c0-2 1-3 3-7z" fill="#FACC15"/></symbol>`);
} else if (id.includes('star')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><polygon points="18,3 22,14 34,14 24,21 28,33 18,26 8,33 12,21 2,14 14,14" fill="#FACC15"/></symbol>`);
} else if (id.includes('check')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="18" r="16" fill="#22C55E"/><path d="M11 18l4 4 10-10" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round"/></symbol>`);
} else if (id.includes('cross')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="18" r="16" fill="#EF4444"/><path d="M13 13l10 10M23 13l-10 10" stroke="#fff" stroke-width="3" stroke-linecap="round"/></symbol>`);
} else if (id.includes('lock')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="10" y="16" width="16" height="14" rx="3" fill="#64748B"/><path d="M13 16v-3a5 5 0 0 1 10 0v3" stroke="#64748B" stroke-width="3" fill="none"/></symbol>`);
} else if (id.includes('dog')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="20" r="10" fill="#D97706"/><circle cx="10" cy="12" r="4" fill="#92400E"/><circle cx="26" cy="12" r="4" fill="#92400E"/></symbol>`);
} else if (id.includes('cat')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="20" r="10" fill="#FB923C"/><polygon points="10,12 14,18 6,18" fill="#FB923C"/><polygon points="26,12 30,18 22,18" fill="#FB923C"/></symbol>`);
} else if (id.includes('pizza')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><path d="M6 28 L30 28 L18 6 Z" fill="#FACC15"/><circle cx="16" cy="22" r="2" fill="#EF4444"/></symbol>`);
} else if (id.includes('coffee')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="10" y="12" width="14" height="12" rx="3" fill="#78350F"/><path d="M24 15h3a3 3 0 0 1 0 6h-3" stroke="#78350F" stroke-width="2" fill="none"/></symbol>`);
} else if (id.includes('car')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="6" y="16" width="24" height="10" rx="4" fill="#3B82F6"/><circle cx="12" cy="26" r="3" fill="#111"/><circle cx="24" cy="26" r="3" fill="#111"/></symbol>`);
} else if (id.includes('airplane')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><path d="M6 18 L30 10 L22 18 L30 26 Z" fill="#60A5FA"/></symbol>`);
} else if (id.includes('rocket')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><path d="M18 4c4 8 4 12 4 18H14c0-6 0-10 4-18z" fill="#CBD5E1"/><circle cx="18" cy="16" r="3" fill="#38BDF8"/></symbol>`);
} else if (id.includes('bulb')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="16" r="9" fill="#FDE047"/><rect x="14" y="24" width="8" height="4" rx="2" fill="#94A3B8"/></symbol>`);
} else if (id.includes('phone')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="12" y="6" width="12" height="24" rx="3" fill="#334155"/></symbol>`);
} else if (id.includes('100')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="4" y="8" width="28" height="20" rx="4" fill="#EF4444"/><text x="18" y="22" text-anchor="middle" font-size="10" fill="#fff" font-family="Arial,sans-serif">100</text></symbol>`);
} else if (id.includes('wink')) {
parts.push(face(id, mouthSmile, '<path d="M22 15h4" stroke="#664500" stroke-width="2" stroke-linecap="round"/>'));
} else if (id.includes('thinking')) {
parts.push(face(id, mouthLine, '<path d="M24 8c3 0 3 4 0 4" stroke="#664500" stroke-width="2" fill="none"/>'));
} else if (id.includes('sleeping')) {
parts.push(face(id, mouthLine, '<text x="24" y="10" font-size="8" fill="#3B82F6" font-family="Arial">Zzz</text>'));
} else if (id.includes('cool')) {
parts.push(face(id, mouthSmile, '<rect x="8" y="13" width="20" height="5" rx="2" fill="#111" opacity=".85"/>'));
} else if (id.includes('kiss')) {
parts.push(face(id, '', '<circle cx="18" cy="22" r="4" fill="#EC4899"/>'));
} else if (id.includes('tongue')) {
parts.push(face(id, '', '<ellipse cx="18" cy="21" rx="4" ry="3" fill="#664500"/><ellipse cx="18" cy="26" rx="3" ry="4" fill="#F472B6"/>'));
} else if (id.includes('wave')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><path d="M8 20c0-8 8-8 8 0s8-8 8 0" stroke="#FBBF24" stroke-width="5" fill="none" stroke-linecap="round"/></symbol>`);
} else if (id.includes('clap')) {
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="8" y="10" width="7" height="16" rx="3" fill="#FBBF24"/><rect x="21" y="10" width="7" height="16" rx="3" fill="#FBBF24"/></symbol>`);
} else {
const label = id.replace('e-', '').slice(0, 3).toUpperCase();
const hue = Math.abs(id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)) % 360;
parts.push(circle(id, `hsl(${hue} 70% 55%)`, label));
}
}
parts.push('</svg>');
const out = path.join(__dirname, '../public/emojis/lendry-emojis.svg');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, parts.join(''));
console.log(`Generated ${ids.length} emoji symbols`);