правки от 23.04

This commit is contained in:
Anton Budylin
2026-04-23 09:23:57 +03:00
parent d266606297
commit f7bd96af1c
21 changed files with 2665 additions and 333 deletions
+14
View File
@@ -284,3 +284,17 @@ Tindroid (официальный Android-клиент) использует XML
### ADR-005: MinIO для медиа
Избегаем зависимости от облачных провайдеров (AWS S3, GCS). MinIO развёрнут на том же сервере, совместим с S3 API.
### ADR-006: Chat Bot API как отдельный сервис + управление только через Web UI
Принято решение реализовать чат-ботов через отдельный сервис `bot-gateway`, расположенный рядом с Tinode, а не внутри Tinode и не внутри Compliance.
Причины:
1. Изоляция bot-логики и токенов от Tinode core.
2. Независимое масштабирование и rate-limiting для bot traffic.
3. Ускорение разработки Bot API без усложнения Tinode fork.
Ограничение интерфейсов управления:
1. Создание и управление ботами выполняется только через веб-интерфейс (раздел `Чат-боты`).
2. Мобильные клиенты (Android/iOS/Desktop как UI-обёртка) не предоставляют функций создания/управления ботами.
3. Runtime-вызовы Bot API для самих ботов выполняются по токену через `bot-gateway`.
-1
View File
@@ -267,7 +267,6 @@ def run(args, schema, secret):
stdoutln("\rMessage type not handled" + str(msg))
except grpc.RpcError as err:
# print(err)
printerr("gRPC failed with {0}: {1}".format(err.code(), err.details()))
failed = True
except Exception as ex:
+1 -1
View File
@@ -2,4 +2,4 @@ VITE_TINODE_HOST=app.lastochka-m.ru
VITE_TINODE_API_KEY=AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K
VITE_TINODE_SECURE=true
VITE_APP_NAME=Ласточка-Web
VITE_BOT_GATEWAY_URL=http://localhost:8090
+6 -5
View File
@@ -5,6 +5,7 @@ import ChatListScreen from '@/components/ChatListScreen'
import ChatScreen from '@/components/ChatScreen'
import SettingsScreen from '@/components/SettingsScreen'
import SearchScreen from '@/components/SearchScreen'
import BotsScreen from '@/components/BotsScreen'
import FullscreenImageViewer from '@/components/FullscreenImageViewer'
import { Bird } from 'lucide-react'
import LoginScreen from '@/components/auth/LoginScreen'
@@ -36,15 +37,15 @@ export default function App() {
const showSettings = view === 'settings' || view === 'profile'
const showSearch = view === 'search'
const showBots = view === 'bots'
if (showSettings || showSearch) {
const standalone = showSearch ? <SearchScreen /> : <SettingsScreen />
const maxWidth = showSearch ? 'max-w-[560px]' : 'max-w-[480px]'
if (showSettings || showSearch || showBots) {
const standalone = showSearch ? <SearchScreen /> : showBots ? <BotsScreen /> : <SettingsScreen />
return (
<div className="h-full w-full overflow-hidden relative">
{isDesktop ? (
<div className="h-full w-full max-w-[1600px] mx-auto p-3 md:p-4 lg:p-6 flex items-center justify-center">
<div className={`w-full ${maxWidth} h-full max-h-[900px] rounded-3xl overflow-hidden shadow-2xl shadow-black/10 dark:shadow-black/30 bg-white/50 dark:bg-[#0e1621]/60 border border-white/30 dark:border-white/10 backdrop-blur-sm`}>
<div className="h-full w-full max-w-[1600px] mx-auto p-3 md:p-4 lg:p-6">
<div className="h-full rounded-3xl overflow-hidden shadow-2xl shadow-black/10 dark:shadow-black/30 bg-white/50 dark:bg-[#0e1621]/60 border border-white/30 dark:border-white/10 backdrop-blur-sm">
{standalone}
</div>
</div>
+447
View File
@@ -0,0 +1,447 @@
import { useEffect, useMemo, useState } from 'react'
import { ArrowLeft, Bot, KeyRound, Plus, RefreshCw, Save, Send, ShieldOff, Trash2, Webhook } from 'lucide-react'
import { useChatStore } from '@/store/chatStore'
import { useAuthStore } from '@/store/auth'
import {
createBot,
createTestUpdate,
deleteBot,
deleteBotWebhook,
listBots,
regenerateBotToken,
revokeBotToken,
setBotWebhook,
updateBot,
type BotRecord,
} from '@/lib/bot-api'
function provisionStatusLabel(status?: BotRecord['provision_status']): string {
if (status === 'ready') return 'Готов'
if (status === 'failed') return 'Ошибка'
return 'Подготовка'
}
function provisionStatusClasses(status?: BotRecord['provision_status']): string {
if (status === 'ready') return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
if (status === 'failed') return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
}
interface BotsScreenProps {
embedded?: boolean
}
export default function BotsScreen({ embedded = false }: BotsScreenProps) {
const { goBack } = useChatStore()
const { userId } = useAuthStore()
const [bots, setBots] = useState<BotRecord[]>([])
const [selectedBotId, setSelectedBotId] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [tokenPreview, setTokenPreview] = useState<string | null>(null)
const [newName, setNewName] = useState('')
const [newUsername, setNewUsername] = useState('')
const [newAbout, setNewAbout] = useState('')
const selectedBot = useMemo(
() => bots.find((bot) => bot.id === selectedBotId) ?? null,
[bots, selectedBotId],
)
const [editName, setEditName] = useState('')
const [editUsername, setEditUsername] = useState('')
const [editAbout, setEditAbout] = useState('')
const [webhookUrl, setWebhookUrl] = useState('')
const [webhookSecret, setWebhookSecret] = useState('')
const selectedBotReady = selectedBot?.provision_status === 'ready'
useEffect(() => {
if (!userId) return
void reloadBots(userId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId])
useEffect(() => {
setEditName(selectedBot?.display_name ?? '')
setEditUsername(selectedBot?.username ?? '')
setEditAbout(selectedBot?.about ?? '')
setWebhookUrl(selectedBot?.webhook_url ?? '')
setWebhookSecret('')
}, [selectedBot])
useEffect(() => {
if (!userId || !selectedBot || selectedBot.provision_status !== 'pending') {
return
}
let cancelled = false
let timer: number | undefined
const poll = async () => {
if (cancelled) return
await reloadBots(userId, true)
if (cancelled) return
timer = window.setTimeout(poll, 4000)
}
timer = window.setTimeout(poll, 2500)
return () => {
cancelled = true
if (timer !== undefined) {
window.clearTimeout(timer)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, selectedBot?.id, selectedBot?.provision_status])
async function reloadBots(ownerId: string, silent = false) {
if (!silent) {
setIsLoading(true)
setError(null)
}
try {
const data = await listBots(ownerId)
setBots(data)
setSelectedBotId((prev) => prev ?? data[0]?.id ?? null)
} catch (err) {
if (!silent) {
setError(err instanceof Error ? err.message : 'Не удалось загрузить ботов')
}
} finally {
if (!silent) {
setIsLoading(false)
}
}
}
async function handleCreate() {
if (!userId) return
setError(null)
setTokenPreview(null)
try {
const data = await createBot(userId, {
display_name: newName,
username: newUsername,
about: newAbout,
})
setBots((prev) => [data.bot, ...prev])
setSelectedBotId(data.bot.id)
setTokenPreview(data.api_token)
setNewName('')
setNewUsername('')
setNewAbout('')
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось создать бота')
}
}
async function handleSave() {
if (!userId || !selectedBot) return
setError(null)
try {
const updated = await updateBot(userId, selectedBot.id, {
display_name: editName,
username: editUsername,
about: editAbout,
})
setBots((prev) => prev.map((bot) => (bot.id === updated.id ? updated : bot)))
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось сохранить изменения')
}
}
async function handleRegenerateToken() {
if (!userId || !selectedBot) return
setError(null)
try {
const data = await regenerateBotToken(userId, selectedBot.id)
setBots((prev) => prev.map((bot) => (bot.id === data.bot.id ? data.bot : bot)))
setTokenPreview(data.api_token)
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось сгенерировать новый ключ')
}
}
async function handleRevokeToken() {
if (!userId || !selectedBot) return
setError(null)
try {
const data = await revokeBotToken(userId, selectedBot.id)
setBots((prev) => prev.map((bot) => (bot.id === data.id ? data : bot)))
setTokenPreview(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось отозвать ключ')
}
}
async function handleSetWebhook() {
if (!userId || !selectedBot) return
setError(null)
try {
const updated = await setBotWebhook(userId, selectedBot.id, {
url: webhookUrl,
secret: webhookSecret,
})
setBots((prev) => prev.map((bot) => (bot.id === updated.id ? updated : bot)))
setWebhookSecret('')
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось сохранить webhook')
}
}
async function handleDeleteWebhook() {
if (!userId || !selectedBot) return
setError(null)
try {
const updated = await deleteBotWebhook(userId, selectedBot.id)
setBots((prev) => prev.map((bot) => (bot.id === updated.id ? updated : bot)))
setWebhookUrl('')
setWebhookSecret('')
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось удалить webhook')
}
}
async function handleDeleteBot() {
if (!userId || !selectedBot) return
if (!window.confirm(`Удалить бота "${selectedBot.display_name}"?`)) return
setError(null)
setTokenPreview(null)
try {
await deleteBot(userId, selectedBot.id)
const nextBots = bots.filter((bot) => bot.id !== selectedBot.id)
setBots(nextBots)
setSelectedBotId((prev) => (prev === selectedBot.id ? (nextBots[0]?.id ?? null) : prev))
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось удалить бота')
}
}
async function handleTestUpdate() {
if (!userId || !selectedBot) return
setError(null)
try {
const chatId = selectedBot.tinode_topic?.trim() || 'manual-test-chat'
await createTestUpdate(userId, selectedBot.id, {
chat_id: chatId,
text: `Manual test at ${new Date().toISOString()}`,
})
await reloadBots(userId)
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось создать тестовый апдейт')
}
}
return (
<div className={embedded ? 'h-full' : 'flex flex-col h-full bg-gradient-to-br from-[#f8f9fc] via-[#f0f2f7] to-[#e8ecf3] dark:from-[#0a0f18] dark:via-[#0e1621] dark:to-[#111b27]'}>
{!embedded && (
<header className="flex items-center gap-3 px-3 pt-12 pb-3 safe-top glass-strong dark:glass-strong-dark z-10">
<button
onClick={goBack}
className="w-10 h-10 rounded-full flex items-center justify-center hover:bg-black/5 dark:hover:bg-white/5 transition-all tap-target"
>
<ArrowLeft size={22} className="text-gray-700 dark:text-gray-300" />
</button>
<h1 className="text-[20px] font-bold text-gray-900 dark:text-gray-100">Чат-боты</h1>
</header>
)}
<div className={embedded ? 'p-4 space-y-5' : 'flex-1 overflow-y-auto overscroll-contain p-4 space-y-5'}>
<section className="glass dark:glass-dark rounded-2xl p-4">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Создать бота</h2>
<div className="grid gap-2">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Название"
className="px-3 py-2.5 rounded-xl bg-white/70 dark:bg-[#1a2534] border border-white/40 dark:border-white/10 text-sm"
/>
<input
value={newUsername}
onChange={(e) => setNewUsername(e.target.value.toLowerCase())}
placeholder="Логин (например: support_bot)"
className="px-3 py-2.5 rounded-xl bg-white/70 dark:bg-[#1a2534] border border-white/40 dark:border-white/10 text-sm"
/>
<textarea
value={newAbout}
onChange={(e) => setNewAbout(e.target.value)}
placeholder="Описание (опционально)"
rows={2}
className="px-3 py-2.5 rounded-xl bg-white/70 dark:bg-[#1a2534] border border-white/40 dark:border-white/10 text-sm resize-none"
/>
<button
onClick={() => void handleCreate()}
className="mt-1 inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-brand text-white font-medium hover:bg-brand/90 transition"
>
<Plus size={16} />
Создать
</button>
</div>
</section>
<section className="glass dark:glass-dark rounded-2xl p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Мои боты</h2>
<button
onClick={() => userId && void reloadBots(userId)}
className="inline-flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300 hover:text-brand transition"
>
<RefreshCw size={13} />
Обновить
</button>
</div>
{isLoading ? (
<div className="text-sm text-gray-500">Загрузка...</div>
) : bots.length === 0 ? (
<div className="text-sm text-gray-500">Ботов пока нет</div>
) : (
<div className="space-y-2">
{bots.map((bot) => (
<button
key={bot.id}
onClick={() => setSelectedBotId(bot.id)}
className={`w-full text-left rounded-xl border px-3 py-2.5 transition ${
selectedBotId === bot.id
? 'border-brand bg-brand/10'
: 'border-white/30 dark:border-white/10 bg-white/60 dark:bg-[#182333]'
}`}
>
<div className="flex items-center gap-2">
<Bot size={14} className="text-brand" />
<span className="text-sm font-medium">{bot.display_name}</span>
<span className={`ml-auto rounded-full px-2 py-0.5 text-[10px] font-semibold ${provisionStatusClasses(bot.provision_status)}`}>
{provisionStatusLabel(bot.provision_status)}
</span>
</div>
<div className="text-xs text-gray-500 mt-1">@{bot.username}</div>
</button>
))}
</div>
)}
</section>
{selectedBot && (
<section className="glass dark:glass-dark rounded-2xl p-4 space-y-4">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Управление ботом</h2>
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="px-3 py-2.5 rounded-xl bg-white/70 dark:bg-[#1a2534] border border-white/40 dark:border-white/10 text-sm w-full"
/>
<input
value={editUsername}
onChange={(e) => setEditUsername(e.target.value.toLowerCase())}
className="px-3 py-2.5 rounded-xl bg-white/70 dark:bg-[#1a2534] border border-white/40 dark:border-white/10 text-sm w-full"
/>
<textarea
value={editAbout}
onChange={(e) => setEditAbout(e.target.value)}
rows={2}
className="px-3 py-2.5 rounded-xl bg-white/70 dark:bg-[#1a2534] border border-white/40 dark:border-white/10 text-sm w-full resize-none"
/>
<div className="flex flex-wrap gap-2">
<button
onClick={() => void handleSave()}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-brand text-white text-sm"
>
<Save size={14} />
Сохранить
</button>
<button
onClick={() => void handleRegenerateToken()}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-emerald-500 text-white text-sm"
>
<KeyRound size={14} />
Новый API ключ
</button>
<button
onClick={() => void handleRevokeToken()}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-red-500 text-white text-sm"
>
<ShieldOff size={14} />
Отозвать ключ
</button>
<button
onClick={() => void handleDeleteBot()}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-rose-700 text-white text-sm"
>
<Trash2 size={14} />
Удалить бота
</button>
</div>
<div className="rounded-xl border border-white/40 dark:border-white/10 p-3 space-y-1 text-xs text-gray-600 dark:text-gray-300">
<div className="flex items-center gap-2">
<span className="font-semibold">Provision:</span>
<span className={`rounded-full px-2 py-0.5 font-semibold ${provisionStatusClasses(selectedBot.provision_status)}`}>
{provisionStatusLabel(selectedBot.provision_status)}
</span>
</div>
<div>Tinode topic: {selectedBot.tinode_topic || '—'}</div>
{selectedBot.provision_status === 'pending' ? <div>Идёт автообновление статуса</div> : null}
{selectedBot.provision_error ? <div>Ошибка: {selectedBot.provision_error}</div> : null}
</div>
<div className="rounded-xl border border-white/40 dark:border-white/10 p-3 space-y-2">
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wide">Webhook</div>
<input
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://example.com/bot-webhook"
className="px-3 py-2.5 rounded-xl bg-white/70 dark:bg-[#1a2534] border border-white/40 dark:border-white/10 text-sm w-full"
/>
<input
value={webhookSecret}
onChange={(e) => setWebhookSecret(e.target.value)}
placeholder="Secret (для подписи X-Bot-Signature)"
className="px-3 py-2.5 rounded-xl bg-white/70 dark:bg-[#1a2534] border border-white/40 dark:border-white/10 text-sm w-full"
/>
<div className="flex flex-wrap gap-2">
<button
onClick={() => void handleSetWebhook()}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-indigo-600 text-white text-sm"
>
<Webhook size={14} />
Сохранить webhook
</button>
<button
onClick={() => void handleDeleteWebhook()}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-slate-500 text-white text-sm"
>
<ShieldOff size={14} />
Удалить webhook
</button>
<button
onClick={() => void handleTestUpdate()}
disabled={!selectedBotReady}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-amber-500 text-white text-sm disabled:opacity-60 disabled:cursor-not-allowed"
>
<Send size={14} />
Тестовый апдейт
</button>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<div>Last webhook status: {selectedBot.webhook_last_status ?? 0}</div>
{selectedBot.webhook_last_error ? <div>Last error: {selectedBot.webhook_last_error}</div> : null}
{selectedBot.webhook_last_delivery_at ? <div>Last delivery: {selectedBot.webhook_last_delivery_at}</div> : null}
</div>
</div>
</section>
)}
{tokenPreview && (
<section className="glass dark:glass-dark rounded-2xl p-4">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">API ключ (показывается один раз)</h2>
<code className="block text-xs break-all bg-black/80 text-emerald-300 p-3 rounded-xl">{tokenPreview}</code>
</section>
)}
{error && (
<section className="rounded-2xl p-3 bg-red-100/80 dark:bg-red-900/40 text-red-700 dark:text-red-300 text-sm">
{error}
</section>
)}
{!embedded && <div className="safe-bottom" />}
</div>
</div>
)
}
+39 -24
View File
@@ -1,8 +1,8 @@
import { memo } from 'react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { isToday, isYesterday } from 'date-fns'
import clsx from 'clsx'
import { Users, BellOff, Pin, CheckCheck } from 'lucide-react'
import { Users, BellOff, Pin, CheckCheck, Bot } from 'lucide-react'
import type { Chat } from '@/types'
interface ChatItemProps {
@@ -24,15 +24,14 @@ function ChatItem({ chat, isActive, onClick }: ChatItemProps) {
const formatTime = (date?: Date) => {
if (!date) return ''
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / 86400000)
if (days === 0) return format(date, 'HH:mm')
if (days === 1) return 'Вчера'
if (days < 7) return format(date, 'EEE', { locale: ru })
return format(date, 'dd.MM.yy')
if (isToday(date)) return format(date, 'HH:mm')
if (isYesterday(date)) return 'Вчера'
return format(date, 'dd.MM')
}
const messagePreview = chat.typing ? 'печатает...' : (chat.lastMessage || '')
const timeLabel = formatTime(chat.lastMessageTs)
return (
<button
onClick={onClick}
@@ -65,34 +64,50 @@ function ChatItem({ chat, isActive, onClick }: ChatItemProps) {
<Users size={10} className="text-white" />
</div>
)}
{/* Bot icon */}
{(chat.botId || chat.isBot) && !chat.isGroup && (
<div className="absolute -bottom-0.5 -right-0.5 w-5 h-5 bg-indigo-600 rounded-full flex items-center justify-center border-2 border-white dark:border-surface-dark">
<Bot size={10} className="text-white" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-gray-100 truncate">
{chat.name}
</h3>
<div className="flex items-center gap-1.5 flex-shrink-0">
{chat.pinned && <Pin size={14} className="text-muted/60" />}
{chat.lastMessageTs && (
<span className="text-[12px] text-muted tabular-nums">
{formatTime(chat.lastMessageTs)}
<div className="min-w-0 flex items-center gap-1.5">
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-gray-100 truncate">
{chat.name}
</h3>
{(chat.botId || chat.isBot) && (
<span className="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
Бот
</span>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{chat.pinned && <Pin size={14} className="text-muted/60" />}
</div>
</div>
<div className="flex items-center justify-between gap-2 mt-0.5">
<p className="text-[14px] text-gray-500 dark:text-gray-400 truncate">
{chat.typing ? (
<span className="text-brand font-medium italic">печатает...</span>
) : (
chat.lastMessage
<p
className={clsx(
'text-[14px] truncate min-w-0',
chat.typing
? 'text-brand font-medium italic'
: 'text-gray-500 dark:text-gray-400',
)}
>
{messagePreview}
</p>
<div className="flex items-center gap-1.5 flex-shrink-0">
{timeLabel && (
<span className="text-[12px] text-muted tabular-nums">
{timeLabel}
</span>
)}
{chat.muted && <BellOff size={14} className="text-muted/60" />}
{chat.unread ? (
<span
@@ -105,9 +120,9 @@ function ChatItem({ chat, isActive, onClick }: ChatItemProps) {
>
{chat.unread}
</span>
) : chat.lastMessage && (
) : messagePreview && (
/* Read receipts for own messages */
chat.lastMessage.startsWith('Вы:') && (
messagePreview.startsWith('Вы:') && (
<CheckCheck size={16} className="text-brand" />
)
)}
@@ -15,7 +15,7 @@ export default function ChatListScreen({ isDesktop = false }: ChatListScreenProp
setSearchQuery,
setActiveChat,
openSearch,
openSettings,
openProfile,
openCreateGroupModal,
} = useChatStore()
const [searchFocused, setSearchFocused] = useState(false)
@@ -61,7 +61,7 @@ export default function ChatListScreen({ isDesktop = false }: ChatListScreenProp
</span>
</button>
<button
onClick={openSettings}
onClick={openProfile}
className="w-10 h-10 rounded-full bg-white/75 dark:bg-surface-variant-dark/65 flex items-center justify-center tap-target transition-all duration-200 hover:scale-105 active:scale-95 shadow-sm"
>
<Settings size={20} className="text-gray-600 dark:text-gray-300" />
+13 -1
View File
@@ -1,6 +1,6 @@
import { useRef, useEffect, useMemo, useState } from 'react'
import { useChatStore } from '@/store/chatStore'
import { ArrowLeft, MoreVertical, Search, Users, X } from 'lucide-react'
import { ArrowLeft, MoreVertical, Search, Trash2, Users, X } from 'lucide-react'
import MessageBubble, { ReactionsBar } from './MessageBubble'
import MessageInput from './MessageInput'
import GroupSettingsModal from './GroupSettingsModal'
@@ -19,6 +19,7 @@ export default function ChatScreen({ isDesktop = false }: ChatScreenProps) {
addReaction,
removeReaction,
openGroupSettingsModal,
deleteChat,
startReply,
startEdit,
deleteMessage,
@@ -122,6 +123,17 @@ export default function ChatScreen({ isDesktop = false }: ChatScreenProps) {
>
<Search size={20} className="text-gray-700 dark:text-gray-300" />
</button>
<button
onClick={() => {
if (window.confirm(`Удалить чат "${chat.name}"?`)) {
void deleteChat(chat.id)
}
}}
className="w-10 h-10 rounded-full flex items-center justify-center bg-white/70 dark:bg-surface-variant-dark/60 hover:bg-white dark:hover:bg-surface-dark transition-all duration-200 tap-target shadow-sm"
title="Удалить чат"
>
<Trash2 size={20} className="text-red-500" />
</button>
<button
onClick={() => {
if (chat.isGroup) {
+143 -8
View File
@@ -1,11 +1,119 @@
import { format } from 'date-fns'
import { format } from 'date-fns'
import clsx from 'clsx'
import { Check, CheckCheck } from 'lucide-react'
import { Check, CheckCheck, Copy } from 'lucide-react'
import { useState } from 'react'
import type { Message, Reaction, ReactionType } from '@/types'
import { useChatStore } from '@/store/chatStore'
// ─── Message Bubble ──────────────────────────────────────────────
// в”Ђв”Ђв”Ђ Markdown renderer в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
type Seg =
| { t: 'text'; v: string }
| { t: 'bold'; v: string }
| { t: 'italic'; v: string }
| { t: 'strike'; v: string }
| { t: 'code'; v: string }
| { t: 'link'; v: string; url: string }
function normalizeLinkUrl(rawUrl: string): string {
const url = rawUrl.trim()
if (!url) return ''
if (/^(https?:\/\/|mailto:|tel:)/i.test(url)) return url
return `https://${url}`
}
function parseMarkdown(input: string): Seg[] {
interface RawSpan { start: number; end: number; seg: Seg }
const raw: RawSpan[] = []
const add = (re: RegExp, build: (m: RegExpExecArray) => Seg) => {
for (const m of input.matchAll(re))
raw.push({ start: m.index!, end: m.index! + m[0].length, seg: build(m) })
}
add(/`([^`\n]+?)`/g, (m) => ({ t: 'code', v: m[1] }))
add(/\*\*([^*\n]+?)\*\*/g, (m) => ({ t: 'bold', v: m[1] }))
add(/__([^_\n]+?)__/g, (m) => ({ t: 'bold', v: m[1] }))
add(/(?<!\*)\*(?!\*)([^*\n]+?)(?<!\*)\*(?!\*)/g, (m) => ({ t: 'bold', v: m[1] }))
add(/(?<!_)_(?!_)([^_\n]+?)(?<!_)_(?!_)/g, (m) => ({ t: 'italic', v: m[1] }))
add(/~~([^~\n]+?)~~/g, (m) => ({ t: 'strike', v: m[1] }))
add(/\[([^\]\n]*?)\]\(([^)\n]+?)\)/g, (m) => ({ t: 'link', v: m[1] || m[2], url: m[2] }))
if (!raw.length) return [{ t: 'text', v: input }]
raw.sort((a, b) => a.start - b.start || b.end - a.end)
const spans: RawSpan[] = []
let cursor = 0
for (const s of raw) {
if (s.start >= cursor) { spans.push(s); cursor = s.end }
}
const segs: Seg[] = []
let pos = 0
for (const s of spans) {
if (s.start > pos) segs.push({ t: 'text', v: input.slice(pos, s.start) })
segs.push(s.seg)
pos = s.end
}
if (pos < input.length) segs.push({ t: 'text', v: input.slice(pos) })
return segs
}
function MarkdownText({
text,
className,
copiedCodeIndex,
onCopyCode,
}: {
text: string
className?: string
copiedCodeIndex: number | null
onCopyCode: (code: string, index: number) => void
}) {
const segs = parseMarkdown(text)
const hasFormatting = segs.some((s) => s.t !== 'text')
if (!hasFormatting) {
return <p className={className}>{text}</p>
}
return (
<div className={className}>
{segs.map((seg, i) => {
if (seg.t === 'text') return seg.v
if (seg.t === 'bold') return <strong key={i}>{seg.v}</strong>
if (seg.t === 'italic') return <em key={i}>{seg.v}</em>
if (seg.t === 'strike') return <del key={i}>{seg.v}</del>
if (seg.t === 'code') {
return (
<button
key={i}
type="button"
onClick={(event) => {
event.stopPropagation()
onCopyCode(seg.v, i)
}}
className="group relative my-1 block w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/10 px-2.5 py-2 text-left"
>
<span className="absolute right-2 top-2 text-gray-500 dark:text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
{copiedCodeIndex === i ? <Check size={13} /> : <Copy size={13} />}
</span>
<code className="block overflow-x-auto whitespace-pre-wrap break-all text-[13px] font-mono">{seg.v}</code>
</button>
)
}
if (seg.t === 'link') {
const href = normalizeLinkUrl(seg.url)
const label = seg.v || href
return <a key={i} href={href} target="_blank" rel="noopener noreferrer" className="text-brand underline underline-offset-2 hover:opacity-80">{label}</a>
}
return null
})}
</div>
)
}
// в”Ђв”Ђв”Ђ Message Bubble в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
interface MessageBubbleProps {
message: Message
@@ -26,6 +134,30 @@ export default function MessageBubble({
}: MessageBubbleProps) {
const { setFullscreenImage, emojiStyle } = useChatStore()
const [showReactionPicker, setShowReactionPicker] = useState(false)
const [copiedCodeIndex, setCopiedCodeIndex] = useState<number | null>(null)
const copyText = async (value: string) => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
return
}
const ta = document.createElement('textarea')
ta.value = value
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.focus()
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
const handleCopyCode = (code: string, index: number) => {
void copyText(code).then(() => {
setCopiedCodeIndex(index)
window.setTimeout(() => setCopiedCodeIndex((current) => (current === index ? null : current)), 1200)
}).catch(() => {})
}
// Quick reaction handlers
const handleTouchStart = () => {
@@ -130,15 +262,18 @@ export default function MessageBubble({
{/* Text */}
{message.text && (
<p className="text-[14px] leading-relaxed text-gray-900 dark:text-gray-100 break-words whitespace-pre-wrap">
{message.text}
</p>
<MarkdownText
text={message.text}
className="text-[14px] leading-relaxed text-gray-900 dark:text-gray-100 break-words whitespace-pre-wrap"
copiedCodeIndex={copiedCodeIndex}
onCopyCode={handleCopyCode}
/>
)}
{/* Time + Read status */}
<div className={clsx('flex items-center gap-1 float-right ml-2.5 -mb-0.5 relative top-[2px]')}>
{message.edited && (
<span className="text-[10px] text-gray-400/80 dark:text-gray-500/80 mr-0.5 font-medium">ред.</span>
<span className="text-[10px] text-gray-400/80 dark:text-gray-500/80 mr-0.5 font-medium">ред.</span>
)}
<span className="text-[11px] text-gray-400/80 dark:text-gray-500/80 tabular-nums">
{format(message.ts, 'HH:mm')}
@@ -157,7 +292,7 @@ export default function MessageBubble({
)
}
// ─── Reactions Bar ───────────────────────────────────────────────
// в”Ђв”Ђв”Ђ Reactions Bar в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
interface ReactionsBarProps {
reactions: Reaction[]
+450 -138
View File
@@ -1,129 +1,411 @@
import { useEffect, useState, useRef } from 'react'
import { useEffect, useState, useRef } from 'react'
import { useChatStore } from '@/store/chatStore'
import { Send, Smile, Mic, ImagePlus, X } from 'lucide-react'
import { draftyToMarkdown } from '@/lib/tinode-client'
import { Send, Smile, Mic, ImagePlus, X, Bold, Italic, Strikethrough, Code, Link } from 'lucide-react'
import EmojiStickerPicker from './EmojiStickerPicker'
// в”Ђв”Ђв”Ђ Serialise contentEditable в†’ markdown в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
function normalizeLinkUrl(rawUrl: string): string {
const url = rawUrl.trim()
if (!url) return ''
if (/^(https?:\/\/|mailto:|tel:)/i.test(url)) return url
return `https://${url}`
}
type DraftyFmt = { at: number; len: number; tp?: string; key?: number }
type DraftyEnt = { tp: string; data?: Record<string, unknown> }
function pushFmt(fmt: DraftyFmt[], span: DraftyFmt) {
if (span.len <= 0) return
fmt.push(span)
}
function serializeToDrafty(el: HTMLElement): { txt: string; fmt?: DraftyFmt[]; ent?: DraftyEnt[] } | null {
const txtParts: string[] = []
const fmt: DraftyFmt[] = []
const ent: DraftyEnt[] = []
const walk = (node: Node, styles: string[], linkKey?: number) => {
if (node.nodeType === Node.TEXT_NODE) {
const value = node.textContent ?? ''
if (!value) return
const at = txtParts.join('').length
txtParts.push(value)
for (const tp of styles) {
pushFmt(fmt, { at, len: value.length, tp })
}
if (typeof linkKey === 'number') {
pushFmt(fmt, { at, len: value.length, key: linkKey })
}
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
const elNode = node as HTMLElement
const tag = elNode.tagName.toLowerCase()
if (tag === 'br') {
txtParts.push('\n')
return
}
let nextStyles = styles
let nextLinkKey = linkKey
if (tag === 'strong' || tag === 'b') nextStyles = [...styles, 'ST']
if (tag === 'em' || tag === 'i') nextStyles = [...nextStyles, 'EM']
if (tag === 'del' || tag === 's') nextStyles = [...nextStyles, 'DL']
if (tag === 'code') nextStyles = [...nextStyles, 'CO']
if (tag === 'a') {
const href = normalizeLinkUrl(elNode.getAttribute('href') ?? '')
if (href) {
const key = ent.length
ent.push({ tp: 'LN', data: { url: href } })
nextLinkKey = key
}
}
for (const child of Array.from(elNode.childNodes)) {
walk(child, nextStyles, nextLinkKey)
}
}
for (const child of Array.from(el.childNodes)) {
walk(child, [])
}
const txt = txtParts.join('')
if (!txt.trim()) return null
return {
txt,
...(fmt.length ? { fmt } : {}),
...(ent.length ? { ent } : {}),
}
}
// в”Ђв”Ђв”Ђ Parse markdown в†’ HTML (for edit-mode prefill) в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
function mdToHtml(text: string): string {
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_\n]+?)__/g, '<strong>$1</strong>')
.replace(/(?<!\*)\*(?!\*)([^*\n]+?)(?<!\*)\*(?!\*)/g, '<strong>$1</strong>')
.replace(/(?<!_)_(?!_)([^_\n]+?)(?<!_)_(?!_)/g, '<em>$1</em>')
.replace(/~~([^~\n]+?)~~/g, '<del>$1</del>')
.replace(/`([^`\n]+?)`/g, '<code>$1</code>')
.replace(/\[([^\]\n]*?)\]\(([^)\n]+?)\)/g, '<a href="$2">$1</a>')
.replace(/\n/g, '<br>')
}
// в”Ђв”Ђв”Ђ Format toolbar button в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
function FmtBtn({
children,
onActivate,
title,
}: {
children: React.ReactNode
onActivate: () => void
title: string
}) {
return (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); onActivate() }}
title={title}
className="w-7 h-7 rounded flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-black/8 dark:hover:bg-white/8 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
{children}
</button>
)
}
// в”Ђв”Ђв”Ђ MessageInput в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
type FmtType = 'bold' | 'italic' | 'strike' | 'code'
const FMT_TAGS: Record<FmtType, string> = {
bold: 'strong', italic: 'em', strike: 'del', code: 'code',
}
export default function MessageInput() {
const [text, setText] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const [isEmpty, setIsEmpty] = useState(true)
const [isFocused, setIsFocused] = useState(false)
const [showLinkInput, setShowLinkInput] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const editorRef = useRef<HTMLDivElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const linkInputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const savedRangeRef = useRef<Range | null>(null)
const {
sendMessage,
replyToMessage,
editMessage,
showEmojiPicker,
showStickerPicker,
setShowEmojiPicker,
setShowStickerPicker,
replyToId,
editingMessageId,
startReply,
startEdit,
messages,
activeChatId,
sendMessage, replyToMessage, editMessage,
showEmojiPicker, showStickerPicker,
setShowEmojiPicker, setShowStickerPicker,
replyToId, editingMessageId,
startReply, startEdit,
messages, activeChatId,
} = useChatStore()
// Get reply message info
const replyMessage = replyToId && activeChatId
const replyMessage = replyToId && activeChatId
? messages[activeChatId]?.find((m) => m.id === replyToId)
: null
// в”Ђв”Ђ Helpers в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
const checkEmpty = () => {
setIsEmpty(!(editorRef.current?.textContent?.trim()))
}
const clearEditor = () => {
if (editorRef.current) editorRef.current.innerHTML = ''
setIsEmpty(true)
}
const focusEnd = (el: HTMLElement) => {
el.focus()
const range = document.createRange()
range.selectNodeContents(el)
range.collapse(false)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
}
// в”Ђв”Ђ Edit mode: prefill editor в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
useEffect(() => {
const editor = editorRef.current
if (!editor || !editingMessageId) return
const msg = activeChatId
? messages[activeChatId]?.find((m) => m.id === editingMessageId)
: undefined
if (!msg) return
editor.innerHTML = mdToHtml(msg.text)
setIsEmpty(false)
requestAnimationFrame(() => focusEnd(editor))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editingMessageId])
// в”Ђв”Ђ Send в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
const handleSend = () => {
if (text.trim()) {
if (editingMessageId) {
void editMessage(editingMessageId, text.trim())
startEdit(null)
} else if (replyToId) {
void replyToMessage(replyToId, text.trim())
startReply(null)
} else {
void sendMessage(text.trim())
}
setText('')
const editor = editorRef.current
if (!editor) return
const drafty = serializeToDrafty(editor)
const text = draftyToMarkdown(drafty || '').trim()
if (!text) return
if (editingMessageId) {
void editMessage(editingMessageId, text, drafty)
startEdit(null)
} else if (replyToId) {
void replyToMessage(replyToId, text, drafty)
startReply(null)
} else {
void sendMessage(text, undefined, undefined, drafty)
}
clearEditor()
}
const handlePickImage = () => {
imageInputRef.current?.click()
}
// в”Ђв”Ђ Image в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
const handlePickImage = () => imageInputRef.current?.click()
const handleImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) return
// Send file directly
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !file.type.startsWith('image/')) return
void sendMessage('', file)
// Allow selecting the same file again.
event.target.value = ''
e.target.value = ''
}
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
const items = event.clipboardData?.items
if (!items || items.length === 0) return
// в”Ђв”Ђ Paste в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
const items = e.clipboardData?.items
const imageItem = items ? Array.from(items).find((i) => i.type.startsWith('image/')) : null
if (imageItem) {
e.preventDefault()
const file = imageItem.getAsFile()
if (file) void sendMessage('', file)
return
}
const imageItem = Array.from(items).find((item) => item.type.startsWith('image/'))
if (!imageItem) return
// Plain text paste only (no HTML bleed-in)
e.preventDefault()
const text = e.clipboardData?.getData('text/plain') ?? ''
if (!text) return
const file = imageItem.getAsFile()
if (!file) return
const sel = window.getSelection()
if (!sel?.rangeCount) return
const range = sel.getRangeAt(0)
range.deleteContents()
event.preventDefault()
void sendMessage('', file)
const lines = text.split('\n')
lines.forEach((line, i) => {
if (i > 0) {
const br = document.createElement('br')
range.insertNode(br)
range.setStartAfter(br)
}
if (line) {
const tn = document.createTextNode(line)
range.insertNode(tn)
range.setStartAfter(tn)
}
})
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
checkEmpty()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
// в”Ђв”Ђ Keyboard shortcuts в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
return
}
// Shift+Enter в†’ explicit <br> (prevents browser from inserting <div>)
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault()
const sel = window.getSelection()
if (sel?.rangeCount) {
const range = sel.getRangeAt(0)
range.deleteContents()
const br = document.createElement('br')
range.insertNode(br)
range.setStartAfter(br)
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
}
checkEmpty()
return
}
if (e.ctrlKey || e.metaKey) {
if (e.key === 'b' || e.key === 'B') { e.preventDefault(); applyFormat('bold') }
if (e.key === 'i' || e.key === 'I') { e.preventDefault(); applyFormat('italic') }
if (e.key === 'u' || e.key === 'U') { e.preventDefault(); applyFormat('strike') }
}
}
// Auto-focus input when reply or edit mode is active
useEffect(() => {
if (replyToId || editingMessageId) {
inputRef.current?.focus()
}
}, [replyToId, editingMessageId])
// в”Ђв”Ђ Format в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
const applyFormat = (type: FmtType) => {
const editor = editorRef.current
if (!editor) return
editor.focus()
const showSend = text.trim().length > 0
// Use browser's built-in toggle behavior for common inline formats.
if (type === 'bold' || type === 'italic' || type === 'strike') {
const cmd = type === 'bold' ? 'bold' : type === 'italic' ? 'italic' : 'strikeThrough'
document.execCommand(cmd)
checkEmpty()
return
}
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) return
const range = sel.getRangeAt(0)
const selectedText = range.toString()
const el = document.createElement(FMT_TAGS[type])
if (selectedText) {
try {
range.surroundContents(el)
} catch {
// Selection spans multiple nodes — extract and wrap
el.appendChild(range.extractContents())
range.insertNode(el)
}
range.selectNodeContents(el)
sel.removeAllRanges()
sel.addRange(range)
} else {
// No selection — insert placeholder and select it
el.textContent = 'текст'
range.insertNode(el)
range.selectNodeContents(el)
sel.removeAllRanges()
sel.addRange(range)
}
checkEmpty()
}
const handleLinkButton = () => {
const sel = window.getSelection()
if (sel?.rangeCount) savedRangeRef.current = sel.getRangeAt(0).cloneRange()
setShowLinkInput(true)
requestAnimationFrame(() => linkInputRef.current?.focus())
}
const applyLink = () => {
const normalizedUrl = normalizeLinkUrl(linkUrl)
if (!normalizedUrl) return
const editor = editorRef.current
if (!editor) return
editor.focus()
const sel = window.getSelection()
if (!sel) return
if (savedRangeRef.current) {
sel.removeAllRanges()
sel.addRange(savedRangeRef.current)
}
const range = sel.getRangeAt(0)
const selectedText = range.toString()
const a = document.createElement('a')
a.href = normalizedUrl
a.textContent = selectedText || normalizedUrl
if (selectedText) range.deleteContents()
range.insertNode(a)
range.setStartAfter(a)
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
setShowLinkInput(false)
setLinkUrl('')
savedRangeRef.current = null
checkEmpty()
}
const cancelLink = () => {
setShowLinkInput(false)
setLinkUrl('')
savedRangeRef.current = null
}
// в”Ђв”Ђ Pickers close on outside click в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
const isPickerOpen = showEmojiPicker || showStickerPicker
useEffect(() => {
if (!isPickerOpen) return
const closePickers = () => {
setShowEmojiPicker(false)
setShowStickerPicker(false)
const close = () => { setShowEmojiPicker(false); setShowStickerPicker(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() }
const onPtr = (e: MouseEvent | TouchEvent) => {
if (!containerRef.current?.contains(e.target as Node)) close()
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closePickers()
}
}
const handlePointerOutside = (event: MouseEvent | TouchEvent) => {
const container = containerRef.current
const target = event.target as Node | null
if (!container || !target) return
if (!container.contains(target)) {
closePickers()
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('mousedown', handlePointerOutside)
document.addEventListener('touchstart', handlePointerOutside, { passive: true })
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onPtr)
document.addEventListener('touchstart', onPtr, { passive: true })
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handlePointerOutside)
document.removeEventListener('touchstart', handlePointerOutside)
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onPtr)
document.removeEventListener('touchstart', onPtr)
}
}, [isPickerOpen, setShowEmojiPicker, setShowStickerPicker])
// в”Ђв”Ђ Auto-focus on reply/edit в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
useEffect(() => {
if (replyToId || editingMessageId) editorRef.current?.focus()
}, [replyToId, editingMessageId])
// в”Ђв”Ђв”Ђ Render в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
return (
<div ref={containerRef} className="safe-bottom">
{(showEmojiPicker || showStickerPicker) && <EmojiStickerPicker />}
@@ -139,11 +421,8 @@ export default function MessageInput() {
{replyMessage.text}
</p>
</div>
<button
type="button"
onClick={() => startReply(null)}
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<button type="button" onClick={() => startReply(null)}
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<X size={16} className="text-gray-500 dark:text-gray-400" />
</button>
</div>
@@ -153,80 +432,110 @@ export default function MessageInput() {
{editingMessageId && (
<div className="flex items-center gap-2 px-3 py-2 glass-strong dark:glass-strong-dark border-t border-gray-200/30 dark:border-gray-700/20">
<div className="flex-1 min-w-0">
<p className="text-[12px] font-semibold text-brand truncate">
Редактирование сообщения
</p>
<p className="text-[12px] font-semibold text-brand truncate">Редактирование сообщения</p>
</div>
<button
type="button"
onClick={() => startEdit(null)}
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<button type="button" onClick={() => startEdit(null)}
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<X size={16} className="text-gray-500 dark:text-gray-400" />
</button>
</div>
)}
{/* Input bar */}
{/* Formatting toolbar */}
{isFocused && !showLinkInput && (
<div className="flex items-center gap-0.5 px-3 py-1 glass-strong dark:glass-strong-dark border-t border-gray-200/30 dark:border-gray-700/20">
<FmtBtn onActivate={() => applyFormat('bold')} title="Жирный (Ctrl+B)"><Bold size={14} /></FmtBtn>
<FmtBtn onActivate={() => applyFormat('italic')} title="Курсив (Ctrl+I)"><Italic size={14} /></FmtBtn>
<FmtBtn onActivate={() => applyFormat('strike')} title="Зачёркнутый (Ctrl+U)"><Strikethrough size={14} /></FmtBtn>
<FmtBtn onActivate={() => applyFormat('code')} title="Код"><Code size={14} /></FmtBtn>
<div className="w-px h-4 bg-gray-200/60 dark:bg-gray-600/40 mx-1" />
<FmtBtn onActivate={handleLinkButton} title="Ссылка"><Link size={14} /></FmtBtn>
<div className="flex-1" />
<span className="text-[10px] text-gray-400/60 dark:text-gray-600/60 pr-1 select-none">Shift+Enter - новая строка</span>
</div>
)}
{/* Link URL input */}
{isFocused && showLinkInput && (
<div className="flex items-center gap-2 px-3 py-1.5 glass-strong dark:glass-strong-dark border-t border-gray-200/30 dark:border-gray-700/20">
<Link size={13} className="text-gray-400 shrink-0" />
<input
ref={linkInputRef}
type="url"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); applyLink() }
if (e.key === 'Escape') cancelLink()
}}
className="flex-1 bg-transparent text-[13px] text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none"
/>
<button type="button" onClick={applyLink} disabled={!linkUrl.trim()}
className="text-[13px] font-semibold text-brand disabled:opacity-30 transition-opacity">ОК</button>
<button type="button" onClick={cancelLink}>
<X size={14} className="text-gray-400" />
</button>
</div>
)}
{/* Input row */}
<div className="flex items-end gap-2 px-3 py-2 glass-strong dark:glass-strong-dark border-t border-gray-200/30 dark:border-gray-700/20">
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageChange}
/>
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={handleImageChange} />
{/* Left buttons */}
<button
type="button"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
<button type="button" onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-200 tap-target shadow-sm ${
showEmojiPicker
? 'bg-gradient-to-br from-brand/25 to-brand/15 text-brand ring-1 ring-brand/30'
: 'bg-white/70 dark:bg-surface-variant-dark/60 text-gray-500 dark:text-gray-300 hover:bg-white dark:hover:bg-surface-dark'
}`}
>
}`}>
<Smile size={22} />
</button>
<button
type="button"
onClick={handlePickImage}
<button type="button" onClick={handlePickImage}
className="w-10 h-10 rounded-full flex items-center justify-center bg-white/70 dark:bg-surface-variant-dark/60 text-gray-500 dark:text-gray-300 hover:bg-white dark:hover:bg-surface-dark transition-all duration-200 tap-target shadow-sm"
title="Выбрать изображение"
>
title="Выбрать изображение">
<ImagePlus size={22} />
</button>
{/* Text input */}
<div className="flex-1 flex items-end bg-white/80 dark:bg-surface-variant-dark/80 rounded-2xl px-4 py-2.5 min-h-[44px]">
<input
ref={inputRef}
type="text"
placeholder="Сообщение..."
value={text}
onChange={(e) => setText(e.target.value)}
{/* Rich text editor */}
<div className="relative flex-1 bg-white/80 dark:bg-surface-variant-dark/80 rounded-2xl px-4 py-2.5 min-h-[44px] flex items-start">
{/* Placeholder */}
{isEmpty && (
<span className="absolute top-2.5 left-4 text-[15px] text-gray-400 pointer-events-none select-none leading-relaxed">
Сообщение...
</span>
)}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={checkEmpty}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
className="flex-1 bg-transparent text-[15px] text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none max-h-[120px]"
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
const next = e.relatedTarget as Node | null
if (next && containerRef.current?.contains(next)) return
setTimeout(() => {
const active = document.activeElement
if (active && containerRef.current?.contains(active)) return
if (showLinkInput) return
setIsFocused(false)
}, 0)
}}
className="rich-editor flex-1 min-h-[24px] max-h-[120px] overflow-y-auto bg-transparent text-[15px] text-gray-900 dark:text-gray-100 outline-none leading-relaxed whitespace-pre-wrap break-words"
/>
</div>
{/* Right button */}
{showSend ? (
<button
onClick={handleSend}
className="w-10 h-10 rounded-full bg-brand hover:bg-brand-dark text-white flex items-center justify-center transition-all duration-200 hover:scale-105 active:scale-95 tap-target shadow-lg shadow-brand/25"
>
{!isEmpty ? (
<button onClick={handleSend}
className="w-10 h-10 rounded-full bg-brand hover:bg-brand-dark text-white flex items-center justify-center transition-all duration-200 hover:scale-105 active:scale-95 tap-target shadow-lg shadow-brand/25">
<Send size={18} />
</button>
) : (
<button
type="button"
className="w-10 h-10 rounded-full flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5 transition-all tap-target"
>
<button type="button"
className="w-10 h-10 rounded-full flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5 transition-all tap-target">
<Mic size={22} />
</button>
)}
@@ -234,3 +543,6 @@ export default function MessageInput() {
</div>
)
}
+689 -61
View File
@@ -1,5 +1,9 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'
import { useChatStore } from '@/store/chatStore'
import { useAuthStore } from '@/store/auth'
import { changePassword, updateProfile } from '@/lib/email-auth'
import { useUserSettingsStore, type AccentColor } from '@/store/userSettingsStore'
import BotsScreen from './BotsScreen'
import {
ArrowLeft,
Bell,
@@ -10,7 +14,6 @@ import {
Globe,
HardDrive,
LogOut,
Trash2,
User,
Palette,
MessageSquare,
@@ -18,8 +21,10 @@ import {
Info,
Key,
Smile,
Bot,
Camera,
Check,
} from 'lucide-react'
import ProfileHeader from './settings/ProfileHeader'
import {
SettingsSection,
SettingsItem,
@@ -27,37 +32,493 @@ import {
SettingsDivider,
} from './settings/SettingsItem'
export default function SettingsScreen() {
const { goBack, openProfile, darkMode, toggleDarkMode, emojiStyle, setEmojiStyle } = useChatStore()
const { logout, deleteAccount } = useAuthStore()
type DetailView = 'none' | 'privacy' | 'security' | 'appearance' | 'accent' | 'language' | 'storage' | 'about' | 'bots'
const handleDeleteAccount = async () => {
const confirmed = window.confirm('Удалить аккаунт навсегда? Это действие нельзя отменить.')
if (!confirmed) return
await deleteAccount()
export default function SettingsScreen() {
const {
view,
goBack,
openSettings,
openProfile,
darkMode,
toggleDarkMode,
emojiStyle,
setEmojiStyle,
} = useChatStore()
const { logout, displayName, avatar, userId } = useAuthStore()
const {
notificationsEnabled,
chatNotificationsEnabled,
vibrationEnabled,
privacyShowStatus,
privacyAllowSearch,
privacyReadReceipts,
security2FA,
language,
accentColor,
mediaAutoload,
setNotificationsEnabled,
setChatNotificationsEnabled,
setVibrationEnabled,
setPrivacyShowStatus,
setPrivacyAllowSearch,
setPrivacyReadReceipts,
setSecurity2FA,
setLanguage,
setAccentColor,
cycleMediaAutoload,
} = useUserSettingsStore()
const [detailView, setDetailView] = useState<DetailView>('none')
const [savingProfile, setSavingProfile] = useState(false)
const [profileError, setProfileError] = useState('')
const [profileSuccess, setProfileSuccess] = useState('')
const [passwordBusy, setPasswordBusy] = useState(false)
const [passwordError, setPasswordError] = useState('')
const [passwordSuccess, setPasswordSuccess] = useState('')
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const fileInputRef = useRef<HTMLInputElement | null>(null)
const [profileName, setProfileName] = useState(displayName || '')
const [profileBio, setProfileBio] = useState('')
const [avatarDataUrl, setAvatarDataUrl] = useState<string | null>(avatar || null)
useEffect(() => {
if (view !== 'settings') setDetailView('none')
}, [view])
useEffect(() => {
setProfileName(displayName || '')
}, [displayName])
useEffect(() => {
setAvatarDataUrl(avatar || null)
}, [avatar])
const memoryUsageText = useMemo(() => {
let bytes = 0
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i)
if (!key) continue
const value = localStorage.getItem(key) || ''
bytes += key.length + value.length
}
const mb = bytes / (1024 * 1024)
return `${mb.toFixed(2)} МБ (local cache)`
}, [darkMode, emojiStyle, accentColor, language, mediaAutoload])
const accentLabel = accentColor === 'indigo'
? 'Индиго'
: accentColor === 'emerald'
? 'Изумруд'
: 'Роза'
const languageLabel = language === 'ru' ? 'Русский' : 'English'
const mediaAutoloadLabel = mediaAutoload === 'all'
? 'Wi‑Fi и мобильные данные'
: mediaAutoload === 'wifi'
? 'Только WiFi'
: 'Выключено'
const handleBack = () => {
if (detailView !== 'none') {
setDetailView('none')
return
}
goBack()
}
return (
<div className="flex flex-col h-full bg-gradient-to-br from-[#f8f9fc] via-[#f0f2f7] to-[#e8ecf3] dark:from-[#0a0f18] dark:via-[#0e1621] dark:to-[#111b27]">
{/* Header */}
<header className="flex items-center gap-3 px-3 pt-12 pb-3 safe-top glass-strong dark:glass-strong-dark z-10">
<button
onClick={goBack}
className="w-10 h-10 rounded-full flex items-center justify-center hover:bg-black/5 dark:hover:bg-white/5 transition-all tap-target"
>
<ArrowLeft size={22} className="text-gray-700 dark:text-gray-300" />
</button>
<h1 className="text-[20px] font-bold text-gray-900 dark:text-gray-100">
Настройки
</h1>
</header>
const onPickAvatar = () => {
fileInputRef.current?.click()
}
{/* Content */}
<div className="flex-1 overflow-y-auto overscroll-contain">
{/* Profile */}
<ProfileHeader />
const onAvatarSelected = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : null
setAvatarDataUrl(result)
}
reader.readAsDataURL(file)
}
{/* Account */}
const saveProfile = async () => {
const safeName = profileName.trim()
if (!safeName) {
setProfileError('Имя не может быть пустым')
return
}
setSavingProfile(true)
setProfileError('')
setProfileSuccess('')
const result = await updateProfile({
displayName: safeName,
avatar: avatarDataUrl || undefined,
bio: profileBio.trim() || undefined,
})
setSavingProfile(false)
if (!result.success) {
setProfileError(result.error || 'Не удалось сохранить профиль')
return
}
useAuthStore.setState({ displayName: safeName, avatar: avatarDataUrl })
setProfileSuccess('Профиль сохранён')
}
const submitPasswordChange = async () => {
if (!oldPassword || !newPassword) {
setPasswordError('Заполните текущий и новый пароль')
return
}
if (newPassword.length < 6) {
setPasswordError('Новый пароль должен быть не менее 6 символов')
return
}
if (newPassword !== confirmPassword) {
setPasswordError('Подтверждение пароля не совпадает')
return
}
setPasswordBusy(true)
setPasswordError('')
setPasswordSuccess('')
const result = await changePassword(oldPassword, newPassword)
setPasswordBusy(false)
if (!result.success) {
setPasswordError(result.error || 'Не удалось сменить пароль')
return
}
setOldPassword('')
setNewPassword('')
setConfirmPassword('')
setPasswordSuccess('Пароль успешно изменён')
}
const renderDetail = () => {
if (view === 'profile') {
const name = profileName.trim() || 'Пользователь'
const initials = name
.split(' ')
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2)
return (
<div className="px-4 py-5 space-y-5">
<div className="flex flex-col items-center">
<div className="relative">
{avatarDataUrl ? (
<img src={avatarDataUrl} alt={name} className="w-24 h-24 rounded-full object-cover shadow-lg" />
) : (
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-brand to-purple-500 flex items-center justify-center text-white text-2xl font-bold shadow-lg">
{initials}
</div>
)}
<button
onClick={onPickAvatar}
className="absolute bottom-0 right-0 w-8 h-8 rounded-full bg-white dark:bg-surface-dark border-2 border-brand flex items-center justify-center shadow-md"
>
<Camera size={14} className="text-brand" />
</button>
</div>
<input ref={fileInputRef} type="file" accept="image/*" onChange={onAvatarSelected} className="hidden" />
{userId && <p className="text-xs text-gray-500 dark:text-gray-400 mt-3 font-mono">ID: {userId}</p>}
</div>
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Имя
<input
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
className="mt-1 w-full h-11 px-3 rounded-xl bg-white dark:bg-[#242f3d] border border-black/10 dark:border-white/10 outline-none"
placeholder="Ваше имя"
/>
</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
О себе
<textarea
value={profileBio}
onChange={(e) => setProfileBio(e.target.value)}
className="mt-1 w-full min-h-[96px] px-3 py-2 rounded-xl bg-white dark:bg-[#242f3d] border border-black/10 dark:border-white/10 outline-none"
placeholder="Короткий статус"
/>
</label>
{profileError && <p className="text-sm text-red-500">{profileError}</p>}
{profileSuccess && <p className="text-sm text-green-500">{profileSuccess}</p>}
<button
onClick={() => void saveProfile()}
disabled={savingProfile}
className="w-full h-11 rounded-xl bg-brand text-white font-semibold disabled:opacity-60"
>
{savingProfile ? 'Сохраняем...' : 'Сохранить профиль'}
</button>
</div>
</div>
)
}
if (detailView === 'privacy') {
return (
<div className="pt-4">
<SettingsSection title="Конфиденциальность">
<SettingsToggle
icon={<User size={18} />}
label="Показывать статус"
description="Онлайн и время последнего визита"
checked={privacyShowStatus}
onChange={setPrivacyShowStatus}
/>
<SettingsDivider />
<SettingsToggle
icon={<Globe size={18} />}
label="Доступен в поиске"
description="По логину и номеру телефона"
checked={privacyAllowSearch}
onChange={setPrivacyAllowSearch}
/>
<SettingsDivider />
<SettingsToggle
icon={<Check size={18} />}
label="Отчёты о прочтении"
description="Показывать, что вы прочитали сообщение"
checked={privacyReadReceipts}
onChange={setPrivacyReadReceipts}
/>
</SettingsSection>
</div>
)
}
if (detailView === 'security') {
return (
<div className="pt-4 px-4 space-y-4">
<SettingsSection title="Безопасность аккаунта">
<SettingsToggle
icon={<Shield size={18} />}
label="2FA"
description="Дополнительная проверка входа"
checked={security2FA}
onChange={setSecurity2FA}
/>
</SettingsSection>
<div className="glass dark:glass-dark rounded-2xl p-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Сменить пароль</h3>
<input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Текущий пароль"
className="w-full h-11 px-3 rounded-xl bg-white dark:bg-[#242f3d] border border-black/10 dark:border-white/10 outline-none"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Новый пароль"
className="w-full h-11 px-3 rounded-xl bg-white dark:bg-[#242f3d] border border-black/10 dark:border-white/10 outline-none"
/>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Повторите новый пароль"
className="w-full h-11 px-3 rounded-xl bg-white dark:bg-[#242f3d] border border-black/10 dark:border-white/10 outline-none"
/>
{passwordError && <p className="text-sm text-red-500">{passwordError}</p>}
{passwordSuccess && <p className="text-sm text-green-500">{passwordSuccess}</p>}
<button
onClick={() => void submitPasswordChange()}
disabled={passwordBusy}
className="w-full h-11 rounded-xl bg-brand text-white font-semibold disabled:opacity-60"
>
{passwordBusy ? 'Обновляем...' : 'Обновить пароль'}
</button>
</div>
</div>
)
}
if (detailView === 'accent') {
return (
<div className="pt-4">
<SettingsSection title="Цвет акцента">
{[
{ key: 'indigo', label: 'Индиго', color: 'bg-indigo-500' },
{ key: 'emerald', label: 'Изумруд', color: 'bg-emerald-500' },
{ key: 'rose', label: 'Роза', color: 'bg-rose-500' },
].map((item) => (
<div key={item.key}>
<SettingsItem
icon={<span className={`w-4 h-4 rounded-full ${item.color}`} />}
label={item.label}
right={accentColor === item.key ? <Check size={18} className="text-brand" /> : undefined}
onClick={() => setAccentColor(item.key as AccentColor)}
/>
<SettingsDivider />
</div>
))}
</SettingsSection>
</div>
)
}
if (detailView === 'language') {
return (
<div className="pt-4">
<SettingsSection title="Язык">
<SettingsItem
icon={<Globe size={18} />}
label="Русский"
right={language === 'ru' ? <Check size={18} className="text-brand" /> : undefined}
onClick={() => setLanguage('ru')}
/>
<SettingsDivider />
<SettingsItem
icon={<Globe size={18} />}
label="English"
right={language === 'en' ? <Check size={18} className="text-brand" /> : undefined}
onClick={() => setLanguage('en')}
/>
</SettingsSection>
</div>
)
}
if (detailView === 'storage') {
return (
<div className="pt-4">
<SettingsSection title="Данные и память">
<SettingsItem
icon={<HardDrive size={18} />}
label="Использование памяти"
description={memoryUsageText}
/>
<SettingsDivider />
<SettingsItem
icon={<Shield size={18} />}
label="Автозагрузка медиа"
description={mediaAutoloadLabel}
onClick={cycleMediaAutoload}
/>
</SettingsSection>
</div>
)
}
if (detailView === 'appearance') {
return (
<div className="pt-4">
<SettingsSection title="Оформление">
<SettingsItem
icon={darkMode ? <Sun size={18} /> : <Moon size={18} />}
label="Тёмная тема"
right={
<button
role="switch"
aria-checked={darkMode}
onClick={(e) => {
e.stopPropagation()
toggleDarkMode()
}}
className={`relative w-12 h-7 rounded-full transition-colors duration-200 ${
darkMode ? 'bg-brand' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-6 h-6 rounded-full bg-white shadow-sm transition-transform duration-200 ${
darkMode && 'translate-x-5'
}`}
/>
</button>
}
onClick={toggleDarkMode}
/>
<SettingsDivider />
<SettingsItem
icon={<Palette size={18} />}
label="Цвет акцента"
description={accentLabel}
onClick={() => setDetailView('accent')}
/>
<SettingsDivider />
<SettingsItem
icon={<Smile size={18} />}
label="Стиль эмодзи"
description={emojiStyle === 'classic' ? 'Классика' : 'Минимал'}
right={
<div className="flex items-center p-0.5 rounded-lg bg-gray-100 dark:bg-surface-variant-dark gap-0.5">
<button
onClick={(e) => {
e.stopPropagation()
setEmojiStyle('classic')
}}
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${
emojiStyle === 'classic'
? 'bg-white dark:bg-surface-dark text-gray-800 dark:text-gray-100 shadow-sm'
: 'text-gray-500 dark:text-gray-400'
}`}
>
Классика
</button>
<button
onClick={(e) => {
e.stopPropagation()
setEmojiStyle('minimal')
}}
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${
emojiStyle === 'minimal'
? 'bg-white dark:bg-surface-dark text-gray-800 dark:text-gray-100 shadow-sm'
: 'text-gray-500 dark:text-gray-400'
}`}
>
Минимал
</button>
</div>
}
onClick={() => setEmojiStyle(emojiStyle === 'classic' ? 'minimal' : 'classic')}
/>
<SettingsDivider />
<SettingsItem
icon={<Globe size={18} />}
label="Язык"
description={languageLabel}
onClick={() => setDetailView('language')}
/>
</SettingsSection>
</div>
)
}
if (detailView === 'about') {
return (
<div className="pt-4">
<SettingsSection title="О приложении">
<SettingsItem
icon={<Info size={18} />}
label="Версия"
description="Ласточка 1.0.0 (web)"
/>
</SettingsSection>
</div>
)
}
if (detailView === 'bots') {
return <BotsScreen embedded />
}
return null
}
const renderMainList = () => (
<>
<div className="pt-2">
<SettingsSection title="Аккаунт">
<SettingsItem
icon={<User size={18} />}
@@ -69,45 +530,50 @@ export default function SettingsScreen() {
<SettingsItem
icon={<Key size={18} />}
label="Конфиденциальность"
description="Кто видит мой статус"
onClick={() => {}}
description="Кто видит ваш профиль"
onClick={() => setDetailView('privacy')}
/>
<SettingsDivider />
<SettingsItem
icon={<Lock size={18} />}
label="Безопасность"
description="Двухфакторная аутентификация"
onClick={() => {}}
description="Пароль и 2FA"
onClick={() => setDetailView('security')}
/>
<SettingsDivider />
<SettingsItem
icon={<Bot size={18} />}
label="Чат-боты"
description="Создание и API ключи"
onClick={() => setDetailView('bots')}
/>
</SettingsSection>
{/* Notifications */}
<SettingsSection title="Уведомления">
<SettingsToggle
icon={<Bell size={18} />}
label="Уведомления"
description="Push и звук"
checked={true}
onChange={() => {}}
checked={notificationsEnabled}
onChange={setNotificationsEnabled}
/>
<SettingsDivider />
<SettingsToggle
icon={<MessageSquare size={18} />}
label="Уведомления в чатах"
description="Сообщения от контактов"
checked={true}
onChange={() => {}}
checked={chatNotificationsEnabled}
onChange={setChatNotificationsEnabled}
/>
<SettingsDivider />
<SettingsToggle
icon={<Smartphone size={18} />}
label="Вибрация"
checked={true}
onChange={() => {}}
checked={vibrationEnabled}
onChange={setVibrationEnabled}
/>
</SettingsSection>
{/* Appearance */}
<SettingsSection title="Оформление">
<SettingsItem
icon={darkMode ? <Sun size={18} /> : <Moon size={18} />}
@@ -137,8 +603,8 @@ export default function SettingsScreen() {
<SettingsItem
icon={<Palette size={18} />}
label="Цвет акцента"
description="Индиго"
onClick={() => {}}
description={accentLabel}
onClick={() => setDetailView('accent')}
/>
<SettingsDivider />
<SettingsItem
@@ -181,47 +647,36 @@ export default function SettingsScreen() {
<SettingsItem
icon={<Globe size={18} />}
label="Язык"
description="Русский"
onClick={() => {}}
description={languageLabel}
onClick={() => setDetailView('language')}
/>
</SettingsSection>
{/* Storage */}
<SettingsSection title="Данные и память">
<SettingsItem
icon={<HardDrive size={18} />}
label="Использование памяти"
description="2.4 ГБ из 5 ГБ"
onClick={() => {}}
description={memoryUsageText}
onClick={() => setDetailView('storage')}
/>
<SettingsDivider />
<SettingsItem
icon={<Shield size={18} />}
label="Автозагрузка медиа"
description="Wi-Fi и мобильные данные"
onClick={() => {}}
description={mediaAutoloadLabel}
onClick={() => setDetailView('storage')}
/>
</SettingsSection>
{/* About */}
<SettingsSection title="О приложении">
<SettingsItem
icon={<Info size={18} />}
label="Версия"
description="Ласточка 1.0.0 (прототип)"
description="Ласточка 1.0.0 (web)"
/>
</SettingsSection>
{/* Logout */}
<div className="mx-4 mb-6">
<button
onClick={() => void handleDeleteAccount()}
className="w-full flex items-center justify-center gap-2 px-4 py-3.5 rounded-2xl bg-red-100/90 dark:bg-red-900/30 text-red-600 dark:text-red-400 font-semibold text-[15px] hover:bg-red-200 dark:hover:bg-red-900/40 active:scale-[0.98] transition-all mb-3"
title="Операция необратима"
>
<Trash2 size={18} />
Удалить аккаунт (необратимо)
</button>
<button
onClick={() => void logout()}
className="w-full flex items-center justify-center gap-2 px-4 py-3.5 rounded-2xl bg-red-50 dark:bg-red-900/20 text-red-500 font-semibold text-[15px] hover:bg-red-100 dark:hover:bg-red-900/30 active:scale-[0.98] transition-all"
@@ -230,8 +685,181 @@ export default function SettingsScreen() {
Выйти
</button>
</div>
</div>
</>
)
const isDetailMode = view === 'profile' || detailView !== 'none'
const headerTitle = view === 'profile'
? 'Профиль'
: detailView === 'privacy'
? 'Конфиденциальность'
: detailView === 'security'
? 'Безопасность'
: detailView === 'appearance'
? 'Оформление'
: detailView === 'accent'
? 'Цвет акцента'
: detailView === 'language'
? 'Язык'
: detailView === 'storage'
? 'Данные и память'
: detailView === 'about'
? 'О приложении'
: detailView === 'bots'
? 'Чат-боты'
: 'Настройки'
return (
<div className="flex flex-col h-full bg-gradient-to-br from-[#f8f9fc] via-[#f0f2f7] to-[#e8ecf3] dark:from-[#0a0f18] dark:via-[#0e1621] dark:to-[#111b27]">
<header className="flex items-center gap-3 px-3 pt-12 pb-3 safe-top glass-strong dark:glass-strong-dark z-10">
<button
onClick={handleBack}
className="w-10 h-10 rounded-full flex items-center justify-center hover:bg-black/5 dark:hover:bg-white/5 transition-all tap-target"
>
<ArrowLeft size={22} className="text-gray-700 dark:text-gray-300" />
</button>
<h1 className="text-[20px] font-bold text-gray-900 dark:text-gray-100">
{headerTitle}
</h1>
</header>
<div className="flex-1 overflow-y-auto overscroll-contain">
<div className="hidden lg:grid lg:grid-cols-[320px_1fr] gap-4 px-4 pt-4 pb-3">
<aside className="glass dark:glass-dark rounded-2xl border border-black/5 dark:border-white/10 p-3 h-fit">
<div className="space-y-1">
<button
onClick={openProfile}
className={`w-full flex items-center justify-start text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
view === 'profile'
? 'bg-brand/15 text-brand'
: 'hover:bg-black/5 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
}`}
>
Профиль
</button>
<button
onClick={() => {
openSettings()
setDetailView('privacy')
}}
className={`w-full flex items-center justify-start text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
detailView === 'privacy'
? 'bg-brand/15 text-brand'
: 'hover:bg-black/5 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
}`}
>
Конфиденциальность
</button>
<button
onClick={() => {
openSettings()
setDetailView('security')
}}
className={`w-full flex items-center justify-start text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
detailView === 'security'
? 'bg-brand/15 text-brand'
: 'hover:bg-black/5 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
}`}
>
Безопасность
</button>
<button
onClick={() => {
openSettings()
setDetailView('appearance')
}}
className={`w-full flex items-center justify-start text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
detailView === 'appearance'
? 'bg-brand/15 text-brand'
: 'hover:bg-black/5 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
}`}
>
Оформление
</button>
<button
onClick={() => {
openSettings()
setDetailView('accent')
}}
className={`w-full flex items-center justify-start text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
detailView === 'accent'
? 'bg-brand/15 text-brand'
: 'hover:bg-black/5 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
}`}
>
Цвет акцента
</button>
<button
onClick={() => {
openSettings()
setDetailView('language')
}}
className={`w-full flex items-center justify-start text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
detailView === 'language'
? 'bg-brand/15 text-brand'
: 'hover:bg-black/5 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
}`}
>
Язык
</button>
<button
onClick={() => {
openSettings()
setDetailView('storage')
}}
className={`w-full flex items-center justify-start text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
detailView === 'storage'
? 'bg-brand/15 text-brand'
: 'hover:bg-black/5 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
}`}
>
Данные и память
</button>
<button
onClick={() => {
openSettings()
setDetailView('about')
}}
className={`w-full flex items-center justify-start text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
detailView === 'about'
? 'bg-brand/15 text-brand'
: 'hover:bg-black/5 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
}`}
>
О приложении
</button>
<button
onClick={() => {
openSettings()
setDetailView('bots')
}}
className={`w-full flex items-center justify-start text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
detailView === 'bots'
? 'bg-brand/15 text-brand'
: 'hover:bg-black/5 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
}`}
>
Чат-боты
</button>
<button
onClick={() => void logout()}
className="w-full flex items-center justify-start text-left px-3 py-2.5 rounded-xl text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
Выйти
</button>
</div>
</aside>
<section className="glass dark:glass-dark rounded-2xl border border-black/5 dark:border-white/10 overflow-y-auto">
{isDetailMode ? renderDetail() : renderMainList()}
</section>
</div>
<div className="lg:hidden w-full">
{isDetailMode ? renderDetail() : renderMainList()}
</div>
{/* Safe area */}
<div className="safe-bottom" />
</div>
</div>
@@ -1,8 +1,8 @@
import { useState, type FormEvent, useEffect } from 'react'
import { useAuthStore } from '@/store/auth'
import { formatPhoneNumber, isValidPhoneNumber, isValidEmail } from '@/lib/phone-utils'
import { cleanPhoneNumber, formatPhoneNumber, isValidPhoneNumber, isValidEmail, normalizeEmail } from '@/lib/phone-utils'
import { Eye, EyeOff } from 'lucide-react'
import { checkPhoneAvailability, checkLoginAvailability, checkEmailAvailability } from '@/lib/email-auth'
import { checkAvailability, checkPhoneAvailability, checkLoginAvailability, checkEmailAvailability } from '@/lib/email-auth'
interface RegisterFormProps {
onSuccess?: () => void
@@ -167,13 +167,47 @@ export default function RegisterForm({ onSuccess }: RegisterFormProps) {
return
}
// Hard server-side duplicate check on submit to avoid race conditions.
const normalizedLogin = login.trim().toLowerCase()
const normalizedEmail = normalizeEmail(email)
const normalizedPhone = cleanPhoneNumber(phone)
setIsCheckingLogin(true)
setIsCheckingEmail(true)
setIsCheckingPhone(true)
const availability = await checkAvailability(
{
login: normalizedLogin,
email: normalizedEmail,
phone: normalizedPhone,
},
// In production this endpoint may be unavailable/misrouted.
// Do not block registration on transport/proxy errors:
// server-side account creation will still enforce uniqueness.
{ failOpen: true },
)
setIsCheckingLogin(false)
setIsCheckingEmail(false)
setIsCheckingPhone(false)
const loginDup = availability.loginAvailable === false
const emailDup = availability.emailAvailable === false
const phoneDup = availability.phoneAvailable === false
if (loginDup || emailDup || phoneDup) {
if (loginDup) setLoginError('Этот логин уже занят')
if (emailDup) setEmailError('Этот email уже зарегистрирован')
if (phoneDup) setPhoneError('Этот номер уже зарегистрирован')
return
}
// Регистрация без верификации email
await sendRegistrationEmail(
login,
normalizedLogin,
password,
email,
phone,
displayName.trim() || login
normalizedEmail,
normalizedPhone,
displayName.trim() || normalizedLogin
)
// После успешной регистрации переходим на главную
@@ -13,11 +13,11 @@ export function SettingsSection({ title, children }: SettingsSectionProps) {
return (
<div className="mb-6">
{title && (
<h3 className="px-4 mb-2 text-[12px] font-semibold text-muted uppercase tracking-wider">
<h3 className="px-4 mb-2 text-[12px] font-semibold text-muted uppercase tracking-wider text-left">
{title}
</h3>
)}
<div className="mx-4 glass dark:glass-dark rounded-2xl overflow-hidden">
<div className="mx-4 glass dark:glass-dark rounded-2xl overflow-hidden text-left">
{children}
</div>
</div>
@@ -58,7 +58,7 @@ export function SettingsItem({
</div>
{/* Label + description */}
<div className="flex-1 min-w-0 ml-3">
<div className="flex-1 min-w-0 ml-3 text-left">
<p className={clsx('text-[15px] font-medium truncate', danger ? 'text-red-500' : 'text-gray-900 dark:text-gray-100')}>
{label}
</p>
@@ -83,7 +83,7 @@ export function SettingsItem({
return (
<button
onClick={onClick}
className="w-full flex items-center px-4 py-3.5 hover:bg-black/5 dark:hover:bg-white/5 active:bg-black/10 dark:active:bg-white/10 transition-colors"
className="w-full flex items-center text-left px-4 py-3.5 hover:bg-black/5 dark:hover:bg-white/5 active:bg-black/10 dark:active:bg-white/10 transition-colors"
>
{content}
</button>
@@ -91,7 +91,7 @@ export function SettingsItem({
}
return (
<div className="flex items-center px-4 py-3.5">
<div className="flex items-center text-left px-4 py-3.5">
{content}
</div>
)
+19
View File
@@ -81,4 +81,23 @@
-webkit-user-select: none;
user-select: none;
}
/* Rich text editor (contentEditable) */
.rich-editor strong, .rich-editor b { font-weight: 600; }
.rich-editor em, .rich-editor i { font-style: italic; }
.rich-editor del, .rich-editor s { text-decoration: line-through; }
.rich-editor code {
background: rgba(0,0,0,0.08);
border-radius: 3px;
padding: 0 4px;
font-family: ui-monospace, monospace;
font-size: 13px;
}
.rich-editor a {
color: #6366f1;
text-decoration: underline;
text-underline-offset: 2px;
}
.dark .rich-editor code { background: rgba(255,255,255,0.12); }
.dark .rich-editor a { color: #818cf8; }
}
+167
View File
@@ -0,0 +1,167 @@
export interface BotRecord {
id: string
owner_user_id: string
username: string
display_name: string
about: string
is_active: boolean
created_at: string
updated_at: string
last_used_at?: string
webhook_url?: string
webhook_last_status?: number
webhook_last_error?: string
webhook_last_delivery_at?: string
tinode_user_id?: string
tinode_login?: string
tinode_topic?: string
allowed_topics?: string[]
provision_status?: 'pending' | 'ready' | 'failed'
provision_error?: string
}
interface ApiResponse<T> {
data: T
}
const BOT_GATEWAY_URL = (import.meta.env.VITE_BOT_GATEWAY_URL as string) || 'http://localhost:8090'
function buildHeaders(ownerUserId: string): HeadersInit {
return {
'Content-Type': 'application/json',
'X-User-Id': ownerUserId,
}
}
async function unwrap<T>(res: Response): Promise<T> {
if (!res.ok) {
let msg = `Request failed: ${res.status}`
try {
const payload = await res.json() as { error?: { message?: string } }
if (payload?.error?.message) msg = payload.error.message
} catch {
// ignore JSON parse failures
}
throw new Error(msg)
}
return res.json() as Promise<T>
}
export async function listBots(ownerUserId: string): Promise<BotRecord[]> {
const res = await fetch(`${BOT_GATEWAY_URL}/api/v1/bots`, {
headers: buildHeaders(ownerUserId),
})
const payload = await unwrap<ApiResponse<BotRecord[]>>(res)
return payload.data
}
export async function createBot(
ownerUserId: string,
input: { display_name: string; username: string; about?: string },
): Promise<{ bot: BotRecord; api_token: string }> {
const res = await fetch(`${BOT_GATEWAY_URL}/api/v1/bots`, {
method: 'POST',
headers: buildHeaders(ownerUserId),
body: JSON.stringify(input),
})
const payload = await unwrap<ApiResponse<{ bot: BotRecord; api_token: string }>>(res)
return payload.data
}
export async function updateBot(
ownerUserId: string,
botId: string,
input: { display_name?: string; username?: string; about?: string },
): Promise<BotRecord> {
const res = await fetch(`${BOT_GATEWAY_URL}/api/v1/bots/${encodeURIComponent(botId)}`, {
method: 'PATCH',
headers: buildHeaders(ownerUserId),
body: JSON.stringify(input),
})
const payload = await unwrap<ApiResponse<BotRecord>>(res)
return payload.data
}
export async function deleteBot(
ownerUserId: string,
botId: string,
): Promise<BotRecord> {
const res = await fetch(`${BOT_GATEWAY_URL}/api/v1/bots/${encodeURIComponent(botId)}`, {
method: 'DELETE',
headers: buildHeaders(ownerUserId),
})
const payload = await unwrap<ApiResponse<BotRecord>>(res)
return payload.data
}
export async function regenerateBotToken(
ownerUserId: string,
botId: string,
): Promise<{ bot: BotRecord; api_token: string }> {
const res = await fetch(`${BOT_GATEWAY_URL}/api/v1/bots/${encodeURIComponent(botId)}/tokens/regenerate`, {
method: 'POST',
headers: buildHeaders(ownerUserId),
})
const payload = await unwrap<ApiResponse<{ bot: BotRecord; api_token: string }>>(res)
return payload.data
}
export async function revokeBotToken(ownerUserId: string, botId: string): Promise<BotRecord> {
const res = await fetch(`${BOT_GATEWAY_URL}/api/v1/bots/${encodeURIComponent(botId)}/tokens/revoke`, {
method: 'POST',
headers: buildHeaders(ownerUserId),
})
const payload = await unwrap<ApiResponse<BotRecord>>(res)
return payload.data
}
export async function setBotWebhook(
ownerUserId: string,
botId: string,
input: { url: string; secret: string },
): Promise<BotRecord> {
const res = await fetch(`${BOT_GATEWAY_URL}/api/v1/bots/${encodeURIComponent(botId)}/webhook`, {
method: 'POST',
headers: buildHeaders(ownerUserId),
body: JSON.stringify(input),
})
const payload = await unwrap<ApiResponse<BotRecord>>(res)
return payload.data
}
export async function deleteBotWebhook(ownerUserId: string, botId: string): Promise<BotRecord> {
const res = await fetch(`${BOT_GATEWAY_URL}/api/v1/bots/${encodeURIComponent(botId)}/webhook`, {
method: 'DELETE',
headers: buildHeaders(ownerUserId),
})
const payload = await unwrap<ApiResponse<BotRecord>>(res)
return payload.data
}
export async function createTestUpdate(
ownerUserId: string,
botId: string,
input: { chat_id: string; text: string },
): Promise<{ update_id: number; type: string; payload: unknown; created_at: string }> {
const res = await fetch(`${BOT_GATEWAY_URL}/api/v1/bots/${encodeURIComponent(botId)}/updates/test`, {
method: 'POST',
headers: buildHeaders(ownerUserId),
body: JSON.stringify(input),
})
const payload = await unwrap<ApiResponse<{ update_id: number; type: string; payload: unknown; created_at: string }>>(res)
return payload.data
}
export async function createIncomingUpdate(
ownerUserId: string,
botId: string,
input: { chat_id: string; text: string },
): Promise<{ update_id: number; type: string; payload: unknown; created_at: string }> {
const res = await fetch(`${BOT_GATEWAY_URL}/api/v1/bots/${encodeURIComponent(botId)}/updates/incoming`, {
method: 'POST',
headers: buildHeaders(ownerUserId),
body: JSON.stringify(input),
})
const payload = await unwrap<ApiResponse<{ update_id: number; type: string; payload: unknown; created_at: string }>>(res)
return payload.data
}
+5 -4
View File
@@ -101,12 +101,13 @@ export async function checkAvailability(params: {
login?: string
email?: string
phone?: string
}): Promise<{
}, options?: { failOpen?: boolean }): Promise<{
loginAvailable: boolean
emailAvailable: boolean
phoneAvailable: boolean
error?: string
}> {
const failOpen = options?.failOpen ?? true
const url = '/v1/check-availability'
try {
@@ -132,9 +133,9 @@ export async function checkAvailability(params: {
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка проверки доступности'
return {
loginAvailable: true,
emailAvailable: true,
phoneAvailable: true,
loginAvailable: failOpen,
emailAvailable: failOpen,
phoneAvailable: failOpen,
error: msg,
}
}
+72
View File
@@ -88,6 +88,77 @@ export function draftyToText(content: unknown): string {
return '[вложение]'
}
type DraftyFmtSpan = { at?: number; len?: number; key?: number; tp?: string }
type DraftyEntity = { tp?: string; data?: Record<string, unknown> }
function applyWrapperByType(
rawText: string,
at: number,
len: number,
tp: string,
entityData?: Record<string, unknown>,
): string {
if (at < 0 || len <= 0 || at + len > rawText.length) return rawText
const left = rawText.slice(0, at)
const mid = rawText.slice(at, at + len)
const right = rawText.slice(at + len)
switch (tp) {
case 'ST':
return `${left}**${mid}**${right}`
case 'EM':
return `${left}_${mid}_${right}`
case 'DL':
return `${left}~~${mid}~~${right}`
case 'CO':
return `${left}\`${mid}\`${right}`
case 'LN': {
const url = typeof entityData?.url === 'string'
? entityData.url
: typeof entityData?.ref === 'string'
? entityData.ref
: ''
if (!url) return rawText
return `${left}[${mid}](${url})${right}`
}
default:
return rawText
}
}
// Convert Drafty to markdown-like text preserving common inline formatting.
export function draftyToMarkdown(content: unknown): string {
if (!content) return ''
if (typeof content === 'string') return content
if (typeof content !== 'object') return ''
const d = content as Record<string, unknown>
const txt = typeof d.txt === 'string' ? d.txt : draftyToText(content)
if (!txt) return ''
const fmt = Array.isArray(d.fmt) ? (d.fmt as DraftyFmtSpan[]) : []
if (!fmt.length) return txt
const ent = Array.isArray(d.ent) ? (d.ent as DraftyEntity[]) : []
const spans = fmt
.map((span) => {
const at = typeof span.at === 'number' ? span.at : -1
const len = typeof span.len === 'number' ? span.len : 0
const tp = typeof span.tp === 'string'
? span.tp
: (typeof span.key === 'number' && ent[span.key]?.tp ? ent[span.key]!.tp! : '')
const entityData = typeof span.key === 'number' ? ent[span.key]?.data : undefined
return { at, len, tp, entityData }
})
.filter((span) => span.at >= 0 && span.len > 0 && !!span.tp)
.sort((a, b) => (b.at - a.at) || (b.len - a.len))
let out = txt
for (const span of spans) {
out = applyWrapperByType(out, span.at, span.len, span.tp, span.entityData)
}
return out
}
/**
* Check if Drafty content contains an image (IM/EX entity with mime type image/*).
* Handles both flat format { mime, ref, val } and nested { data: { mime, ref, val } }.
@@ -318,3 +389,4 @@ export function removeAuthToken() {
}
export type { MeTopic, Topic, TinodeContact, TinodeMessage }
+448 -77
View File
@@ -1,9 +1,10 @@
import { create } from 'zustand'
import { create } from 'zustand'
import type { Chat, Message, Reaction, StickerPack } from '@/types'
import { contactDisplayName, draftyToText, getAvatarUrl, getTinode, createImageDrafty, uploadFile, getFileUrl } from '@/lib/tinode-client'
import { contactDisplayName, draftyToMarkdown, getAvatarUrl, getTinode, createImageDrafty, uploadFile, getFileUrl } from '@/lib/tinode-client'
import type { TinodeContact, TinodeMessage } from '@/lib/tinode-client'
import { createIncomingUpdate, listBots, type BotRecord } from '@/lib/bot-api'
// ─── Mock Data (removed - using real Tinode data only) ───────────
// в”Ђв”Ђв”Ђ Mock Data (removed - using real Tinode data only) в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
// Mock data has been removed. All data now comes from Tinode server.
const CURRENT_USER_ID = 'usr_me'
@@ -13,6 +14,80 @@ const MESSAGES_PAGE = 24
const PHONE_DIGITS_REGEX = /\D/g
const userDisplayNameCache = new Map<string, string>()
const previewHydrationInFlight = new Set<string>()
const CHAT_PREVIEW_CACHE_KEY = 'lastochka.chatPreviewCache.v1'
const REACTION_CACHE_KEY = 'lastochka.reactionCache.v1'
type ChatPreviewCacheEntry = {
lastMessage?: string
lastMessageTs?: string
}
function readChatPreviewCache(): Record<string, ChatPreviewCacheEntry> {
try {
const raw = localStorage.getItem(CHAT_PREVIEW_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, ChatPreviewCacheEntry>
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
function writeChatPreviewCache(cache: Record<string, ChatPreviewCacheEntry>) {
try {
localStorage.setItem(CHAT_PREVIEW_CACHE_KEY, JSON.stringify(cache))
} catch {
// ignore localStorage write errors
}
}
function setChatPreviewCache(topicId: string, lastMessage?: string, lastMessageTs?: Date) {
if (!topicId) return
const cache = readChatPreviewCache()
cache[topicId] = {
lastMessage: lastMessage || undefined,
lastMessageTs: lastMessageTs ? lastMessageTs.toISOString() : undefined,
}
writeChatPreviewCache(cache)
}
function readReactionCache(): Record<string, Reaction[]> {
try {
const raw = localStorage.getItem(REACTION_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, Reaction[]>
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
function writeReactionCache(cache: Record<string, Reaction[]>) {
try {
localStorage.setItem(REACTION_CACHE_KEY, JSON.stringify(cache))
} catch {
// ignore localStorage write errors
}
}
function getCachedReactions(messageId: string): Reaction[] {
if (!messageId) return []
const cache = readReactionCache()
const value = cache[messageId]
return Array.isArray(value) ? value : []
}
function setCachedReactions(messageId: string, reactions: Reaction[]) {
if (!messageId) return
const cache = readReactionCache()
if (!reactions.length) {
delete cache[messageId]
} else {
cache[messageId] = reactions
}
writeReactionCache(cache)
}
function cacheUserDisplayName(userId?: string, name?: string) {
if (!userId || !name) return
@@ -23,7 +98,7 @@ function cacheUserDisplayName(userId?: string, name?: string) {
function resolveSenderName(senderId: string, myUserId: string): string {
if (!senderId || senderId === myUserId || senderId === CURRENT_USER_ID) {
return 'Вы'
return 'Р’С‹'
}
return userDisplayNameCache.get(senderId) || senderId
}
@@ -122,6 +197,11 @@ function buildFndQueries(rawQuery: string): string[] {
function contactToChat(cont: TinodeContact): Chat {
const name = contactDisplayName(cont)
const pub = (cont.public || {}) as Record<string, unknown>
const botType = typeof pub.type === 'string' ? pub.type.toLowerCase() : ''
const pubTags = Array.isArray(pub.tags) ? pub.tags : []
const hasBotTag = pubTags.some((tag) => typeof tag === 'string' && tag.toLowerCase() === 'bot')
const isBot = botType === 'bot' || hasBotTag
if (cont.topic?.startsWith('usr')) {
cacheUserDisplayName(cont.topic, name)
}
@@ -139,11 +219,39 @@ function contactToChat(cont: TinodeContact): Chat {
unread: cont.unread ?? 0,
online: cont.online ?? false,
isGroup: cont.topic?.startsWith('grp') ?? false,
isBot,
pinned: false,
muted: false,
}
}
function botToSearchChat(bot: BotRecord, existingChatId?: string): Chat {
const topicId = existingChatId || bot.tinode_user_id?.trim() || bot.tinode_topic?.trim() || `bot:${bot.id}`
const status = bot.provision_status || 'pending'
const description = bot.about || (status === 'ready' ? 'Чат-бот' : 'Бот ещё не готов')
return {
id: topicId,
botId: bot.id,
isBot: true,
name: `${bot.display_name} рџ¤–`,
login: bot.username,
description,
online: true,
isGroup: false,
unread: 0,
pinned: false,
muted: false,
}
}
function mergeBotVisual(existing: Chat, botLike: Chat): Chat {
return {
...existing,
isBot: existing.isBot || botLike.isBot || Boolean(existing.botId) || Boolean(botLike.botId),
botId: existing.botId || botLike.botId,
}
}
function parseTinodeContent(content: unknown): { text: string; imageUrl?: string; stickerId?: string } {
if (!content) return { text: '' }
@@ -159,7 +267,7 @@ function parseTinodeContent(content: unknown): { text: string; imageUrl?: string
if (typeof content !== 'object') return { text: '' }
const d = content as Record<string, unknown>
const text = draftyToText(content)
const text = draftyToMarkdown(content)
const ent = d.ent as Array<Record<string, unknown>> | undefined
if (ent) {
@@ -179,6 +287,26 @@ function parseTinodeContent(content: unknown): { text: string; imageUrl?: string
return { text }
}
function normalizeMessageTextForEchoMatch(text: string): string {
return text
.trim()
.replace(/\r\n/g, '\n')
.replace(/\[([^\]\n]+?)\]\(([^)\n]+?)\)/g, '$1')
.replace(/\*\*([^*\n]+?)\*\*/g, '$1')
.replace(/__([^_\n]+?)__/g, '$1')
.replace(/(?<!\*)\*(?!\*)([^*\n]+?)(?<!\*)\*(?!\*)/g, '$1')
.replace(/(?<!_)_(?!_)([^_\n]+?)(?<!_)_(?!_)/g, '$1')
.replace(/~~([^~\n]+?)~~/g, '$1')
.replace(/`([^`\n]+?)`/g, '$1')
}
function toChatPreviewText(text: string): string {
return normalizeMessageTextForEchoMatch(text)
.replace(/\n+/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim()
}
function tinodeMsgToMessage(topicId: string, msg: TinodeMessage, myUserId: string): Message {
const senderId = !msg.from || msg.from === myUserId ? CURRENT_USER_ID : msg.from
const parsed = parseTinodeContent(msg.content)
@@ -196,10 +324,30 @@ function tinodeMsgToMessage(topicId: string, msg: TinodeMessage, myUserId: strin
ts: msg.ts instanceof Date ? msg.ts : new Date(msg.ts),
read: true,
seq: msg.seq,
reactions: msg.seq ? getCachedReactions(`${topicId}-${msg.seq}`) : [],
}
}
// ─── Chat Store ──────────────────────────────────────────────────
function messageToPreview(msg?: Message): string | undefined {
if (!msg) return undefined
if (msg.text) return toChatPreviewText(msg.text)
if (msg.imageUrl) return '📷 Фото'
if (msg.stickerId) return '🙂 Стикер'
return 'Сообщение'
}
function sortChatsByPinnedAndTime(chats: Chat[]): Chat[] {
return [...chats].sort((a, b) => {
const ap = a.pinned ? 1 : 0
const bp = b.pinned ? 1 : 0
if (ap !== bp) return bp - ap
const at = a.lastMessageTs ? new Date(a.lastMessageTs).getTime() : 0
const bt = b.lastMessageTs ? new Date(b.lastMessageTs).getTime() : 0
return bt - at
})
}
// в”Ђв”Ђв”Ђ Chat Store в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
interface ChatState {
chats: Chat[]
@@ -212,7 +360,7 @@ interface ChatState {
emojiStyle: 'classic' | 'minimal'
// Navigation
view: 'chatList' | 'chat' | 'search' | 'settings' | 'profile'
view: 'chatList' | 'chat' | 'search' | 'settings' | 'profile' | 'bots'
showMembersPanel: boolean
// UI state
@@ -232,11 +380,13 @@ interface ChatState {
// Actions
setActiveChat: (id: string) => void
deleteChat: (chatId?: string) => Promise<void>
goBack: () => void
openSearch: () => void
closeSearch: () => void
openSettings: () => void
openProfile: () => void
openBots: () => void
openCreateGroupModal: () => void
closeCreateGroupModal: () => void
createGroup: (name: string, memberIds: string[]) => void
@@ -257,10 +407,10 @@ interface ChatState {
cleanup: () => void
// Messaging
sendMessage: (text: string, imageFile?: File, stickerId?: string) => Promise<void>
editMessage: (messageId: string, newText: string) => Promise<void>
sendMessage: (text: string, imageFile?: File, stickerId?: string, draftyContent?: unknown) => Promise<void>
editMessage: (messageId: string, newText: string, draftyContent?: unknown) => Promise<void>
deleteMessage: (messageId: string, hard?: boolean) => Promise<void>
replyToMessage: (messageId: string, text: string) => Promise<void>
replyToMessage: (messageId: string, text: string, draftyContent?: unknown) => Promise<void>
startReply: (messageId: string | null) => void
startEdit: (messageId: string | null) => void
addReaction: (messageId: string, emoji: Reaction['emoji']) => void
@@ -273,6 +423,7 @@ interface ChatState {
setSelectedStickerPack: (packId: string | null) => void
setFullscreenImage: (url: string | null) => void
_refreshContacts: () => void
_hydrateChatPreview: (topicId: string) => Promise<void>
_addMessage: (topicId: string, msg: TinodeMessage) => void
// Stickers & data
@@ -295,24 +446,24 @@ interface GroupSettings {
const initialGroupSettings: Record<string, GroupSettings> = {
grp_team: {
icon: '👥',
icon: 'рџ‘Ґ',
groupType: 'private',
inviteLink: 'https://app.lastochka-m.ru/invite/grp_team',
members: [
{ id: 'usr_me', name: 'Вы' },
{ id: 'usr_max', name: 'Макс' },
{ id: 'usr_anna', name: 'Анна' },
{ id: 'usr_alice', name: 'Алиса Иванова' },
{ id: 'usr_me', name: 'Р’С‹' },
{ id: 'usr_max', name: 'Макс' },
{ id: 'usr_anna', name: 'РђРЅРЅР°' },
{ id: 'usr_alice', name: 'Алиса Иванова' },
],
},
grp_family: {
icon: '❤️',
icon: 'вќ¤пёЏ',
groupType: 'private',
inviteLink: 'https://app.lastochka-m.ru/invite/grp_family',
members: [
{ id: 'usr_me', name: 'Вы' },
{ id: 'usr_mom', name: 'Мама' },
{ id: 'usr_bob', name: 'Борис Петров' },
{ id: 'usr_me', name: 'Р’С‹' },
{ id: 'usr_mom', name: 'Мама' },
{ id: 'usr_bob', name: 'Борис Петров' },
],
},
}
@@ -348,28 +499,28 @@ export const useChatStore = create<ChatState>((set, get) => ({
stickerPacks: [
{
id: 'funny',
name: 'Весёлые',
coverUrl: '😄',
name: 'Весёлые',
coverUrl: '😄',
stickers: [
{ id: 'funny-1', pack: 'funny', url: '😄', emoji: '😄' },
{ id: 'funny-2', pack: 'funny', url: '😂', emoji: '😂' },
{ id: 'funny-3', pack: 'funny', url: '🤣', emoji: '🤣' },
{ id: 'funny-4', pack: 'funny', url: '😎', emoji: '😎' },
{ id: 'funny-5', pack: 'funny', url: '🤩', emoji: '🤩' },
{ id: 'funny-6', pack: 'funny', url: '🥳', emoji: '🥳' },
{ id: 'funny-1', pack: 'funny', url: '😄', emoji: '😄' },
{ id: 'funny-2', pack: 'funny', url: '😂', emoji: '😂' },
{ id: 'funny-3', pack: 'funny', url: 'рџ¤Ј', emoji: 'рџ¤Ј' },
{ id: 'funny-4', pack: 'funny', url: '😎', emoji: '😎' },
{ id: 'funny-5', pack: 'funny', url: '🤩', emoji: '🤩' },
{ id: 'funny-6', pack: 'funny', url: '🥳', emoji: '🥳' },
],
},
{
id: 'love',
name: 'Лайк и любовь',
coverUrl: '❤️',
name: 'Лайк и любовь',
coverUrl: 'вќ¤пёЏ',
stickers: [
{ id: 'love-1', pack: 'love', url: '❤️', emoji: '❤️' },
{ id: 'love-2', pack: 'love', url: '🫶', emoji: '🫶' },
{ id: 'love-3', pack: 'love', url: '😍', emoji: '😍' },
{ id: 'love-4', pack: 'love', url: '😘', emoji: '😘' },
{ id: 'love-5', pack: 'love', url: '🔥', emoji: '🔥' },
{ id: 'love-6', pack: 'love', url: '👍', emoji: '👍' },
{ id: 'love-1', pack: 'love', url: 'вќ¤пёЏ', emoji: 'вќ¤пёЏ' },
{ id: 'love-2', pack: 'love', url: 'рџ«¶', emoji: 'рџ«¶' },
{ id: 'love-3', pack: 'love', url: '😍', emoji: '😍' },
{ id: 'love-4', pack: 'love', url: '😘', emoji: '😘' },
{ id: 'love-5', pack: 'love', url: '🔥', emoji: '🔥' },
{ id: 'love-6', pack: 'love', url: 'рџ‘Ќ', emoji: 'рџ‘Ќ' },
],
},
],
@@ -416,8 +567,18 @@ export const useChatStore = create<ChatState>((set, get) => ({
loaded.push(tinodeMsgToMessage(id, msg, myUserId))
})
loaded.sort((a, b) => a.ts.getTime() - b.ts.getTime())
const lastLoaded = loaded.length > 0 ? loaded[loaded.length - 1] : undefined
set((state) => ({
messages: { ...state.messages, [id]: loaded },
chats: state.chats.map((chat) =>
chat.id === id
? {
...chat,
lastMessage: messageToPreview(lastLoaded) ?? chat.lastMessage,
lastMessageTs: lastLoaded?.ts ?? chat.lastMessageTs,
}
: chat,
),
}))
if ((topic.getDesc()?.unread ?? 0) > 0) {
@@ -431,11 +592,47 @@ export const useChatStore = create<ChatState>((set, get) => ({
void loadTopic()
},
goBack: () => set({
view: 'chatList',
activeChatId: null,
showEmojiPicker: false,
showStickerPicker: false,
deleteChat: async (chatId) => {
const targetChatId = chatId || get().activeChatId
if (!targetChatId) return
const tn = getTinode()
const target = get().chats.find((chat) => chat.id === targetChatId)
if (!target?.botId) {
const topic = tn.getTopic(targetChatId)
if (topic?.isSubscribed()) {
try {
await topic.leave(true)
} catch {
// ignore leave errors; local deletion still applies
}
}
}
set((state) => {
const nextChats = state.chats.filter((chat) => chat.id !== targetChatId)
const nextMessages = { ...state.messages }
delete nextMessages[targetChatId]
const nextSearch = state.searchResults.filter((chat) => chat.id !== targetChatId)
const wasActive = state.activeChatId === targetChatId
return {
chats: nextChats,
messages: nextMessages,
searchResults: nextSearch,
activeChatId: wasActive ? null : state.activeChatId,
view: wasActive ? 'chatList' : state.view,
}
})
},
goBack: () => set((state) => {
const backToSettings = state.view === 'profile' || state.view === 'bots'
return {
view: backToSettings ? 'settings' : 'chatList',
activeChatId: backToSettings ? state.activeChatId : null,
showEmojiPicker: false,
showStickerPicker: false,
}
}),
openSearch: () => set({ view: 'search', searchQuery: '', searchResults: [], isSearching: false }),
@@ -445,6 +642,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
openProfile: () => set({ view: 'profile' }),
openBots: () => set({ view: 'bots' }),
openCreateGroupModal: () => set({ showCreateGroupModal: true }),
closeCreateGroupModal: () => set({ showCreateGroupModal: false }),
@@ -466,7 +665,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
unread: 0,
pinned: false,
muted: false,
lastMessage: 'Группа создана',
lastMessage: 'Группа создана',
lastMessageTs: nowTs,
}
@@ -474,14 +673,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
id: `msg_created_${Date.now()}`,
chatId: groupId,
senderId: CURRENT_USER_ID,
senderName: 'Вы',
text: `Создана группа «${cleanName}»`,
senderName: 'Р’С‹',
text: `Создана группа «${cleanName}»`,
ts: nowTs,
read: true,
}
const groupMembers: GroupMemberLite[] = [
{ id: CURRENT_USER_ID, name: 'Вы' },
{ id: CURRENT_USER_ID, name: 'Р’С‹' },
...uniqueMembers.map((memberId) => {
const user = state.chats.find((chat) => chat.id === memberId)
return { id: memberId, name: user?.name || memberId }
@@ -500,7 +699,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
groupSettings: {
...state.groupSettings,
[groupId]: {
icon: '👥',
icon: 'рџ‘Ґ',
groupType: 'private',
inviteLink: `https://app.lastochka-m.ru/invite/${groupId}`,
members: groupMembers,
@@ -609,6 +808,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
const fnd = tn.getFndTopic()
const remoteMatches: Chat[] = []
const remoteSeen = new Set<string>()
const botMatches: Chat[] = []
try {
if (!fnd.isSubscribed()) {
@@ -626,6 +826,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
remoteMatches.push(contactToChat(sub))
})
}
const ownerUserId = tn.getCurrentUserID()
if (ownerUserId) {
const bots = await listBots(ownerUserId)
for (const bot of bots) {
const existingChat = get().chats.find((chat) => {
if (bot.tinode_user_id?.trim() && chat.id === bot.tinode_user_id.trim()) return true
if (bot.tinode_topic?.trim() && chat.id === bot.tinode_topic.trim()) return true
return false
})
const chat = botToSearchChat(bot, existingChat?.id)
if (doesChatMatchQuery(chat, normalizedQuery)) {
botMatches.push(chat)
}
}
}
} catch (err) {
console.error('Search failed:', err)
}
@@ -640,6 +856,17 @@ export const useChatStore = create<ChatState>((set, get) => ({
seen.add(chat.id)
}
}
for (const chat of botMatches) {
if (!seen.has(chat.id)) {
merged.push(chat)
seen.add(chat.id)
} else {
const idx = merged.findIndex((item) => item.id === chat.id)
if (idx >= 0) {
merged[idx] = mergeBotVisual(merged[idx], chat)
}
}
}
set({
searchResults: merged,
@@ -759,6 +986,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
const me = tn.getMeTopic()
const nextChats: Chat[] = []
const existingChats = get().chats
const existingMessagesByChat = get().messages
const previewCache = readChatPreviewCache()
me.contacts((cont: TinodeContact) => {
if (!cont.topic) return
@@ -767,24 +996,98 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
const existing = existingChats.find((chat) => chat.id === cont.topic)
const pinRank = typeof me.pinnedTopicRank === 'function' ? me.pinnedTopicRank(cont.topic) : 0
const mapped = contactToChat(cont)
const topicMessages = existingMessagesByChat[cont.topic]
const lastLoaded = topicMessages && topicMessages.length > 0
? topicMessages[topicMessages.length - 1]
: undefined
const cached = previewCache[cont.topic]
const cachedTs = cached?.lastMessageTs ? new Date(cached.lastMessageTs) : undefined
nextChats.push({
...contactToChat(cont),
...mapped,
pinned: existing?.pinned ?? pinRank > 0,
muted: existing?.muted ?? false,
lastMessage: messageToPreview(lastLoaded) ?? existing?.lastMessage ?? cached?.lastMessage,
lastMessageTs: lastLoaded?.ts ?? mapped.lastMessageTs ?? existing?.lastMessageTs ?? cachedTs,
description: existing?.description,
isBot: existing?.isBot ?? mapped.isBot,
botId: existing?.botId,
})
})
nextChats.sort((a, b) => {
const ap = a.pinned ? 1 : 0
const bp = b.pinned ? 1 : 0
if (ap !== bp) return bp - ap
const at = a.lastMessageTs ? new Date(a.lastMessageTs).getTime() : 0
const bt = b.lastMessageTs ? new Date(b.lastMessageTs).getTime() : 0
return bt - at
})
const sortedChats = sortChatsByPinnedAndTime(nextChats)
set({ chats: sortedChats })
set({ chats: nextChats })
sortedChats
.filter((chat) => !chat.lastMessage)
.slice(0, 30)
.forEach((chat) => {
void get()._hydrateChatPreview(chat.id)
})
},
_hydrateChatPreview: async (topicId) => {
if (!topicId || previewHydrationInFlight.has(topicId)) return
previewHydrationInFlight.add(topicId)
try {
const tn = getTinode()
const topic = tn.getTopic(topicId)
if (!topic.isSubscribed()) {
await topic.subscribe(
topic.startMetaQuery()
.withLaterData(1)
.build(),
)
}
cacheTopicSubscribers(topic)
const myUserId = tn.getCurrentUserID()
let latest: Message | undefined
topic.messages((msg: TinodeMessage) => {
const mapped = tinodeMsgToMessage(topicId, msg, myUserId)
if (!latest || mapped.ts.getTime() > latest.ts.getTime()) {
latest = mapped
}
})
if (!latest) return
const latestMsg = latest
const latestPreview = messageToPreview(latestMsg)
setChatPreviewCache(topicId, latestPreview, latestMsg.ts)
set((state) => {
const existing = state.messages[topicId] ?? []
const hasMessage = existing.some((item) => item.id === latestMsg.id)
const nextMessages = hasMessage
? existing
: [...existing, latestMsg].sort((a, b) => a.ts.getTime() - b.ts.getTime())
const nextChats = state.chats.map((chat) => {
if (chat.id !== topicId) return chat
const currentTs = chat.lastMessageTs ? new Date(chat.lastMessageTs).getTime() : 0
const latestTs = latestMsg.ts.getTime()
const shouldReplace = !chat.lastMessage || latestTs >= currentTs
return {
...chat,
lastMessage: shouldReplace ? (latestPreview ?? chat.lastMessage) : chat.lastMessage,
lastMessageTs: latestTs >= currentTs ? latestMsg.ts : chat.lastMessageTs,
}
})
return {
messages: { ...state.messages, [topicId]: nextMessages },
chats: sortChatsByPinnedAndTime(nextChats),
}
})
} catch {
// ignore preview hydration errors for individual chats
} finally {
previewHydrationInFlight.delete(topicId)
}
},
_addMessage: (topicId, msg) => {
@@ -802,7 +1105,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
? existing.findIndex((item) =>
!item.seq &&
item.senderId === mapped.senderId &&
item.text === mapped.text &&
(
item.text === mapped.text ||
normalizeMessageTextForEchoMatch(item.text) === normalizeMessageTextForEchoMatch(mapped.text)
) &&
(item.imageUrl || '') === (mapped.imageUrl || '') &&
(item.stickerId || '') === (mapped.stickerId || ''),
)
@@ -810,8 +1116,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (optimisticIdx >= 0) {
// Replace optimistic local echo with the authoritative server message.
const optimistic = existing[optimisticIdx]
const shouldKeepOptimisticText = normalizeMessageTextForEchoMatch(optimistic.text) === normalizeMessageTextForEchoMatch(mapped.text)
nextMessages = [...existing]
nextMessages[optimisticIdx] = mapped
nextMessages[optimisticIdx] = {
...mapped,
text: shouldKeepOptimisticText ? optimistic.text : mapped.text,
reactions: optimistic.reactions && optimistic.reactions.length ? optimistic.reactions : mapped.reactions,
}
} else {
nextMessages = [...existing, mapped]
}
@@ -823,21 +1135,64 @@ export const useChatStore = create<ChatState>((set, get) => ({
chat.id === topicId
? {
...chat,
lastMessage: mapped.text || (mapped.imageUrl ? '📷 Фото' : 'Сообщение'),
lastMessage: messageToPreview(mapped) || 'Сообщение',
lastMessageTs: mapped.ts,
}
: chat,
),
}
})
setChatPreviewCache(topicId, messageToPreview(mapped), mapped.ts)
},
sendMessage: async (text, imageFile, stickerId) => {
sendMessage: async (text, imageFile, stickerId, draftyContent) => {
const { activeChatId } = get()
if (!activeChatId) return
const nowTs = new Date()
const safeText = text.trim()
const tn = getTinode()
const activeChat = get().chats.find((chat) => chat.id === activeChatId)
if (activeChat?.botId) {
if (!safeText) return
const botId = activeChat.botId
const ownerUserId = tn.getCurrentUserID()
if (!botId || !ownerUserId) return
const optimisticMsg: Message = {
id: `msg_${Date.now()}`,
chatId: activeChatId,
senderId: CURRENT_USER_ID,
senderName: 'Р’С‹',
text: safeText,
ts: nowTs,
read: false,
}
set((state) => {
const chatMessages = state.messages[activeChatId] || []
return {
messages: { ...state.messages, [activeChatId]: [...chatMessages, optimisticMsg] },
chats: state.chats.map((chat) =>
chat.id === activeChatId
? { ...chat, lastMessage: toChatPreviewText(safeText), lastMessageTs: nowTs }
: chat,
),
}
})
try {
await createIncomingUpdate(ownerUserId, botId, {
chat_id: activeChatId,
text: safeText,
})
} catch (err) {
console.error('Bot message send failed:', err)
}
return
}
const topic = tn.getTopic(activeChatId)
if (!topic) return
@@ -872,7 +1227,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
id: `msg_${Date.now()}`,
chatId: activeChatId,
senderId: CURRENT_USER_ID,
senderName: 'Вы',
senderName: 'Р’С‹',
text,
imageUrl: getFileUrl(uploadResult.ref),
ts: nowTs,
@@ -885,7 +1240,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
messages: { ...state.messages, [activeChatId]: [...chatMessages, optimisticMsg] },
chats: state.chats.map((chat) =>
chat.id === activeChatId
? { ...chat, lastMessage: text || '📷 Фото', lastMessageTs: nowTs }
? { ...chat, lastMessage: toChatPreviewText(text) || '📷 Фото', lastMessageTs: nowTs }
: chat
),
}
@@ -907,7 +1262,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
id: `msg_${Date.now()}`,
chatId: activeChatId,
senderId: CURRENT_USER_ID,
senderName: 'Вы',
senderName: 'Р’С‹',
text: '',
stickerId,
ts: nowTs,
@@ -920,7 +1275,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
messages: { ...state.messages, [activeChatId]: [...chatMessages, optimisticMsg] },
chats: state.chats.map((chat) =>
chat.id === activeChatId
? { ...chat, lastMessage: '🙂 Стикер', lastMessageTs: nowTs }
? { ...chat, lastMessage: '🙂 Стикер', lastMessageTs: nowTs }
: chat,
),
}
@@ -943,7 +1298,6 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
// Handle plain text
const safeText = text.trim()
if (!safeText) return
// Optimistic UI: add message immediately
@@ -951,7 +1305,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
id: `msg_${Date.now()}`,
chatId: activeChatId,
senderId: CURRENT_USER_ID,
senderName: 'Вы',
senderName: 'Р’С‹',
text: safeText,
ts: nowTs,
read: false,
@@ -963,13 +1317,17 @@ export const useChatStore = create<ChatState>((set, get) => ({
messages: { ...state.messages, [activeChatId]: [...chatMessages, optimisticMsg] },
chats: state.chats.map((chat) =>
chat.id === activeChatId
? { ...chat, lastMessage: safeText, lastMessageTs: nowTs }
? { ...chat, lastMessage: toChatPreviewText(safeText), lastMessageTs: nowTs }
: chat
),
}
})
await topic.publishMessage(topic.createMessage(safeText, false)).catch(() => {
const plainDraft = topic.createMessage(safeText, false) as Record<string, unknown>
if (draftyContent && typeof draftyContent === 'object') {
plainDraft.content = draftyContent
}
await topic.publishMessage(plainDraft).catch(() => {
// keep optimistic UI, avoid breaking UX on transient network errors
})
},
@@ -986,9 +1344,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
const alreadyExists = existingReactions.some((r) => r.emoji === emoji && r.userId === CURRENT_USER_ID)
if (alreadyExists) return msg
const nextReactions = [...existingReactions, { emoji, userId: CURRENT_USER_ID, userName: 'Вы' }]
setCachedReactions(msg.id, nextReactions)
return {
...msg,
reactions: [...existingReactions, { emoji, userId: CURRENT_USER_ID, userName: 'Вы' }],
reactions: nextReactions,
}
}) || [],
}
@@ -1004,11 +1364,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
...messages,
[activeChatId]: messages[activeChatId]?.map((msg) => {
if (msg.id !== messageId) return msg
const nextReactions = msg.reactions?.filter(
(r) => !(r.emoji === emoji && r.userId === CURRENT_USER_ID)
) || []
setCachedReactions(msg.id, nextReactions)
return {
...msg,
reactions: msg.reactions?.filter(
(r) => !(r.emoji === emoji && r.userId === CURRENT_USER_ID)
) || [],
reactions: nextReactions,
}
}) || [],
}
@@ -1026,7 +1388,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
startReply: (messageId) => set({ replyToId: messageId }),
startEdit: (messageId) => set({ editingMessageId: messageId }),
editMessage: async (messageId, newText) => {
editMessage: async (messageId, newText, draftyContent) => {
const { activeChatId, messages } = get()
if (!activeChatId || !newText.trim()) return
@@ -1050,7 +1412,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
// Send edited message via Tinode (re-publish with same seq)
try {
const editedContent = topic.createMessage(newText.trim(), false)
const editedContent = topic.createMessage(newText.trim(), false) as Record<string, unknown>
if (draftyContent && typeof draftyContent === 'object') {
editedContent.content = draftyContent
}
await topic.publishMessage(editedContent)
} catch (err) {
console.error('Edit message failed:', err)
@@ -1085,7 +1450,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
},
replyToMessage: async (messageId, text) => {
replyToMessage: async (messageId, text, draftyContent) => {
const { activeChatId, messages } = get()
if (!activeChatId || !text.trim()) return
@@ -1099,6 +1464,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
// Create message with reply info in head
const content = topic.createMessage(text.trim(), false) as Record<string, unknown>
if (draftyContent && typeof draftyContent === 'object') {
content.content = draftyContent
}
// Add reply reference to message head (Tinode supports this)
const msgWithReply = {
@@ -1114,13 +1482,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
id: `msg_${Date.now()}`,
chatId: activeChatId,
senderId: CURRENT_USER_ID,
senderName: 'Вы',
senderName: 'Р’С‹',
text,
ts: nowTs,
read: false,
replyTo: {
seq: originalMsg.seq || 0,
senderName: originalMsg.senderName || 'Пользователь',
senderName: originalMsg.senderName || 'Пользователь',
text: originalMsg.text.substring(0, 50) + (originalMsg.text.length > 50 ? '...' : ''),
},
}
@@ -1131,7 +1499,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
messages: { ...state.messages, [activeChatId]: [...chatMessages, optimisticMsg] },
chats: state.chats.map((chat) =>
chat.id === activeChatId
? { ...chat, lastMessage: text, lastMessageTs: nowTs }
? { ...chat, lastMessage: toChatPreviewText(text), lastMessageTs: nowTs }
: chat
),
replyToId: null,
@@ -1145,3 +1513,6 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
},
}))
+102
View File
@@ -0,0 +1,102 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type AppLanguage = 'ru' | 'en'
export type AccentColor = 'indigo' | 'emerald' | 'rose'
export type MediaAutoload = 'all' | 'wifi' | 'none'
interface UserSettingsState {
notificationsEnabled: boolean
chatNotificationsEnabled: boolean
vibrationEnabled: boolean
privacyShowStatus: boolean
privacyAllowSearch: boolean
privacyReadReceipts: boolean
security2FA: boolean
language: AppLanguage
accentColor: AccentColor
mediaAutoload: MediaAutoload
setNotificationsEnabled: (value: boolean) => void
setChatNotificationsEnabled: (value: boolean) => void
setVibrationEnabled: (value: boolean) => void
setPrivacyShowStatus: (value: boolean) => void
setPrivacyAllowSearch: (value: boolean) => void
setPrivacyReadReceipts: (value: boolean) => void
setSecurity2FA: (value: boolean) => void
setLanguage: (value: AppLanguage) => void
setAccentColor: (value: AccentColor) => void
setMediaAutoload: (value: MediaAutoload) => void
cycleMediaAutoload: () => void
}
const LEGACY_SETTINGS_KEY = 'lastochka.settings.v1'
const USER_SETTINGS_KEY = 'lastochka.userSettings.v1'
type SettingsFields = Pick<
UserSettingsState,
| 'notificationsEnabled'
| 'chatNotificationsEnabled'
| 'vibrationEnabled'
| 'privacyShowStatus'
| 'privacyAllowSearch'
| 'privacyReadReceipts'
| 'security2FA'
| 'language'
| 'accentColor'
| 'mediaAutoload'
>
const defaultSettings: SettingsFields = {
notificationsEnabled: true,
chatNotificationsEnabled: true,
vibrationEnabled: true,
privacyShowStatus: true,
privacyAllowSearch: true,
privacyReadReceipts: true,
security2FA: false,
language: 'ru',
accentColor: 'indigo',
mediaAutoload: 'all',
}
function readLegacySettings(): Partial<SettingsFields> {
try {
const raw = localStorage.getItem(LEGACY_SETTINGS_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Partial<SettingsFields>
return parsed ?? {}
} catch {
return {}
}
}
const initialSettings: SettingsFields = {
...defaultSettings,
...readLegacySettings(),
}
export const useUserSettingsStore = create<UserSettingsState>()(
persist(
(set, get) => ({
...initialSettings,
setNotificationsEnabled: (value) => set({ notificationsEnabled: value }),
setChatNotificationsEnabled: (value) => set({ chatNotificationsEnabled: value }),
setVibrationEnabled: (value) => set({ vibrationEnabled: value }),
setPrivacyShowStatus: (value) => set({ privacyShowStatus: value }),
setPrivacyAllowSearch: (value) => set({ privacyAllowSearch: value }),
setPrivacyReadReceipts: (value) => set({ privacyReadReceipts: value }),
setSecurity2FA: (value) => set({ security2FA: value }),
setLanguage: (value) => set({ language: value }),
setAccentColor: (value) => set({ accentColor: value }),
setMediaAutoload: (value) => set({ mediaAutoload: value }),
cycleMediaAutoload: () => {
const current = get().mediaAutoload
const next = current === 'all' ? 'wifi' : current === 'wifi' ? 'none' : 'all'
set({ mediaAutoload: next })
},
}),
{
name: USER_SETTINGS_KEY,
},
),
)
+1
View File
@@ -4,6 +4,7 @@ interface ImportMetaEnv {
readonly VITE_TINODE_API_KEY: string
readonly VITE_TINODE_SECURE: string
readonly VITE_APP_NAME: string
readonly VITE_BOT_GATEWAY_URL: string
}
interface ImportMeta {
+2
View File
@@ -55,6 +55,8 @@ export interface Message {
export interface Chat {
id: string
botId?: string
isBot?: boolean
name: string
login?: string
phone?: string