mirror of
https://github.com/dev993848/lastochka-messenger.git
synced 2026-05-23 13:45:50 +00:00
правки от 23.04
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
<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>
|
||||
<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)}
|
||||
{(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" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.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 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
|
||||
? 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()) {
|
||||
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.trim())
|
||||
void editMessage(editingMessageId, text, drafty)
|
||||
startEdit(null)
|
||||
} else if (replyToId) {
|
||||
void replyToMessage(replyToId, text.trim())
|
||||
void replyToMessage(replyToId, text, drafty)
|
||||
startReply(null)
|
||||
} else {
|
||||
void sendMessage(text.trim())
|
||||
}
|
||||
setText('')
|
||||
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
|
||||
|
||||
const imageItem = Array.from(items).find((item) => item.type.startsWith('image/'))
|
||||
if (!imageItem) 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) return
|
||||
|
||||
event.preventDefault()
|
||||
void sendMessage('', file)
|
||||
if (file) void sendMessage('', file)
|
||||
return
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Plain text paste only (no HTML bleed-in)
|
||||
e.preventDefault()
|
||||
const text = e.clipboardData?.getData('text/plain') ?? ''
|
||||
if (!text) return
|
||||
|
||||
const sel = window.getSelection()
|
||||
if (!sel?.rangeCount) return
|
||||
const range = sel.getRangeAt(0)
|
||||
range.deleteContents()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ 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 */}
|
||||
<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">
|
||||
{/* 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={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageChange}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Left buttons */}
|
||||
{/* 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} />
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,87 +32,388 @@ 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'
|
||||
? 'Только Wi‑Fi'
|
||||
: 'Выключено'
|
||||
|
||||
const handleBack = () => {
|
||||
if (detailView !== 'none') {
|
||||
setDetailView('none')
|
||||
return
|
||||
}
|
||||
goBack()
|
||||
}
|
||||
|
||||
const onPickAvatar = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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="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">
|
||||
<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={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"
|
||||
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"
|
||||
>
|
||||
<ArrowLeft size={22} className="text-gray-700 dark:text-gray-300" />
|
||||
<Camera size={14} className="text-brand" />
|
||||
</button>
|
||||
<h1 className="text-[20px] font-bold text-gray-900 dark:text-gray-100">
|
||||
Настройки
|
||||
</h1>
|
||||
</header>
|
||||
</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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain">
|
||||
{/* Profile */}
|
||||
<ProfileHeader />
|
||||
<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>
|
||||
|
||||
{/* Account */}
|
||||
<SettingsSection title="Аккаунт">
|
||||
<SettingsItem
|
||||
<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="Имя, фото, статус"
|
||||
onClick={openProfile}
|
||||
label="Показывать статус"
|
||||
description="Онлайн и время последнего визита"
|
||||
checked={privacyShowStatus}
|
||||
onChange={setPrivacyShowStatus}
|
||||
/>
|
||||
<SettingsDivider />
|
||||
<SettingsItem
|
||||
icon={<Key size={18} />}
|
||||
label="Конфиденциальность"
|
||||
description="Кто видит мой статус"
|
||||
onClick={() => {}}
|
||||
<SettingsToggle
|
||||
icon={<Globe size={18} />}
|
||||
label="Доступен в поиске"
|
||||
description="По логину и номеру телефона"
|
||||
checked={privacyAllowSearch}
|
||||
onChange={setPrivacyAllowSearch}
|
||||
/>
|
||||
<SettingsDivider />
|
||||
<SettingsItem
|
||||
icon={<Lock size={18} />}
|
||||
label="Безопасность"
|
||||
description="Двухфакторная аутентификация"
|
||||
onClick={() => {}}
|
||||
<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>
|
||||
|
||||
{/* Notifications */}
|
||||
<SettingsSection title="Уведомления">
|
||||
<SettingsToggle
|
||||
icon={<Bell size={18} />}
|
||||
label="Уведомления"
|
||||
description="Push и звук"
|
||||
checked={true}
|
||||
onChange={() => {}}
|
||||
<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 />
|
||||
<SettingsToggle
|
||||
icon={<MessageSquare size={18} />}
|
||||
label="Уведомления в чатах"
|
||||
description="Сообщения от контактов"
|
||||
checked={true}
|
||||
onChange={() => {}}
|
||||
</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 />
|
||||
<SettingsToggle
|
||||
icon={<Smartphone size={18} />}
|
||||
label="Вибрация"
|
||||
checked={true}
|
||||
onChange={() => {}}
|
||||
<SettingsItem
|
||||
icon={<Globe size={18} />}
|
||||
label="English"
|
||||
right={language === 'en' ? <Check size={18} className="text-brand" /> : undefined}
|
||||
onClick={() => setLanguage('en')}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Appearance */}
|
||||
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} />}
|
||||
@@ -137,8 +443,8 @@ export default function SettingsScreen() {
|
||||
<SettingsItem
|
||||
icon={<Palette size={18} />}
|
||||
label="Цвет акцента"
|
||||
description="Индиго"
|
||||
onClick={() => {}}
|
||||
description={accentLabel}
|
||||
onClick={() => setDetailView('accent')}
|
||||
/>
|
||||
<SettingsDivider />
|
||||
<SettingsItem
|
||||
@@ -181,47 +487,196 @@ export default function SettingsScreen() {
|
||||
<SettingsItem
|
||||
icon={<Globe size={18} />}
|
||||
label="Язык"
|
||||
description="Русский"
|
||||
onClick={() => {}}
|
||||
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} />}
|
||||
label="Мой профиль"
|
||||
description="Имя, фото, статус"
|
||||
onClick={openProfile}
|
||||
/>
|
||||
<SettingsDivider />
|
||||
<SettingsItem
|
||||
icon={<Key size={18} />}
|
||||
label="Конфиденциальность"
|
||||
description="Кто видит ваш профиль"
|
||||
onClick={() => setDetailView('privacy')}
|
||||
/>
|
||||
<SettingsDivider />
|
||||
<SettingsItem
|
||||
icon={<Lock size={18} />}
|
||||
label="Безопасность"
|
||||
description="Пароль и 2FA"
|
||||
onClick={() => setDetailView('security')}
|
||||
/>
|
||||
<SettingsDivider />
|
||||
<SettingsItem
|
||||
icon={<Bot size={18} />}
|
||||
label="Чат-боты"
|
||||
description="Создание и API ключи"
|
||||
onClick={() => setDetailView('bots')}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Уведомления">
|
||||
<SettingsToggle
|
||||
icon={<Bell size={18} />}
|
||||
label="Уведомления"
|
||||
description="Push и звук"
|
||||
checked={notificationsEnabled}
|
||||
onChange={setNotificationsEnabled}
|
||||
/>
|
||||
<SettingsDivider />
|
||||
<SettingsToggle
|
||||
icon={<MessageSquare size={18} />}
|
||||
label="Уведомления в чатах"
|
||||
description="Сообщения от контактов"
|
||||
checked={chatNotificationsEnabled}
|
||||
onChange={setChatNotificationsEnabled}
|
||||
/>
|
||||
<SettingsDivider />
|
||||
<SettingsToggle
|
||||
icon={<Smartphone size={18} />}
|
||||
label="Вибрация"
|
||||
checked={vibrationEnabled}
|
||||
onChange={setVibrationEnabled}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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,
|
||||
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 })
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
set({ chats: nextChats })
|
||||
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) => ({
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
)
|
||||
Vendored
+1
@@ -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 {
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface Message {
|
||||
|
||||
export interface Chat {
|
||||
id: string
|
||||
botId?: string
|
||||
isBot?: boolean
|
||||
name: string
|
||||
login?: string
|
||||
phone?: string
|
||||
|
||||
Reference in New Issue
Block a user