Reorganize web folder structurally
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Stage 1: Build React app
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files for apps/web
|
||||
# We use the local package files to avoid workspace hoisting issues in Docker
|
||||
COPY apps/web/package.json ./
|
||||
# If there is a lockfile in apps/web, use it, otherwise use root (but root is workspace, so better not)
|
||||
# Let's try to generate a clean install
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copy source code
|
||||
COPY apps/web/ ./
|
||||
|
||||
# Build web frontend
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Custom Nginx config to proxy /api and /socket.io to server:3001
|
||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Knot Messenger</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@knot/web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="url(#g1)" />
|
||||
<path d="M16 6C11 6 8 9 9 14C10 19 14 21 16 26C18 21 22 19 23 14C24 9 21 6 16 6Z" fill="white" fill-opacity="0.9" />
|
||||
<defs>
|
||||
<linearGradient id="g1" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6366f1" />
|
||||
<stop offset="1" stop-color="#8b5cf6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 MiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { useAuthStore } from './modules/auth/application/authStore';
|
||||
import AuthPage from './modules/auth/presentation/AuthPage';
|
||||
import ChatPage from './modules/chats/presentation/ChatPage';
|
||||
import AdminPage from './modules/admin/presentation/pages/AdminPage';
|
||||
import NotificationProvider from './core/presentation/components/ui/NotificationProvider';
|
||||
|
||||
export default function App() {
|
||||
const { token, user, checkAuth, isLoading } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-surface">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<AppLoader />
|
||||
<p className="text-zinc-500 text-sm">Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple routing for admin
|
||||
if (window.location.pathname.startsWith('/admin')) {
|
||||
return <AdminPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
{token && user ? (
|
||||
<ChatPage key="chat" />
|
||||
) : (
|
||||
<AuthPage key="auth" />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<NotificationProvider />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppLoader() {
|
||||
return (
|
||||
<div className="relative w-12 h-12">
|
||||
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-accent animate-spin" />
|
||||
<div
|
||||
className="absolute inset-1 rounded-full border-2 border-transparent border-t-accent/70 animate-spin"
|
||||
style={{ animationDuration: '0.8s', animationDirection: 'reverse' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-2 rounded-full border-2 border-transparent border-t-accent/40 animate-spin"
|
||||
style={{ animationDuration: '0.6s' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface NotificationState {
|
||||
notifications: Notification[];
|
||||
addNotification: (type: NotificationType, message: string, duration?: number) => void;
|
||||
removeNotification: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set) => ({
|
||||
notifications: [],
|
||||
addNotification: (type, message, duration = 5000) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
set((state) => ({
|
||||
notifications: [...state.notifications, { id, type, message, duration }],
|
||||
}));
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
}));
|
||||
}, duration);
|
||||
}
|
||||
},
|
||||
removeNotification: (id) =>
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
})),
|
||||
}));
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type ChatTheme = 'midnight' | 'ocean' | 'forest' | 'sunset' | 'classic' | 'neon' | 'aurora' | 'cyber' | 'glass' | 'void';
|
||||
|
||||
interface ThemeState {
|
||||
chatTheme: ChatTheme;
|
||||
setChatTheme: (theme: ChatTheme) => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
chatTheme: 'midnight',
|
||||
setChatTheme: (theme) => set({ chatTheme: theme }),
|
||||
}),
|
||||
{
|
||||
name: 'knot-theme-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,183 @@
|
||||
// ─── User types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserBasic {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export interface UserPresence extends UserBasic {
|
||||
isOnline: boolean;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface User extends UserPresence {
|
||||
bio: string | null;
|
||||
birthday: string | null;
|
||||
createdAt: string;
|
||||
hideStoryViews?: boolean;
|
||||
}
|
||||
|
||||
// ─── Chat types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChatMember {
|
||||
id: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
isPinned?: boolean;
|
||||
isMuted?: boolean;
|
||||
isArchived?: boolean;
|
||||
clearedAt?: string | null;
|
||||
user: UserPresence;
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
filename: string | null;
|
||||
thumbnail: string | null;
|
||||
size: number | null;
|
||||
duration: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: string;
|
||||
emoji: string;
|
||||
userId: string;
|
||||
user: { id: string; username: string; displayName: string; avatar?: string | null };
|
||||
}
|
||||
|
||||
export interface MessageSender {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
chatId: string;
|
||||
senderId: string;
|
||||
content: string | null;
|
||||
type: string;
|
||||
replyToId: string | null;
|
||||
quote?: string | null;
|
||||
forwardedFromId?: string | null;
|
||||
storyId?: string | null;
|
||||
storyMediaUrl?: string | null;
|
||||
storyMediaType?: string | null;
|
||||
isEdited: boolean;
|
||||
isDeleted: boolean;
|
||||
scheduledAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
sender: MessageSender;
|
||||
replyTo?: {
|
||||
id: string;
|
||||
content: string | null;
|
||||
isDeleted?: boolean;
|
||||
quote?: string | null;
|
||||
media?: MediaItem[];
|
||||
sender: { id: string; username: string; displayName: string };
|
||||
} | null;
|
||||
forwardedFrom?: UserBasic | null;
|
||||
media: MediaItem[];
|
||||
reactions: Reaction[];
|
||||
readBy: Array<{ userId: string }>;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string | null;
|
||||
description?: string | null;
|
||||
avatar: string | null;
|
||||
createdAt: string;
|
||||
members: ChatMember[];
|
||||
messages: Message[];
|
||||
unreadCount: number;
|
||||
pinnedMessages?: Array<{
|
||||
id: string;
|
||||
message: Message;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ─── Socket event types ────────────────────────────────────────────────
|
||||
|
||||
export interface TypingUser {
|
||||
chatId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface CallInfo {
|
||||
from: string;
|
||||
offer: RTCSessionDescriptionInit;
|
||||
callType: 'voice' | 'video';
|
||||
chatId: string;
|
||||
callerInfo?: UserBasic | null;
|
||||
}
|
||||
|
||||
// ─── Story types ───────────────────────────────────────────────────────
|
||||
|
||||
export interface Story {
|
||||
id: string;
|
||||
type: string;
|
||||
mediaUrl: string | null;
|
||||
content: string | null;
|
||||
bgColor: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
viewCount: number;
|
||||
viewed: boolean;
|
||||
reactions?: Array<{ id: string; userId: string; emoji: string; createdAt: string }>;
|
||||
replyCount?: number;
|
||||
}
|
||||
|
||||
export interface StoryViewer {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar: string | null;
|
||||
viewedAt: string;
|
||||
}
|
||||
|
||||
export interface StoryGroup {
|
||||
user: UserBasic;
|
||||
stories: Story[];
|
||||
hasUnviewed: boolean;
|
||||
}
|
||||
|
||||
// ─── Utility types ─────────────────────────────────────────────────
|
||||
|
||||
// ─── Friend types ──────────────────────────────────────────────────
|
||||
|
||||
export interface FriendRequest {
|
||||
id: string;
|
||||
user: User;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FriendWithId extends UserPresence {
|
||||
friendshipId: string;
|
||||
}
|
||||
|
||||
export interface FriendshipStatus {
|
||||
status: 'none' | 'pending' | 'accepted' | 'declined' | 'self';
|
||||
friendshipId?: string | null;
|
||||
direction?: 'incoming' | 'outgoing';
|
||||
}
|
||||
|
||||
// ─── Utility types ─────────────────────────────────────────────────────
|
||||
|
||||
/** Audio file extensions recognized by the app. */
|
||||
export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma'] as const;
|
||||
|
||||
/** Max file size for uploads (200MB). */
|
||||
export const MAX_FILE_SIZE = 200 * 1024 * 1024;
|
||||
|
||||
/** Max avatar size (5MB). */
|
||||
export const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { httpClient } from './httpClient';
|
||||
|
||||
export class AppApi {
|
||||
static async analyzeTelegramImport(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return httpClient.request('/import/telegram/analyze', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
static async executeTelegramImport(req: { token: string; mapping: Record<string, string>; groupName?: string }) {
|
||||
return httpClient.request('/import/telegram/execute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
|
||||
static async getTrendingGifs() {
|
||||
return httpClient.request<any>('/klipy/trending');
|
||||
}
|
||||
|
||||
static async searchKlipyGifs(query: string) {
|
||||
return httpClient.request<any>(`/klipy/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
export class HttpClient {
|
||||
private token: string | null = null;
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
|
||||
const { timeout = 30_000, ...fetchOptions } = options;
|
||||
const controller = new AbortController();
|
||||
const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : undefined;
|
||||
|
||||
const isFormData = fetchOptions.body instanceof FormData;
|
||||
const computedHeaders: Record<string, string> = {
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
...(fetchOptions.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (!isFormData && !computedHeaders['Content-Type']) {
|
||||
computedHeaders['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...fetchOptions,
|
||||
headers: computedHeaders,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Время ожидания запроса истекло');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMessage = errorData.error || errorData.message || 'Ошибка запроса';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export const httpClient = new HttpClient();
|
||||
@@ -0,0 +1,565 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type Lang = 'ru' | 'en';
|
||||
|
||||
const LANG_KEY = 'knot_language';
|
||||
|
||||
const translations = {
|
||||
ru: {
|
||||
// Side menu
|
||||
myProfile: 'Мой профиль',
|
||||
settings: 'Настройки',
|
||||
aboutApp: 'О приложении',
|
||||
logout: 'Выйти из аккаунта',
|
||||
// Profile
|
||||
name: 'Имя',
|
||||
username: 'Имя пользователя',
|
||||
aboutMe: 'О себе',
|
||||
birthday: 'Дата рождения',
|
||||
enterName: 'Введите имя',
|
||||
tellAboutYourself: 'Расскажите о себе',
|
||||
removePhoto: 'Удалить фото',
|
||||
// Settings
|
||||
language: 'Язык / Language',
|
||||
interfaceLang: 'Язык интерфейса',
|
||||
theme: 'Оформление',
|
||||
russian: 'Русский',
|
||||
english: 'English',
|
||||
about: 'О приложении',
|
||||
version: 'Версия',
|
||||
modernMessenger: 'Современный мессенджер с фокусом',
|
||||
onPrivacy: 'на приватность и удобство',
|
||||
modernMessengerShort: 'Современный мессенджер',
|
||||
// Chat
|
||||
chat: 'Чат',
|
||||
group: 'Группа',
|
||||
searchChats: 'Поиск чатов...',
|
||||
noChats: 'Нет чатов — создайте первый!',
|
||||
nothingFound: 'Ничего не найдено',
|
||||
selectChat: 'Выберите чат для начала общения',
|
||||
noMessages: 'Нет сообщений — напишите первое!',
|
||||
typing: 'печатает...',
|
||||
online: 'в сети',
|
||||
wasRecently: 'был(а) недавно',
|
||||
members: 'участников',
|
||||
files: 'файлов',
|
||||
clearAll: 'Очистить всё',
|
||||
messagePlaceholder: 'Сообщение...',
|
||||
message: 'Сообщение...',
|
||||
addCaption: 'Добавьте подпись...',
|
||||
searchMessages: 'Поиск сообщений...',
|
||||
searchMessagesBtn: 'Поиск сообщений',
|
||||
userProfile: 'Профиль пользователя',
|
||||
enableSound: 'Включить звук',
|
||||
disableSound: 'Выключить звук',
|
||||
deleteChat: 'Удалить чат',
|
||||
// Messages
|
||||
messageDeleted: 'Сообщение удалено',
|
||||
reply: 'Ответить',
|
||||
copy: 'Копировать',
|
||||
edit: 'Редактировать',
|
||||
delete: 'Удалить',
|
||||
deleteForMe: 'Удалить у меня',
|
||||
deleteForAll: 'Удалить у всех',
|
||||
deleteAlsoFor: 'Удалить также для',
|
||||
editing: 'Редактирование',
|
||||
replyTo: 'Ответ',
|
||||
media: 'Медиа',
|
||||
download: 'Скачать',
|
||||
edited: 'ред.',
|
||||
selected: 'выбрано',
|
||||
fileLabel: 'Файл',
|
||||
kb: 'КБ',
|
||||
voice: '🎤 Голосовое',
|
||||
photo: '🖼 Фото',
|
||||
video: '🎬 Видео',
|
||||
file: 'Файл',
|
||||
// Call
|
||||
call: 'Звонок',
|
||||
videoCall: 'Видеозвонок',
|
||||
incomingCall: 'Входящий звонок',
|
||||
incomingVideoCall: 'Входящий видеозвонок',
|
||||
calling: 'Вызов...',
|
||||
accept: 'Принять',
|
||||
decline: 'Отклонить',
|
||||
endCall: 'Завершить',
|
||||
callEnded: 'Звонок завершён',
|
||||
callDeclined: 'Звонок отклонён',
|
||||
// Photo/video
|
||||
photoVideo: 'Фото / видео',
|
||||
fileBtn: 'Файл',
|
||||
sendError: 'Ошибка отправки',
|
||||
// New chat
|
||||
newChat: 'Новый чат',
|
||||
menu: 'Меню',
|
||||
// Date picker
|
||||
months: ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'],
|
||||
weekDays: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'],
|
||||
clear: 'Очистить',
|
||||
today: 'Сегодня',
|
||||
// New chat
|
||||
newChatTitle: 'Новый чат',
|
||||
selectMembers: 'Выберите участников',
|
||||
newGroup: 'Новая группа',
|
||||
groupNamePlaceholder: 'Название группы...',
|
||||
membersCount: 'Участники',
|
||||
createGroup: 'Создать группу',
|
||||
upTo200: 'До 200 участников',
|
||||
findUser: 'Найти пользователя по имени или @username...',
|
||||
addMembers: 'Добавить участников...',
|
||||
usersNotFound: 'Пользователи не найдены',
|
||||
enterNameOrUsername: 'Введите имя или @username',
|
||||
next: 'Далее',
|
||||
pinMessage: 'Закрепить',
|
||||
unpinMessage: 'Открепить',
|
||||
pinnedMessage: 'Закреплённое сообщение',
|
||||
forwardMessage: 'Переслать сообщение',
|
||||
forward: 'Переслать',
|
||||
forwardedFrom: 'Переслано от',
|
||||
replyWithQuote: 'Ответить с цитатой',
|
||||
select: 'Выбрать',
|
||||
sending: 'Отправка...',
|
||||
scheduleMessage: 'Запланировать',
|
||||
scheduleSend: 'Отправить по расписанию',
|
||||
scheduled: 'Запланировано',
|
||||
myStory: 'Моя история',
|
||||
newStory: 'Новая история',
|
||||
textStory: 'Текст',
|
||||
imageStory: 'Фото',
|
||||
mediaStory: 'Медиа',
|
||||
typeYourStory: 'Напишите историю...',
|
||||
publishStory: 'Опубликовать',
|
||||
stories: 'Истории',
|
||||
clearChat: 'Очистить чат',
|
||||
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
|
||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
||||
pinChat: 'Закрепить чат',
|
||||
unpinChat: 'Открепить чат',
|
||||
chatCleared: 'Чат очищен',
|
||||
groupSettings: 'Настройки группы',
|
||||
editGroupName: 'Изменить название',
|
||||
addMember: 'Добавить участника',
|
||||
removeMember: 'Удалить из группы',
|
||||
leaveGroup: 'Покинуть группу',
|
||||
adminBadge: 'Админ',
|
||||
groupDescription: 'Описание',
|
||||
noDescription: 'Нет описания',
|
||||
memberBadge: 'Участник',
|
||||
screenShare: 'Демонстрация экрана',
|
||||
stopScreenShare: 'Остановить демонстрацию',
|
||||
switchCamera: 'Выбрать камеру',
|
||||
showInChat: 'Показать в чате',
|
||||
minimize: 'Свернуть',
|
||||
volume: 'Громкость',
|
||||
mute: 'Выключить микрофон',
|
||||
unmute: 'Включить микрофон',
|
||||
noiseSuppressionOn: 'Шумоподавление включено',
|
||||
noiseSuppressionOff: 'Шумоподавление выключено',
|
||||
selectMicrophone: 'Выбрать микрофон',
|
||||
microphone: 'Микрофон',
|
||||
rightClickVolume: 'ПКМ для регулировки громкости',
|
||||
participants: 'участников',
|
||||
joinCall: 'Присоединиться к звонку',
|
||||
activeCall: 'Активный звонок',
|
||||
groupNameRequired: 'Введите название группы',
|
||||
confirmRemoveMember: 'Удалить этого участника из группы?',
|
||||
yes: 'Да',
|
||||
no: 'Нет',
|
||||
you: 'вы',
|
||||
// User profile
|
||||
mediaTab: 'Медиа',
|
||||
gifs: 'GIF',
|
||||
filesTab: 'Файлы',
|
||||
linksTab: 'Ссылки',
|
||||
profileTitle: 'Профиль',
|
||||
removeAvatar: 'Удалить аватар',
|
||||
namePlaceholder: 'Имя',
|
||||
notSpecified: 'Не указано',
|
||||
onKnotSince: 'На Knot с',
|
||||
cancel: 'Отмена',
|
||||
confirm: 'Подтвердить',
|
||||
save: 'Сохранить',
|
||||
sharedPhotos: 'Общие фото и видео будут здесь',
|
||||
sharedFiles: 'Общие файлы будут здесь',
|
||||
sharedLinks: 'Общие ссылки будут здесь',
|
||||
profileNotFound: 'Профиль не найден',
|
||||
storiesTab: 'Истории',
|
||||
publicationsTab: 'Публикации',
|
||||
noStories: 'Публикаций пока нет',
|
||||
goToMessage: 'Перейти к сообщению',
|
||||
story: 'История',
|
||||
commonGroups: 'Общие группы',
|
||||
more: 'Ещё',
|
||||
// Typing
|
||||
typingText: 'печатает',
|
||||
// Emoji
|
||||
emojiFrequent: 'Часто',
|
||||
emojiGestures: 'Жесты',
|
||||
emojiEmotions: 'Эмоции',
|
||||
emojiObjects: 'Предметы',
|
||||
emojiFood: 'Еда',
|
||||
emojiNature: 'Природа',
|
||||
// Auth
|
||||
login: 'Вход',
|
||||
register: 'Регистрация',
|
||||
latinOnly: '(латиница, нельзя изменить)',
|
||||
displayNameLabel: 'Отображаемое имя',
|
||||
displayNamePlaceholder: 'Ваше имя (любой язык)',
|
||||
password: 'Пароль',
|
||||
passwordPlaceholder: 'Введите пароль',
|
||||
bioPlaceholder: 'Расскажите о себе (необязательно)',
|
||||
loginBtn: 'Войти',
|
||||
createAccount: 'Создать аккаунт',
|
||||
testAccounts: 'Тестовые аккаунты: evgeniy, anastasia, artem, polina',
|
||||
passwordForAll: 'Пароль для всех:',
|
||||
dontHaveAccount: 'Нет аккаунта?',
|
||||
alreadyHaveAccount: 'Уже есть аккаунт?',
|
||||
passwordRequirements: 'Минимум 8 символов, буквы и цифры',
|
||||
registerNow: 'Зарегистрироваться',
|
||||
loginNow: 'Войти',
|
||||
// File/upload
|
||||
fileTooLarge: 'Файл слишком большой (макс. 200МБ)',
|
||||
dropFileHere: 'Отпустите файл здесь',
|
||||
uploading: 'Загрузка...',
|
||||
uploadError: 'Ошибка загрузки файла',
|
||||
changePhoto: 'Сменить фото',
|
||||
remove: 'Удалить',
|
||||
// Formatting
|
||||
formatBold: 'Жирный',
|
||||
formatBoldHint: '**текст**',
|
||||
formatItalic: 'Курсив',
|
||||
formatItalicHint: '_текст_',
|
||||
formatStrike: 'Зачёркнут',
|
||||
formatStrikeHint: '~текст~',
|
||||
formatMono: 'Моноширинный',
|
||||
formatMonoHint: '`текст`',
|
||||
// Draft
|
||||
draft: 'Черновик:',
|
||||
// Date
|
||||
datePlaceholder: 'дд.мм.гггг',
|
||||
// GIF / Emoji
|
||||
tenorKeyRequired: 'Для GIF нужен Tenor API ключ',
|
||||
openConsoleRun: 'Откройте консоль и выполните:',
|
||||
searchGifs: 'Поиск GIF...',
|
||||
trending: 'Популярные',
|
||||
// Misc
|
||||
error: 'Ошибка',
|
||||
// Friends
|
||||
friends: 'Контакты',
|
||||
friendRequests: 'Заявки в контакты',
|
||||
friendsList: 'Список контактов',
|
||||
noFriends: 'Пока нет контактов',
|
||||
addFriend: 'Добавить в контакты',
|
||||
removeFriend: 'Удалить из контактов',
|
||||
requestSent: 'Заявка отправлена',
|
||||
searchFriends: 'Поиск по @username (мин. 3 символа)',
|
||||
noSearchResults: 'Пользователи не найдены',
|
||||
minCharsHint: 'Введите минимум 3 символа после @',
|
||||
// Story viewers
|
||||
storyViewers: 'Кто просмотрел',
|
||||
noViewers: 'Пока никто не посмотрел',
|
||||
replyToStory: 'Ответить на историю...',
|
||||
send: 'Отправить',
|
||||
// Favorites
|
||||
favorites: 'Избранное',
|
||||
favoritesDescription: 'Сохраняйте важные сообщения здесь',
|
||||
// Privacy
|
||||
privacy: 'Приватность',
|
||||
hideStoryViews: 'Скрывать просмотры историй',
|
||||
hideStoryViewsDesc: 'Другие не увидят, что вы смотрели их историю',
|
||||
// Scheduled
|
||||
scheduledFor: 'Запланировано на',
|
||||
messageScheduled: 'Сообщение запланировано',
|
||||
scheduledDelivered: 'Сообщение отправлено для',
|
||||
scheduledDeliveredAt: 'в',
|
||||
scheduleIn1h: 'Через 1 час',
|
||||
scheduleIn3h: 'Через 3 часа',
|
||||
scheduleTomorrow: 'Завтра в 9:00',
|
||||
scheduleCustom: 'Выбрать дату и время',
|
||||
scheduleTime: 'Время',
|
||||
scheduleDate: 'Дата',
|
||||
// Last seen
|
||||
lastSeenAt: 'был(а)',
|
||||
justNow: 'только что',
|
||||
loadStoriesError: 'Ошибка загрузки историй',
|
||||
loadChatsError: 'Ошибка загрузки чатов',
|
||||
loadMessagesError: 'Ошибка загрузки сообщений',
|
||||
unreadMessages: 'Непрочитанные сообщения',
|
||||
},
|
||||
en: {
|
||||
myProfile: 'My Profile',
|
||||
settings: 'Settings',
|
||||
aboutApp: 'About',
|
||||
logout: 'Log Out',
|
||||
name: 'Name',
|
||||
username: 'Username',
|
||||
aboutMe: 'About me',
|
||||
birthday: 'Birthday',
|
||||
enterName: 'Enter name',
|
||||
tellAboutYourself: 'Tell about yourself',
|
||||
removePhoto: 'Remove photo',
|
||||
language: 'Language',
|
||||
interfaceLang: 'Interface language',
|
||||
theme: 'Theme',
|
||||
russian: 'Русский',
|
||||
english: 'English',
|
||||
about: 'About',
|
||||
version: 'Version',
|
||||
modernMessenger: 'A modern messenger focused',
|
||||
onPrivacy: 'on privacy and convenience',
|
||||
modernMessengerShort: 'Knot Messenger',
|
||||
searchChats: 'Search chats...',
|
||||
chat: 'Chat',
|
||||
group: 'Group',
|
||||
noChats: 'No chats — create one!',
|
||||
nothingFound: 'Nothing found',
|
||||
selectChat: 'Select a chat to start messaging',
|
||||
noMessages: 'No messages — write the first one!',
|
||||
typing: 'typing...',
|
||||
online: 'online',
|
||||
wasRecently: 'was recently',
|
||||
members: 'members',
|
||||
files: 'files',
|
||||
clearAll: 'Clear all',
|
||||
messagePlaceholder: 'Message...',
|
||||
message: 'Message...',
|
||||
addCaption: 'Add a caption...',
|
||||
searchMessages: 'Search messages...',
|
||||
searchMessagesBtn: 'Search messages',
|
||||
userProfile: 'User profile',
|
||||
enableSound: 'Enable sound',
|
||||
disableSound: 'Mute',
|
||||
deleteChat: 'Delete chat',
|
||||
messageDeleted: 'Message deleted',
|
||||
reply: 'Reply',
|
||||
copy: 'Copy',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
deleteForMe: 'Delete for me',
|
||||
deleteForAll: 'Delete for everyone',
|
||||
deleteAlsoFor: 'Delete also for',
|
||||
editing: 'Editing',
|
||||
replyTo: 'Reply',
|
||||
media: 'Media',
|
||||
download: 'Download',
|
||||
edited: 'edit.',
|
||||
selected: 'selected',
|
||||
fileLabel: 'File',
|
||||
kb: 'KB',
|
||||
voice: '🎤 Voice',
|
||||
photo: '🖼 Photo',
|
||||
video: '🎬 Video',
|
||||
file: 'File',
|
||||
call: 'Call',
|
||||
videoCall: 'Video call',
|
||||
incomingCall: 'Incoming call',
|
||||
incomingVideoCall: 'Incoming video call',
|
||||
calling: 'Calling...',
|
||||
accept: 'Accept',
|
||||
decline: 'Decline',
|
||||
endCall: 'End call',
|
||||
callEnded: 'Call ended',
|
||||
callDeclined: 'Call declined',
|
||||
photoVideo: 'Photo / video',
|
||||
fileBtn: 'File',
|
||||
sendError: 'Send error',
|
||||
newChat: 'New chat',
|
||||
menu: 'Menu',
|
||||
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
|
||||
weekDays: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],
|
||||
clear: 'Clear',
|
||||
today: 'Today',
|
||||
newChatTitle: 'New Chat',
|
||||
selectMembers: 'Select members',
|
||||
newGroup: 'New Group',
|
||||
groupNamePlaceholder: 'Group name...',
|
||||
membersCount: 'Members',
|
||||
createGroup: 'Create Group',
|
||||
upTo200: 'Up to 200 members',
|
||||
findUser: 'Find user by name or @username...',
|
||||
addMembers: 'Add members...',
|
||||
usersNotFound: 'Users not found',
|
||||
enterNameOrUsername: 'Enter name or @username',
|
||||
next: 'Next',
|
||||
pinMessage: 'Pin',
|
||||
unpinMessage: 'Unpin',
|
||||
pinnedMessage: 'Pinned message',
|
||||
forwardMessage: 'Forward message',
|
||||
forward: 'Forward',
|
||||
forwardedFrom: 'Forwarded from',
|
||||
replyWithQuote: 'Reply with quote',
|
||||
select: 'Select',
|
||||
sending: 'Sending...',
|
||||
scheduleMessage: 'Schedule',
|
||||
scheduleSend: 'Send scheduled',
|
||||
scheduled: 'Scheduled',
|
||||
myStory: 'My story',
|
||||
newStory: 'New story',
|
||||
textStory: 'Text',
|
||||
imageStory: 'Photo',
|
||||
mediaStory: 'Media',
|
||||
typeYourStory: 'Write your story...',
|
||||
publishStory: 'Publish',
|
||||
stories: 'Stories',
|
||||
clearChat: 'Clear chat',
|
||||
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
|
||||
deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
|
||||
pinChat: 'Pin chat',
|
||||
unpinChat: 'Unpin chat',
|
||||
chatCleared: 'Chat cleared',
|
||||
groupSettings: 'Group settings',
|
||||
editGroupName: 'Edit name',
|
||||
addMember: 'Add member',
|
||||
removeMember: 'Remove from group',
|
||||
leaveGroup: 'Leave group',
|
||||
adminBadge: 'Admin',
|
||||
groupDescription: 'Description',
|
||||
noDescription: 'No description',
|
||||
memberBadge: 'Member',
|
||||
screenShare: 'Screen share',
|
||||
stopScreenShare: 'Stop sharing',
|
||||
switchCamera: 'Switch camera',
|
||||
showInChat: 'Show in chat',
|
||||
minimize: 'Minimize',
|
||||
volume: 'Volume',
|
||||
mute: 'Mute',
|
||||
unmute: 'Unmute',
|
||||
noiseSuppressionOn: 'Noise suppression on',
|
||||
noiseSuppressionOff: 'Noise suppression off',
|
||||
selectMicrophone: 'Select microphone',
|
||||
microphone: 'Microphone',
|
||||
rightClickVolume: 'Right-click to adjust volume',
|
||||
participants: 'participants',
|
||||
joinCall: 'Join call',
|
||||
activeCall: 'Active call',
|
||||
groupNameRequired: 'Enter a group name',
|
||||
confirmRemoveMember: 'Remove this member from the group?',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
you: 'you',
|
||||
mediaTab: 'Media',
|
||||
gifs: 'GIF',
|
||||
filesTab: 'Files',
|
||||
linksTab: 'Links',
|
||||
profileTitle: 'Profile',
|
||||
removeAvatar: 'Remove avatar',
|
||||
namePlaceholder: 'Name',
|
||||
notSpecified: 'Not specified',
|
||||
onKnotSince: 'On Knot since',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
save: 'Save',
|
||||
sharedPhotos: 'Shared photos and videos will appear here',
|
||||
sharedFiles: 'Shared files will appear here',
|
||||
sharedLinks: 'Shared links will appear here',
|
||||
profileNotFound: 'Profile not found',
|
||||
storiesTab: 'Stories',
|
||||
publicationsTab: 'Publications',
|
||||
noStories: 'No stories yet',
|
||||
goToMessage: 'Go to message',
|
||||
story: 'Story',
|
||||
commonGroups: 'Common groups',
|
||||
more: 'More',
|
||||
typingText: 'typing',
|
||||
emojiFrequent: 'Frequent',
|
||||
emojiGestures: 'Gestures',
|
||||
emojiEmotions: 'Emotions',
|
||||
emojiObjects: 'Objects',
|
||||
emojiFood: 'Food',
|
||||
emojiNature: 'Nature',
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
latinOnly: '(latin only, cannot be changed)',
|
||||
displayNameLabel: 'Display name',
|
||||
displayNamePlaceholder: 'Your name (any language)',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'Enter password',
|
||||
bioPlaceholder: 'Tell about yourself (optional)',
|
||||
loginBtn: 'Login',
|
||||
createAccount: 'Create account',
|
||||
testAccounts: 'Test accounts: evgeniy, anastasia, artem, polina',
|
||||
passwordForAll: 'Password for all:',
|
||||
dontHaveAccount: "Don't have an account?",
|
||||
alreadyHaveAccount: 'Already have an account?',
|
||||
passwordRequirements: 'At least 8 characters, letters and numbers',
|
||||
registerNow: 'Register',
|
||||
loginNow: 'Login',
|
||||
fileTooLarge: 'File too large (max 200MB)',
|
||||
dropFileHere: 'Drop file here',
|
||||
uploading: 'Uploading...',
|
||||
uploadError: 'File upload error',
|
||||
changePhoto: 'Change photo',
|
||||
remove: 'Remove',
|
||||
formatBold: 'Bold',
|
||||
formatBoldHint: '**text**',
|
||||
formatItalic: 'Italic',
|
||||
formatItalicHint: '_text_',
|
||||
formatStrike: 'Strikethrough',
|
||||
formatStrikeHint: '~text~',
|
||||
formatMono: 'Monospace',
|
||||
formatMonoHint: '`text`',
|
||||
draft: 'Draft:',
|
||||
datePlaceholder: 'dd.mm.yyyy',
|
||||
tenorKeyRequired: 'Tenor API key required for GIFs',
|
||||
openConsoleRun: 'Open console and run:',
|
||||
searchGifs: 'Search GIFs...',
|
||||
trending: 'Trending',
|
||||
error: 'Error',
|
||||
friends: 'Contacts',
|
||||
friendRequests: 'Contact requests',
|
||||
friendsList: 'Contacts list',
|
||||
noFriends: 'No contacts yet',
|
||||
addFriend: 'Add to contacts',
|
||||
removeFriend: 'Remove from contacts',
|
||||
requestSent: 'Request sent',
|
||||
searchFriends: 'Search by @username (min. 3 chars)',
|
||||
noSearchResults: 'No users found',
|
||||
minCharsHint: 'Enter at least 3 characters after @',
|
||||
storyViewers: 'Who viewed',
|
||||
noViewers: 'No one viewed yet',
|
||||
replyToStory: 'Reply to story...',
|
||||
send: 'Send',
|
||||
favorites: 'Favorites',
|
||||
favoritesDescription: 'Save important messages here',
|
||||
privacy: 'Privacy',
|
||||
hideStoryViews: 'Hide story views',
|
||||
hideStoryViewsDesc: 'Others won\'t see that you viewed their story',
|
||||
scheduledFor: 'Scheduled for',
|
||||
messageScheduled: 'Message scheduled',
|
||||
scheduledDelivered: 'Message sent to',
|
||||
scheduledDeliveredAt: 'at',
|
||||
scheduleIn1h: 'In 1 hour',
|
||||
scheduleIn3h: 'In 3 hours',
|
||||
scheduleTomorrow: 'Tomorrow at 9:00',
|
||||
scheduleCustom: 'Choose date & time',
|
||||
scheduleTime: 'Time',
|
||||
scheduleDate: 'Date',
|
||||
lastSeenAt: 'was',
|
||||
justNow: 'just now',
|
||||
loadStoriesError: 'Error loading stories',
|
||||
loadChatsError: 'Error loading chats',
|
||||
loadMessagesError: 'Error loading messages',
|
||||
unreadMessages: 'Unread messages',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof translations.ru;
|
||||
type TranslationValue<K extends TranslationKey> = (typeof translations.ru)[K];
|
||||
|
||||
interface LangState {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: <K extends TranslationKey>(key: K) => TranslationValue<K>;
|
||||
}
|
||||
|
||||
export const useLang = create<LangState>((set, get) => ({
|
||||
lang: (localStorage.getItem(LANG_KEY) as Lang) || 'ru',
|
||||
setLang: (lang) => {
|
||||
localStorage.setItem(LANG_KEY, lang);
|
||||
set({ lang });
|
||||
},
|
||||
t: (key) => {
|
||||
const { lang } = get();
|
||||
return (translations[lang][key] ?? translations.ru[key] ?? key) as TranslationValue<typeof key>;
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,49 @@
|
||||
export const getCroppedImg = async (
|
||||
imageSrc: string,
|
||||
pixelCrop: { x: number; y: number; width: number; height: number }
|
||||
): Promise<File | null> => {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set sizing
|
||||
canvas.width = pixelCrop.width;
|
||||
canvas.height = pixelCrop.height;
|
||||
|
||||
// Draw cropped image
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height
|
||||
);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const file = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
|
||||
resolve(file);
|
||||
}, 'image/jpeg', 0.95);
|
||||
});
|
||||
};
|
||||
|
||||
const createImage = (url: string): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => resolve(image));
|
||||
image.addEventListener('error', (error) => reject(error));
|
||||
image.setAttribute('crossOrigin', 'anonymous');
|
||||
image.src = url;
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
|
||||
|
||||
export interface SocketCompat {
|
||||
on(event: string, callback: (...args: any[]) => void): void;
|
||||
off(event: string, callback?: (...args: any[]) => void): void;
|
||||
emit(event: string, ...args: any[]): void;
|
||||
disconnect(): void;
|
||||
status: string;
|
||||
}
|
||||
|
||||
let connection: HubConnection | null = null;
|
||||
let socketWrapper: SocketCompat | null = null;
|
||||
|
||||
export function connectSocket(token: string): SocketCompat {
|
||||
if (connection && (connection.state === 'Connected' || connection.state === 'Connecting')) {
|
||||
return socketWrapper!;
|
||||
}
|
||||
|
||||
connection = new HubConnectionBuilder()
|
||||
.withUrl('/hubs/chat', {
|
||||
accessTokenFactory: () => token
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(LogLevel.Error)
|
||||
.build();
|
||||
|
||||
// Обертка для совместимости с Socket.io API
|
||||
socketWrapper = {
|
||||
on: (event: string, callback: (...args: any[]) => void) => {
|
||||
// console.log(`[SignalR] Registering handler for: ${event}`);
|
||||
connection?.on(event, callback);
|
||||
},
|
||||
off: (event: string, callback?: (...args: any[]) => void) => {
|
||||
if (callback) {
|
||||
connection?.off(event, callback);
|
||||
} else {
|
||||
connection?.off(event);
|
||||
}
|
||||
},
|
||||
emit: (event: string, ...args: any[]) => {
|
||||
const state = connection?.state;
|
||||
// console.log(`[SignalR] emit('${event}') called, connection state: ${state}`);
|
||||
if (connection?.state === 'Connected') {
|
||||
// В SignalR invoke возвращает Promise, но Socket.io emit - нет.
|
||||
// Мы просто запускаем и логируем ошибки.
|
||||
connection.invoke(event, ...args)
|
||||
// .then(() => console.log(`[SignalR] invoke('${event}') completed successfully`))
|
||||
.catch(err => console.error(`SignalR emit error (${event}):`, err));
|
||||
} else {
|
||||
// console.warn(`SignalR emit skipped (${event}): connection state is ${state}`);
|
||||
}
|
||||
},
|
||||
disconnect: () => {
|
||||
connection?.stop();
|
||||
},
|
||||
get status() {
|
||||
return connection?.state || 'Disconnected';
|
||||
}
|
||||
};
|
||||
|
||||
connection.start()
|
||||
.then(() => {
|
||||
})
|
||||
.catch(err => console.error('[SignalR] Connection error:', err.toString()));
|
||||
|
||||
connection.onclose((error) => {
|
||||
});
|
||||
|
||||
connection.onreconnecting((error) => {
|
||||
});
|
||||
|
||||
connection.onreconnected((connectionId) => {
|
||||
});
|
||||
|
||||
return socketWrapper;
|
||||
}
|
||||
|
||||
export function getSocket(): SocketCompat | null {
|
||||
return socketWrapper;
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
if (connection) {
|
||||
connection.stop();
|
||||
connection = null;
|
||||
socketWrapper = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { memo } from 'react';
|
||||
import { getInitials, generateAvatarColor } from '../../../utils/utils';
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string | null;
|
||||
name: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6 text-[10px]',
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
xl: 'w-20 h-20 text-xl',
|
||||
} as const;
|
||||
|
||||
const onlineDotSize = {
|
||||
xs: 'w-1.5 h-1.5 border',
|
||||
sm: 'w-2 h-2 border',
|
||||
md: 'w-2.5 h-2.5 border-2',
|
||||
lg: 'w-3 h-3 border-2',
|
||||
xl: 'w-4 h-4 border-2',
|
||||
} as const;
|
||||
|
||||
function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarProps) {
|
||||
const sizeClass = sizeClasses[size];
|
||||
const initials = getInitials(name || '?');
|
||||
const gradientClass = generateAvatarColor(name || '');
|
||||
|
||||
return (
|
||||
<div className={`relative shrink-0 ${className}`}>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
className={`${sizeClass} rounded-full object-cover`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`${sizeClass} rounded-full bg-gradient-to-br ${gradientClass} flex items-center justify-center text-white font-medium`}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
{online !== undefined && (
|
||||
<div
|
||||
className={`absolute bottom-0 right-0 ${onlineDotSize[size]} rounded-full border-surface ${
|
||||
online ? 'bg-emerald-500' : 'bg-zinc-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Avatar = memo(AvatarInner);
|
||||
export default Avatar;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useLang } from '../../../infrastructure/i18n';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
danger = true,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const { t } = useLang();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
transition={{ type: 'spring', duration: 0.35, bounce: 0.2 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="w-full max-w-[360px] mx-4 rounded-2xl bg-surface-secondary border border-border/50 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div className="p-5 flex flex-col items-center text-center">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3 ${danger ? 'bg-red-500/15' : 'bg-accent/15'}`}>
|
||||
<AlertTriangle size={24} className={danger ? 'text-red-400' : 'text-accent'} />
|
||||
</div>
|
||||
{title && (
|
||||
<h3 className="text-white text-base font-semibold mb-1">{title}</h3>
|
||||
)}
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">{message}</p>
|
||||
</div>
|
||||
<div className="flex border-t border-border/40">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 py-3 text-sm font-medium text-zinc-400 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
{cancelText || t('cancel')}
|
||||
</button>
|
||||
<div className="w-px bg-border/40" />
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`flex-1 py-3 text-sm font-medium transition-colors ${
|
||||
danger
|
||||
? 'text-red-400 hover:bg-red-500/10 hover:text-red-300'
|
||||
: 'text-accent hover:bg-accent/10'
|
||||
}`}
|
||||
>
|
||||
{confirmText || t('confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
|
||||
import { useLang } from '../../../infrastructure/i18n';
|
||||
|
||||
interface DatePickerProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}
|
||||
|
||||
type View = 'days' | 'months' | 'years';
|
||||
|
||||
export default function DatePicker({ value, onChange }: DatePickerProps) {
|
||||
const { t, lang } = useLang();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number; openUp: boolean } | null>(null);
|
||||
|
||||
const today = new Date();
|
||||
const parsed = value ? new Date(value) : null;
|
||||
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || today.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() || today.getMonth());
|
||||
const [view, setView] = useState<View>('days');
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => {
|
||||
const y = parsed?.getFullYear() || today.getFullYear();
|
||||
return y - (y % 24);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (ref.current && !ref.current.contains(target) && dropdownRef.current && !dropdownRef.current.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', handle), 0);
|
||||
return () => document.removeEventListener('click', handle);
|
||||
}, [open]);
|
||||
|
||||
// Reset view to days when reopened & compute position for portal
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setView('days');
|
||||
if (parsed) {
|
||||
setViewYear(parsed.getFullYear());
|
||||
setViewMonth(parsed.getMonth());
|
||||
setYearRangeStart(parsed.getFullYear() - (parsed.getFullYear() % 24));
|
||||
}
|
||||
// Compute dropdown position
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const dropdownHeight = 370;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const openUp = spaceBelow < dropdownHeight;
|
||||
setPos({
|
||||
top: openUp ? rect.top : rect.bottom + 8,
|
||||
left: rect.left,
|
||||
openUp,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setPos(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const months = t('months');
|
||||
const weekDays = t('weekDays');
|
||||
const shortMonths = useMemo(() => months.map(m => m.slice(0, 3)), [months]);
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const firstDayRaw = new Date(viewYear, viewMonth, 1).getDay();
|
||||
const firstDay = firstDayRaw === 0 ? 6 : firstDayRaw - 1;
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1); }
|
||||
else setViewMonth(viewMonth - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1); }
|
||||
else setViewMonth(viewMonth + 1);
|
||||
};
|
||||
|
||||
const selectDay = (day: number) => {
|
||||
const m = String(viewMonth + 1).padStart(2, '0');
|
||||
const d = String(day).padStart(2, '0');
|
||||
onChange(`${viewYear}-${m}-${d}`);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const isSelected = (day: number) => {
|
||||
if (!parsed) return false;
|
||||
return parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth && parsed.getDate() === day;
|
||||
};
|
||||
|
||||
const isToday = (day: number) => {
|
||||
return today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === day;
|
||||
};
|
||||
|
||||
const displayValue = parsed
|
||||
? parsed.toLocaleDateString(useLang.getState().lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
|
||||
const cells: (number | null)[] = [];
|
||||
for (let i = 0; i < firstDay; i++) cells.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
|
||||
|
||||
// 24 years per page
|
||||
const yearCells = useMemo(() => {
|
||||
const arr: number[] = [];
|
||||
for (let i = 0; i < 24; i++) arr.push(yearRangeStart + i);
|
||||
return arr;
|
||||
}, [yearRangeStart]);
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
if (view === 'days') {
|
||||
setView('months');
|
||||
} else if (view === 'months') {
|
||||
setYearRangeStart(viewYear - (viewYear % 24));
|
||||
setView('years');
|
||||
}
|
||||
};
|
||||
|
||||
const selectMonth = (monthIdx: number) => {
|
||||
setViewMonth(monthIdx);
|
||||
setView('days');
|
||||
};
|
||||
|
||||
const selectYear = (year: number) => {
|
||||
setViewYear(year);
|
||||
setView('months');
|
||||
};
|
||||
|
||||
const headerLabel = view === 'days'
|
||||
? `${months[viewMonth]} ${viewYear}`
|
||||
: view === 'months'
|
||||
? `${viewYear}`
|
||||
: `${yearRangeStart} — ${yearRangeStart + 23}`;
|
||||
|
||||
const handlePrev = () => {
|
||||
if (view === 'days') prevMonth();
|
||||
else if (view === 'months') setViewYear(y => y - 1);
|
||||
else setYearRangeStart(s => s - 24);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (view === 'days') nextMonth();
|
||||
else if (view === 'months') setViewYear(y => y + 1);
|
||||
else setYearRangeStart(s => s + 24);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-tertiary text-sm text-white border border-border hover:border-accent transition-colors text-left"
|
||||
>
|
||||
<Calendar size={14} className="text-zinc-500 flex-shrink-0" />
|
||||
<span className={displayValue ? 'text-white' : 'text-zinc-500'}>
|
||||
{displayValue || (lang === 'ru' ? 'дд.мм.гггг' : 'mm/dd/yyyy')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<AnimatePresence>
|
||||
{pos && (
|
||||
<motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, y: pos.openUp ? 8 : -8, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: pos.openUp ? 8 : -8, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed w-72 glass-strong rounded-xl shadow-2xl z-[9999] overflow-hidden border border-border"
|
||||
style={{
|
||||
left: pos.left,
|
||||
...(pos.openUp
|
||||
? { bottom: window.innerHeight - pos.top + 8 }
|
||||
: { top: pos.top }),
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<button type="button" onClick={handlePrev} className="p-1 rounded-lg hover:bg-surface-hover text-zinc-400 hover:text-white transition-colors">
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeaderClick}
|
||||
className={`text-sm font-medium text-white transition-colors ${view !== 'years' ? 'hover:text-accent cursor-pointer' : 'cursor-default'}`}
|
||||
>
|
||||
{headerLabel}
|
||||
</button>
|
||||
<button type="button" onClick={handleNext} className="p-1 rounded-lg hover:bg-surface-hover text-zinc-400 hover:text-white transition-colors">
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{/* ===== DAYS VIEW ===== */}
|
||||
{view === 'days' && (
|
||||
<motion.div
|
||||
key="days"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
>
|
||||
<div className="grid grid-cols-7 px-3 pt-2">
|
||||
{weekDays.map((d) => (
|
||||
<div key={d} className="text-center text-[11px] text-zinc-500 font-medium py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 px-3 pb-2">
|
||||
{cells.map((day, i) => (
|
||||
<div key={i} className="flex items-center justify-center">
|
||||
{day ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectDay(day)}
|
||||
className={`w-8 h-8 rounded-full text-sm flex items-center justify-center transition-all ${
|
||||
isSelected(day)
|
||||
? 'bg-accent text-white font-semibold shadow-lg shadow-accent/30'
|
||||
: isToday(day)
|
||||
? 'text-knot-400 font-semibold ring-1 ring-knot-500/50'
|
||||
: 'text-zinc-300 hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-8 h-8" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ===== MONTHS VIEW ===== */}
|
||||
{view === 'months' && (
|
||||
<motion.div
|
||||
key="months"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid grid-cols-3 gap-1 p-3"
|
||||
>
|
||||
{shortMonths.map((m, idx) => {
|
||||
const isCurrentMonth = viewYear === today.getFullYear() && idx === today.getMonth();
|
||||
const isSelectedMonth = parsed && viewYear === parsed.getFullYear() && idx === parsed.getMonth();
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => selectMonth(idx)}
|
||||
className={`py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
isSelectedMonth
|
||||
? 'bg-accent text-white shadow-lg shadow-accent/30'
|
||||
: isCurrentMonth
|
||||
? 'text-knot-400 ring-1 ring-knot-500/50'
|
||||
: 'text-zinc-300 hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ===== YEARS VIEW ===== */}
|
||||
{view === 'years' && (
|
||||
<motion.div
|
||||
key="years"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid grid-cols-4 gap-1 p-3"
|
||||
>
|
||||
{yearCells.map((yr) => {
|
||||
const isCurrentYear = yr === today.getFullYear();
|
||||
const isSelectedYear = parsed && yr === parsed.getFullYear();
|
||||
return (
|
||||
<button
|
||||
key={yr}
|
||||
type="button"
|
||||
onClick={() => selectYear(yr)}
|
||||
className={`py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isSelectedYear
|
||||
? 'bg-accent text-white shadow-lg shadow-accent/30'
|
||||
: isCurrentYear
|
||||
? 'text-knot-400 ring-1 ring-knot-500/50'
|
||||
: 'text-zinc-300 hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{yr}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange(''); setOpen(false); }}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
{t('clear')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const m = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(today.getDate()).padStart(2, '0');
|
||||
onChange(`${today.getFullYear()}-${m}-${d}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs text-knot-400 hover:text-knot-300 transition-colors"
|
||||
>
|
||||
{t('today')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Download, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface ImageLightboxProps {
|
||||
url?: string;
|
||||
images?: { url: string; type?: string }[];
|
||||
initialIndex?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ImageLightbox({ url, images, initialIndex = 0, onClose }: ImageLightboxProps) {
|
||||
const gallery = images && images.length > 0;
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
const currentUrl = gallery ? images![index].url : url!;
|
||||
const currentType = gallery ? images![index].type : undefined;
|
||||
const total = gallery ? images!.length : 1;
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (gallery) setIndex((i) => (i > 0 ? i - 1 : total - 1));
|
||||
}, [gallery, total]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (gallery) setIndex((i) => (i < total - 1 ? i + 1 : 0));
|
||||
}, [gallery, total]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft') goPrev();
|
||||
if (e.key === 'ArrowRight') goNext();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, goPrev, goNext]);
|
||||
|
||||
return createPortal(
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2 z-10">
|
||||
{gallery && total > 1 && (
|
||||
<span className="text-sm text-white/70 mr-2">{index + 1} / {total}</span>
|
||||
)}
|
||||
<a
|
||||
href={currentUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<Download size={20} />
|
||||
</a>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Left arrow */}
|
||||
{gallery && total > 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<ChevronLeft size={28} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Right arrow */}
|
||||
{gallery && total > 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<ChevronRight size={28} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentUrl}
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-w-[90vw] max-h-[90vh] flex items-center justify-center"
|
||||
>
|
||||
{currentType === 'video' ? (
|
||||
<video
|
||||
src={currentUrl}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-w-[90vw] max-h-[90vh] rounded-lg shadow-2xl"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={currentUrl}
|
||||
alt=""
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, AlertCircle, CheckCircle, AlertTriangle, Info } from 'lucide-react';
|
||||
import { useNotificationStore, NotificationType } from '../../../application/stores/notificationStore';
|
||||
|
||||
const icons: Record<NotificationType, React.ReactNode> = {
|
||||
info: <Info className="text-blue-400" size={20} />,
|
||||
success: <CheckCircle className="text-emerald-400" size={20} />,
|
||||
warning: <AlertTriangle className="text-amber-400" size={20} />,
|
||||
error: <AlertCircle className="text-rose-400" size={20} />,
|
||||
};
|
||||
|
||||
const colors: Record<NotificationType, string> = {
|
||||
info: 'border-blue-500/30 bg-blue-500/10',
|
||||
success: 'border-emerald-500/30 bg-emerald-500/10',
|
||||
warning: 'border-amber-500/30 bg-amber-500/10',
|
||||
error: 'border-rose-500/30 bg-rose-500/10',
|
||||
};
|
||||
|
||||
export default function NotificationProvider() {
|
||||
const { notifications, removeNotification } = useNotificationStore();
|
||||
|
||||
return (
|
||||
<div className="fixed top-6 right-6 z-[9999] flex flex-col gap-3 pointer-events-none w-full max-w-sm">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{notifications.map((n) => (
|
||||
<motion.div
|
||||
key={n.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
|
||||
className={`pointer-events-auto relative group overflow-hidden rounded-2xl border backdrop-blur-xl p-4 shadow-2xl ${colors[n.type]}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">{icons[n.type]}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 leading-relaxed">
|
||||
{n.message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeNotification(n.id)}
|
||||
className="flex-shrink-0 -mr-1 -mt-1 p-1 rounded-full text-white/30 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar for auto-hide */}
|
||||
{n.duration && n.duration > 0 && (
|
||||
<motion.div
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: 0 }}
|
||||
transition={{ duration: n.duration / 1000, ease: 'linear' }}
|
||||
className="absolute bottom-0 left-0 h-0.5 bg-white/20"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
User,
|
||||
Users,
|
||||
Settings,
|
||||
Languages,
|
||||
Info,
|
||||
LogOut,
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
Edit3,
|
||||
Check,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Calendar,
|
||||
AtSign,
|
||||
MessageSquare,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Palette,
|
||||
Sparkles,
|
||||
UserPlus,
|
||||
UserMinus,
|
||||
UserCheck,
|
||||
Clock,
|
||||
Search,
|
||||
Shield,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../../modules/auth/application/authStore';
|
||||
import { useFriendStore } from '../../../modules/friends/application/friendStore';
|
||||
import { useChatStore } from '../../../modules/chats/application/chatStore';
|
||||
import { UserApi } from '../../../modules/users/infrastructure/userApi';
|
||||
import { getSocket } from '../../infrastructure/socket';
|
||||
import { useLang } from '../../infrastructure/i18n';
|
||||
import { useThemeStore, ChatTheme } from '../../application/stores/themeStore';
|
||||
import DatePicker from '../components/ui/DatePicker';
|
||||
import TelegramImportModal from '../../../modules/users/presentation/components/TelegramImportModal';
|
||||
import type { User as UserType, UserPresence, FriendRequest, FriendWithId } from '../../domain/types';
|
||||
|
||||
type SideView = 'main' | 'profile' | 'settings' | 'about' | 'themes' | 'friends';
|
||||
|
||||
interface SideMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenProfile: () => void;
|
||||
}
|
||||
|
||||
export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuProps) {
|
||||
const { user, updateUser, logout } = useAuthStore();
|
||||
const { clearStore } = useChatStore();
|
||||
const { chatTheme, setChatTheme } = useThemeStore();
|
||||
const { t, lang, setLang } = useLang();
|
||||
|
||||
const [view, setView] = useState<SideView>('main');
|
||||
const [prevView, setPrevView] = useState<SideView>('main');
|
||||
const [themeIndex, setThemeIndex] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
|
||||
// Friends state
|
||||
const {
|
||||
friends,
|
||||
friendRequests,
|
||||
isLoading: friendsLoading,
|
||||
searchQuery: friendSearch,
|
||||
searchResults: friendSearchResults,
|
||||
isSearching: friendSearchLoading,
|
||||
setSearchQuery: setFriendSearch,
|
||||
loadFriends,
|
||||
acceptRequest: handleAcceptRequest,
|
||||
declineRequest: handleDeclineRequest,
|
||||
removeFriend: handleRemoveFriend,
|
||||
sendRequest: handleSendFriendRequest,
|
||||
searchFriends,
|
||||
clearSearch,
|
||||
initializeSocketEvents
|
||||
} = useFriendStore();
|
||||
|
||||
const themeCards: { id: ChatTheme; color: string; accent: string; name: string; nameEn: string; desc: string; descEn: string; animated?: boolean; gradient?: string }[] = [
|
||||
{ id: 'midnight', color: '#0f0f13', accent: '#6366f1', name: 'Полночь', nameEn: 'Midnight', desc: 'Тёмная тема с мягкими акцентами', descEn: 'Dark theme with soft accents' },
|
||||
{ id: 'ocean', color: '#0b172a', accent: '#3b82f6', name: 'Океан', nameEn: 'Ocean', desc: 'Глубокий синий с прохладными тонами', descEn: 'Deep blue with cool tones' },
|
||||
{ id: 'forest', color: '#0f1c15', accent: '#10b981', name: 'Лес', nameEn: 'Forest', desc: 'Природный зелёный и спокойствие', descEn: 'Natural green and serenity' },
|
||||
{ id: 'sunset', color: '#1f111a', accent: '#ec4899', gradient: 'linear-gradient(135deg, #1f111a, #150a0f)', name: 'Закат', nameEn: 'Sunset', desc: 'Тёплый розовый градиент заката', descEn: 'Warm pink sunset gradient' },
|
||||
{ id: 'classic', color: '#121215', accent: '#a1a1aa', name: 'Классика', nameEn: 'Classic', desc: 'Минималистичная монохромная тема', descEn: 'Minimalist monochrome theme' },
|
||||
{ id: 'neon', color: '#0b0f19', accent: '#8b5cf6', name: 'Неон', nameEn: 'Neon', desc: 'Фиолетовое свечение за курсором', descEn: 'Purple glow follows your cursor', animated: true },
|
||||
{ id: 'aurora', color: '#022c22', accent: '#10b981', gradient: 'linear-gradient(135deg, #022c22, #064e3b)', name: 'Аврора', nameEn: 'Aurora', desc: 'Северное сияние реагирует на мышь', descEn: 'Northern lights react to mouse', animated: true },
|
||||
{ id: 'cyber', color: '#000000', accent: '#f59e0b', name: 'Кибер', nameEn: 'Cyber', desc: 'Сетка и янтарное свечение мыши', descEn: 'Grid pattern with amber glow', animated: true },
|
||||
{ id: 'glass', color: '#0d1117', accent: '#3b82f6', name: 'Стекло', nameEn: 'Glass', desc: 'Плавное свечение следует за мышью', descEn: 'Smooth glow follows the cursor', animated: true },
|
||||
{ id: 'void', color: '#000000', accent: '#ffffff', name: 'Бездна', nameEn: 'Void', desc: 'Абсолютный мрак с точечным светом', descEn: 'Absolute darkness with spot light', animated: true },
|
||||
];
|
||||
|
||||
const changeView = (next: SideView) => {
|
||||
setPrevView(view);
|
||||
setView(next);
|
||||
if (next === 'themes') {
|
||||
const idx = themeCards.findIndex(tc => tc.id === chatTheme);
|
||||
if (idx >= 0) setThemeIndex(idx);
|
||||
}
|
||||
if (next === 'friends') {
|
||||
loadFriends();
|
||||
}
|
||||
};
|
||||
|
||||
// Friend search effect
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
searchFriends(friendSearch, user?.id);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [friendSearch, user?.id, searchFriends]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
const timer = setTimeout(() => { setView('main'); setPrevView('main'); }, 300);
|
||||
clearSearch();
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
// Load friend request count when menu opens
|
||||
useFriendStore.getState().loadFriends();
|
||||
}, [isOpen, clearSearch]);
|
||||
|
||||
// Real-time friend updates via socket
|
||||
useEffect(() => {
|
||||
const cleanup = initializeSocketEvents();
|
||||
return cleanup;
|
||||
}, [initializeSocketEvents]);
|
||||
|
||||
const handleLogout = () => {
|
||||
clearStore();
|
||||
logout();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const initials = (user?.displayName || user?.username || '??')
|
||||
.split(' ')
|
||||
.map((w: string) => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const menuItems = [
|
||||
{ icon: User, label: t('myProfile'), onClick: () => { onClose(); onOpenProfile(); } },
|
||||
{ icon: Users, label: t('friends'), onClick: () => changeView('friends'), badge: friendRequests.length > 0 ? friendRequests.length : undefined },
|
||||
|
||||
{ icon: Settings, label: t('settings'), onClick: () => changeView('settings') },
|
||||
{ divider: true },
|
||||
{ icon: Info, label: t('aboutApp'), subtitle: 'Knot Messenger v1.0', onClick: () => changeView('about') },
|
||||
];
|
||||
|
||||
// Slide direction for animations
|
||||
const slideDir = prevView === 'main' ? 1 : -1;
|
||||
const viewVariants = {
|
||||
enter: (dir: number) => ({ x: dir * 100, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: -dir * 100, opacity: 0 }),
|
||||
};
|
||||
|
||||
// ======= MAIN VIEW =======
|
||||
const renderMain = () => (
|
||||
<motion.div key="main" className="flex flex-col h-full" initial={false} animate="center" exit="exit" variants={viewVariants} custom={-1} transition={{ duration: 0.2 }}>
|
||||
{/* ── Premium header with avatar ── */}
|
||||
<div className="relative overflow-hidden flex-shrink-0">
|
||||
{/* Animated gradient backdrop */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-knot-500/40 via-purple-600/25 to-transparent pointer-events-none" />
|
||||
<div className="absolute -top-20 -right-20 w-56 h-56 bg-knot-500/15 rounded-full blur-[80px] pointer-events-none" />
|
||||
<div className="absolute -bottom-10 -left-10 w-40 h-40 bg-purple-600/10 rounded-full blur-[60px] pointer-events-none" />
|
||||
|
||||
<div className="relative p-6 pb-5">
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
{/* Avatar with glow ring */}
|
||||
<div className="relative group cursor-pointer" onClick={() => { onClose(); onOpenProfile(); }}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-accent via-purple-500 to-accent rounded-full opacity-60 blur group-hover:opacity-90 transition duration-500 animate-[spin_4s_linear_infinite]" />
|
||||
<div className="relative">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt="" className="w-[72px] h-[72px] rounded-full object-cover ring-[3px] ring-surface" />
|
||||
) : (
|
||||
<div className="w-[72px] h-[72px] rounded-full bg-gradient-to-br from-surface to-surface-secondary flex items-center justify-center ring-[3px] ring-surface relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-accent/20 to-purple-500/20" />
|
||||
<span className="relative z-10 text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-br from-white to-zinc-400 drop-shadow-md">{initials}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Online indicator */}
|
||||
<div className="absolute bottom-0 right-0 w-4 h-4 bg-emerald-500 rounded-full ring-[3px] ring-surface shadow-[0_0_8px_rgba(16,185,129,0.6)]" />
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-xl text-zinc-400 hover:text-white hover:bg-white/10 transition-all backdrop-blur-sm">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Name & username */}
|
||||
<h3 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-white/70 tracking-tight leading-tight">
|
||||
{user?.displayName || user?.username}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 mt-1.5">
|
||||
<AtSign size={12} className="text-knot-400" />
|
||||
<span className="text-sm font-medium text-knot-100/70">{user?.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom fade line */}
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* ── Menu items ── */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
|
||||
{menuItems.map((item, i) => {
|
||||
if ('divider' in item) return <div key={i} className="my-2 mx-2 h-px bg-gradient-to-r from-transparent via-white/8 to-transparent" />;
|
||||
const Icon = item.icon!;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={item.onClick}
|
||||
className="group w-full flex items-center gap-3.5 px-4 py-3 rounded-2xl text-left transition-all duration-200 hover:bg-white/[0.06] active:scale-[0.98]"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl bg-white/[0.06] group-hover:bg-knot-500/15 flex items-center justify-center transition-all duration-200 border border-white/[0.04] group-hover:border-knot-500/20">
|
||||
<Icon size={17} className="text-zinc-400 group-hover:text-knot-400 transition-colors duration-200" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-medium text-zinc-200 group-hover:text-white transition-colors">{item.label}</p>
|
||||
{item.subtitle && <p className="text-[11px] text-zinc-500 mt-0.5">{item.subtitle}</p>}
|
||||
</div>
|
||||
{'badge' in item && item.badge ? (
|
||||
<span className="bg-gradient-to-r from-knot-500 to-purple-600 text-white text-[11px] font-bold min-w-[22px] h-[22px] px-1.5 rounded-full flex items-center justify-center flex-shrink-0 shadow-[0_0_12px_rgba(168,85,247,0.4)]">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : (
|
||||
<ChevronRight size={15} className="text-zinc-600 group-hover:text-zinc-400 transition-colors flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Logout button ── */}
|
||||
<div className="px-3 pb-4 pt-1">
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-white/8 to-transparent mb-3" />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="group w-full flex items-center gap-3.5 px-4 py-3 rounded-2xl transition-all duration-200 hover:bg-red-500/[0.08] active:scale-[0.98]"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl bg-red-500/[0.08] group-hover:bg-red-500/15 flex items-center justify-center transition-all duration-200 border border-red-500/[0.06] group-hover:border-red-500/20">
|
||||
<LogOut size={17} className="text-red-400/70 group-hover:text-red-400 transition-colors duration-200" />
|
||||
</div>
|
||||
<span className="text-[13.5px] font-medium text-red-400/70 group-hover:text-red-400 transition-colors">{t('logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// ======= SETTINGS VIEW =======
|
||||
const renderSettings = () => (
|
||||
<motion.div key="settings" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<div className="h-14 flex items-center gap-3 px-4 border-b border-border flex-shrink-0">
|
||||
<button onClick={() => changeView('main')} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-white flex-1">{t('settings')}</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{/* Theme picker row */}
|
||||
<div className="px-4 py-1">
|
||||
<button
|
||||
onClick={() => changeView('themes')}
|
||||
className="w-full flex items-center gap-4 px-4 py-3.5 rounded-xl bg-surface-tertiary/50 hover:bg-surface-hover transition-colors group"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full flex items-center justify-center" style={{ backgroundColor: themeCards.find(t => t.id === chatTheme)?.accent || '#6366f1' }}>
|
||||
<Palette size={18} className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm font-medium text-zinc-200">{t('theme')}</p>
|
||||
<p className="text-xs text-zinc-500">{lang === 'ru' ? themeCards.find(tc => tc.id === chatTheme)?.name : themeCards.find(tc => tc.id === chatTheme)?.nameEn}</p>
|
||||
</div>
|
||||
<ChevronRight size={18} className="text-zinc-500 group-hover:text-zinc-300 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-3">
|
||||
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">{t('language')}</h4>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => setLang('ru')}
|
||||
className={`w-full flex items-center gap-4 px-3 py-3 rounded-xl transition-colors ${lang === 'ru' ? 'bg-knot-500/15 ring-1 ring-knot-500/30' : 'bg-surface-tertiary/50 hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="text-lg">🇷🇺</span>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm text-zinc-200">Русский</p>
|
||||
</div>
|
||||
{lang === 'ru' && <Check size={16} className="text-knot-400" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLang('en')}
|
||||
className={`w-full flex items-center gap-4 px-3 py-3 rounded-xl transition-colors ${lang === 'en' ? 'bg-knot-500/15 ring-1 ring-knot-500/30' : 'bg-surface-tertiary/50 hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="text-lg">🇬🇧</span>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm text-zinc-200">English</p>
|
||||
</div>
|
||||
{lang === 'en' && <Check size={16} className="text-knot-400" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Privacy */}
|
||||
<div className="px-5 py-3">
|
||||
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">{t('privacy')}</h4>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const newVal = !user?.hideStoryViews;
|
||||
try {
|
||||
await UserApi.updateSettings({ hideStoryViews: newVal });
|
||||
useAuthStore.getState().updateUser({ hideStoryViews: newVal });
|
||||
} catch {}
|
||||
}}
|
||||
className="w-full flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50 hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<Eye size={18} className="text-zinc-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm text-zinc-200">{t('hideStoryViews')}</p>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">{t('hideStoryViewsDesc')}</p>
|
||||
</div>
|
||||
<div className={`w-10 h-6 rounded-full transition-colors flex items-center px-0.5 ${user?.hideStoryViews ? 'bg-knot-500' : 'bg-zinc-600'}`}>
|
||||
<div className={`w-5 h-5 rounded-full bg-white shadow transition-transform ${user?.hideStoryViews ? 'translate-x-4' : 'translate-x-0'}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border mt-2">
|
||||
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">Хранилище и данные</h4>
|
||||
<button
|
||||
onClick={() => { loadFriends(); setShowImportModal(true); }}
|
||||
className="w-full flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50 hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<MessageSquare size={16} className="text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm text-zinc-200">Импорт истории Telegram</p>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">Перенести сообщения и медиа</p>
|
||||
</div>
|
||||
<ChevronRight size={18} className="text-zinc-500 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3">
|
||||
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">{t('about')}</h4>
|
||||
<div className="flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50">
|
||||
<Info size={18} className="text-zinc-400" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-200">Knot Messenger</p>
|
||||
<p className="text-xs text-zinc-500">{t('version')} 1.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// ======= THEMES VIEW =======
|
||||
const renderThemes = () => {
|
||||
const currentCard = themeCards[themeIndex];
|
||||
const isActive = chatTheme === currentCard.id;
|
||||
return (
|
||||
<motion.div key="themes" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<div className="h-14 flex items-center gap-3 px-4 border-b border-border flex-shrink-0">
|
||||
<button onClick={() => changeView('settings')} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-white flex-1">{t('theme')}</h3>
|
||||
<span className="text-xs text-zinc-500 tabular-nums">{themeIndex + 1} / {themeCards.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-5 py-4 gap-4 overflow-hidden">
|
||||
{/* Preview card */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentCard.id}
|
||||
initial={{ opacity: 0, scale: 0.92, x: 40 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.92, x: -40 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
className="w-full rounded-2xl overflow-hidden border border-border/40 shadow-xl flex flex-col"
|
||||
style={{ minHeight: 200 }}
|
||||
>
|
||||
{/* Theme background preview */}
|
||||
<div
|
||||
className={`relative w-full h-32 chat-theme-${currentCard.id}`}
|
||||
style={currentCard.gradient ? { background: currentCard.gradient } : { backgroundColor: currentCard.color }}
|
||||
>
|
||||
{/* Fake chat bubbles */}
|
||||
<div className="absolute inset-0 p-4 flex flex-col justify-end gap-2">
|
||||
<div className="self-start max-w-[65%] px-3 py-2 rounded-2xl rounded-bl-md bg-white/10 backdrop-blur-sm">
|
||||
<p className="text-[11px] text-white/70">Hey! How's it going? 👋</p>
|
||||
</div>
|
||||
<div className="self-end max-w-[65%] px-3 py-2 rounded-2xl rounded-br-md" style={{ backgroundColor: currentCard.accent + '40' }}>
|
||||
<p className="text-[11px] text-white/80">Pretty great, thanks! ✨</p>
|
||||
</div>
|
||||
</div>
|
||||
{currentCard.animated && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 px-2 py-0.5 rounded-full bg-white/10 backdrop-blur-sm">
|
||||
<Sparkles size={10} className="text-yellow-400" />
|
||||
<span className="text-[9px] text-white/60 font-medium">{lang === 'ru' ? 'Интерактив' : 'Interactive'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className="p-4 bg-surface-secondary">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: currentCard.accent }} />
|
||||
<h3 className="text-base font-bold text-white">{lang === 'ru' ? currentCard.name : currentCard.nameEn}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 ml-6">{lang === 'ru' ? currentCard.desc : currentCard.descEn}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation arrows + select */}
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<button
|
||||
onClick={() => setThemeIndex(i => (i - 1 + themeCards.length) % themeCards.length)}
|
||||
className="p-2.5 rounded-xl bg-surface-tertiary/60 text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setChatTheme(currentCard.id); }}
|
||||
className={`flex-1 py-3 rounded-xl text-sm font-semibold transition-all duration-200 ${isActive
|
||||
? 'bg-accent/20 text-accent ring-1 ring-accent/40 cursor-default'
|
||||
: 'bg-accent text-white hover:bg-accent/90 shadow-lg shadow-accent/20'
|
||||
}`}
|
||||
disabled={isActive}
|
||||
>
|
||||
{isActive ? (lang === 'ru' ? '✓ Выбрано' : '✓ Selected') : (lang === 'ru' ? 'Применить' : 'Apply')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setThemeIndex(i => (i + 1) % themeCards.length)}
|
||||
className="p-2.5 rounded-xl bg-surface-tertiary/60 text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dot indicators */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{themeCards.map((tc, i) => (
|
||||
<button
|
||||
key={tc.id}
|
||||
onClick={() => setThemeIndex(i)}
|
||||
className={`rounded-full transition-all duration-200 ${i === themeIndex
|
||||
? 'w-6 h-2 bg-accent'
|
||||
: chatTheme === tc.id
|
||||
? 'w-2 h-2 bg-accent/50'
|
||||
: 'w-2 h-2 bg-zinc-600 hover:bg-zinc-500'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// ======= FRIENDS VIEW =======
|
||||
const renderFriends = () => (
|
||||
<motion.div key="friends" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<div className="h-14 flex items-center gap-3 px-4 border-b border-border flex-shrink-0">
|
||||
<button onClick={() => { changeView('main'); clearSearch(); }} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-white flex-1">{t('friends')}</h3>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchFriends')}
|
||||
value={friendSearch}
|
||||
onChange={(e) => setFriendSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{friendsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-zinc-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search results */}
|
||||
{friendSearch.trim().length > 0 && (
|
||||
<div className="px-4 pt-2 pb-2">
|
||||
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
<Search size={12} className="inline mr-1" />{t('searchFriends').split('(')[0].trim()}
|
||||
</h4>
|
||||
{(() => {
|
||||
const raw = friendSearch.trim();
|
||||
const q = raw.startsWith('@') ? raw.slice(1) : raw;
|
||||
if (q.length < 3) {
|
||||
return <p className="text-xs text-zinc-500 text-center py-3">{t('minCharsHint')}</p>;
|
||||
}
|
||||
if (friendSearchLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 size={18} className="animate-spin text-zinc-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (friendSearchResults.length === 0) {
|
||||
return <p className="text-xs text-zinc-500 text-center py-3">{t('noSearchResults')}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{friendSearchResults.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-border/50">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
{(u.displayName || u.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{u.displayName || u.username}</p>
|
||||
<p className="text-xs text-zinc-500">@{u.username}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSendFriendRequest(u.id)}
|
||||
className="p-2 rounded-lg bg-knot-500/20 text-knot-400 hover:bg-knot-500/30 transition-colors"
|
||||
title={t('addFriend')}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incoming requests */}
|
||||
{friendRequests.length > 0 && (
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
{t('friendRequests')} ({friendRequests.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{friendRequests.map((req) => (
|
||||
<div key={req.id} className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-border/50">
|
||||
{req.user.avatar ? (
|
||||
<img src={req.user.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
{(req.user.displayName || req.user.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{req.user.displayName || req.user.username}</p>
|
||||
<p className="text-xs text-zinc-500">@{req.user.username}</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => handleAcceptRequest(req.id)}
|
||||
className="p-2 rounded-lg bg-green-500/20 text-green-400 hover:bg-green-500/30 transition-colors"
|
||||
title={t('accept')}
|
||||
>
|
||||
<UserCheck size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeclineRequest(req.id)}
|
||||
className="p-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
||||
title={t('decline')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Friends list */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
{t('friendsList')} ({friends.length})
|
||||
</h4>
|
||||
{friends.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500 text-center py-8">{t('noFriends')}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{friends.map((friend) => (
|
||||
<div key={friend.id} className="flex items-center gap-3 p-3 rounded-xl hover:bg-white/5 transition-colors group/friend">
|
||||
<div className="relative">
|
||||
{friend.avatar ? (
|
||||
<img src={friend.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
{(friend.displayName || friend.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{friend.isOnline && (
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-green-500 border-2 border-surface-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{friend.displayName || friend.username}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{friend.isOnline ? t('online') : `@${friend.username}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveFriend(friend.friendshipId)}
|
||||
className="p-2 rounded-lg text-zinc-600 opacity-0 group-hover/friend:opacity-100 hover:bg-red-500/20 hover:text-red-400 transition-all"
|
||||
title={t('removeFriend')}
|
||||
>
|
||||
<UserMinus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// ======= ABOUT VIEW =======
|
||||
const renderAbout = () => (
|
||||
<motion.div key="about" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<div className="h-14 flex items-center gap-3 px-4 border-b border-border flex-shrink-0">
|
||||
<button onClick={() => changeView('main')} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-white flex-1">{t('aboutApp')}</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-accent/20 to-purple-600/20 flex items-center justify-center shadow-[0_0_30px_-5px_var(--color-accent)] ring-1 ring-white/10 relative overflow-hidden">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-white/[0.05] to-transparent pointer-events-none" />
|
||||
<MessageSquare size={36} className="text-accent drop-shadow-md relative z-10" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold gradient-text mb-1">Knot Messenger</h2>
|
||||
<p className="text-sm text-zinc-400 mb-6">{t('version')} 1.0.0</p>
|
||||
<div className="text-xs text-zinc-500 space-y-1">
|
||||
<p>{t('modernMessenger')}</p>
|
||||
<p>{t('onPrivacy')}</p>
|
||||
<p className="mt-4 text-zinc-600">© 2026 Knot Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 bg-black/60 z-50" onClick={onClose} />
|
||||
<motion.div
|
||||
initial={{ x: -320, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -320, opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed left-3 top-3 bottom-3 w-[340px] max-w-[calc(100vw-24px)] bg-surface-secondary/95 backdrop-blur-3xl shadow-[0_0_100px_rgba(0,0,0,0.5)] border border-border/50 rounded-3xl z-50 flex flex-col overflow-hidden"
|
||||
>
|
||||
<AnimatePresence mode="wait" custom={slideDir}>
|
||||
{view === 'main' && renderMain()}
|
||||
{view === 'settings' && renderSettings()}
|
||||
{view === 'themes' && renderThemes()}
|
||||
{view === 'friends' && renderFriends()}
|
||||
{view === 'about' && renderAbout()}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<TelegramImportModal isOpen={showImportModal} onClose={() => setShowImportModal(false)} friends={friends} />
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
X,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../../modules/auth/application/authStore';
|
||||
import { useChatStore } from '../../../modules/chats/application/chatStore';
|
||||
import { useNotificationStore } from '../../application/stores/notificationStore';
|
||||
import { useLang } from '../../infrastructure/i18n';
|
||||
import { StoryApi } from '../../../modules/stories/infrastructure/storyApi';
|
||||
import { getSocket } from '../../infrastructure/socket';
|
||||
import { getInitials, generateAvatarColor } from '../../utils/utils';
|
||||
import Avatar from '../components/ui/Avatar';
|
||||
import { StoryGroup } from '../../domain/types';
|
||||
import ChatListItem from '../../../modules/chats/presentation/components/ChatListItem';
|
||||
import NewChatModal from '../../../modules/chats/presentation/components/NewChatModal';
|
||||
import UserProfile from '../../../modules/users/presentation/components/UserProfile';
|
||||
import SideMenu from './SideMenu';
|
||||
import StoryViewer, { CreateStoryModal } from '../../../modules/stories/presentation/components/StoryViewer';
|
||||
import { useStoryStore } from '../../../modules/stories/application/storyStore';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { chats, activeChat, searchQuery, setSearchQuery, clearStore } = useChatStore();
|
||||
const { t } = useLang();
|
||||
const [showNewChat, setShowNewChat] = useState(false);
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [showSideMenu, setShowSideMenu] = useState(false);
|
||||
const { storyGroups, setStoryGroups, viewerIndex, viewerStoryIndex, openViewer, closeViewer } = useStoryStore();
|
||||
const [showCreateStory, setShowCreateStory] = useState(false);
|
||||
|
||||
const loadStories = () => {
|
||||
StoryApi.getStories()
|
||||
.then(setStoryGroups)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
addNotification('error', (t('loadStoriesError') || 'Failed to load stories') as string);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStories();
|
||||
const interval = setInterval(loadStories, 30000); // refresh every 30s
|
||||
|
||||
const socket = getSocket();
|
||||
const onStoryViewed = (data: any) => {
|
||||
// Refresh stories if I'm the owner or the viewer
|
||||
if (data.ownerId === user?.id || data.userId === user?.id) {
|
||||
loadStories();
|
||||
}
|
||||
};
|
||||
|
||||
socket?.on('story_viewed', onStoryViewed);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
socket?.off('story_viewed', onStoryViewed);
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const filteredChats = chats.filter((chat) => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
if (chat.name?.toLowerCase().includes(q)) return true;
|
||||
return chat.members.some(
|
||||
(m) =>
|
||||
m.user.id !== user?.id &&
|
||||
(m.user.username.toLowerCase().includes(q) ||
|
||||
m.user.displayName.toLowerCase().includes(q))
|
||||
);
|
||||
}).sort((a, b) => {
|
||||
// 1. Favorites chat always on top
|
||||
if (a.type === 'favorites') return -1;
|
||||
if (b.type === 'favorites') return 1;
|
||||
|
||||
// 2. Pinned chats next
|
||||
const aPinned = a.members.find(m => m.user.id === user?.id)?.isPinned;
|
||||
const bPinned = b.members.find(m => m.user.id === user?.id)?.isPinned;
|
||||
if (aPinned && !bPinned) return -1;
|
||||
if (!aPinned && bPinned) return 1;
|
||||
|
||||
// 3. Last message timestamp (if available) - though currently we don't have it on top level
|
||||
return 0;
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
clearStore();
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full flex flex-col bg-surface-secondary sm:rounded-[2rem] overflow-hidden border-x sm:border border-border/50 shadow-2xl relative z-10">
|
||||
{/* Шапка */}
|
||||
<div className="h-14 px-3 flex items-center justify-between border-b border-border/40 bg-surface-secondary flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowSideMenu(true)}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
title={t('menu')}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-xl bg-accent text-white flex-shrink-0">
|
||||
<MessageSquare size={16} fill="currentColor" strokeWidth={0} />
|
||||
</div>
|
||||
<h1 className="text-[16px] font-semibold text-white truncate tracking-tight">Knot Messenger</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewChat(true)}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
title={t('newChat')}
|
||||
>
|
||||
<Plus size={22} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="px-3 py-2 bg-surface-secondary">
|
||||
<div className="relative group">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchChats')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-8 py-1.5 rounded-[10px] bg-surface-tertiary text-[14px] text-white placeholder-zinc-500 border border-transparent focus:border-accent/50 hover:bg-surface-hover transition-all outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full text-zinc-400 hover:text-white transition-colors"
|
||||
title={t('clear')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Story circles */}
|
||||
{(storyGroups.length > 0 || true) && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 overflow-x-auto scrollbar-hide border-b border-border/20 flex-shrink-0">
|
||||
{/* Add story circle */}
|
||||
<button
|
||||
onClick={() => setShowCreateStory(true)}
|
||||
className="flex flex-col items-center gap-1.5 flex-shrink-0 group w-[60px]"
|
||||
>
|
||||
<div className="w-[50px] h-[50px] rounded-full border border-dashed border-zinc-600 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-colors">
|
||||
<Plus size={20} className="text-accent transition-colors" />
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 truncate w-full text-center">{t('newStory')}</span>
|
||||
</button>
|
||||
|
||||
{storyGroups.map((group, idx) => {
|
||||
const avatarUrl = group.user.avatar ? `${API_URL}${group.user.avatar}` : null;
|
||||
const isMine = group.user.id === user?.id;
|
||||
return (
|
||||
<button
|
||||
key={group.user.id}
|
||||
onClick={() => openViewer(idx)}
|
||||
className="flex flex-col items-center gap-1.5 flex-shrink-0 group w-[60px]"
|
||||
>
|
||||
<div className={`w-[50px] h-[50px] rounded-full flex items-center justify-center transition-transform group-hover:scale-[1.03] ${
|
||||
group.hasUnviewed
|
||||
? 'border-[2px] border-accent'
|
||||
: isMine
|
||||
? 'border border-zinc-600'
|
||||
: 'border border-zinc-700'
|
||||
}`}>
|
||||
<div className="w-[44px] h-[44px] rounded-full overflow-hidden">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={group.user.displayName || group.user.username}
|
||||
size="md"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 truncate w-full text-center">
|
||||
{isMine ? t('myStory') : (group.user.displayName || group.user.username).split(' ')[0]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список чатов */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredChats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 px-6">
|
||||
<MessageSquare size={40} className="opacity-30" />
|
||||
<p className="text-sm text-center">
|
||||
{searchQuery ? t('nothingFound') : t('noChats')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredChats.map((chat) => (
|
||||
<ChatListItem key={chat.id} chat={chat} isActive={chat.id === activeChat} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалки */}
|
||||
<AnimatePresence>
|
||||
{showNewChat && <NewChatModal onClose={() => setShowNewChat(false)} />}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{showProfile && <UserProfile userId={user!.id} onClose={() => setShowProfile(false)} isSelf />}
|
||||
</AnimatePresence>
|
||||
<SideMenu
|
||||
isOpen={showSideMenu}
|
||||
onClose={() => setShowSideMenu(false)}
|
||||
onOpenProfile={() => setShowProfile(true)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{viewerIndex !== null && storyGroups.length > 0 && (
|
||||
<StoryViewer
|
||||
stories={storyGroups}
|
||||
initialUserIndex={viewerIndex}
|
||||
initialStoryIndex={viewerStoryIndex}
|
||||
onClose={() => { closeViewer(); loadStories(); }}
|
||||
onRefresh={loadStories}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{showCreateStory && (
|
||||
<CreateStoryModal
|
||||
onClose={() => setShowCreateStory(false)}
|
||||
onCreated={loadStories}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Debounced value — updates value after the specified delay.
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced callback — returns a function that delays execution.
|
||||
*/
|
||||
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
|
||||
callback: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback((...args: Parameters<T>) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => callbackRef.current(...args), delay);
|
||||
}, [delay]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an AbortController that auto-aborts on unmount or when reset is called.
|
||||
*/
|
||||
export function useAbortController() {
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const getSignal = useCallback(() => {
|
||||
if (controllerRef.current) controllerRef.current.abort();
|
||||
controllerRef.current = new AbortController();
|
||||
return controllerRef.current.signal;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return getSignal;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Notification sound using Web Audio API — generates a pleasant chime
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
function getAudioContext(): AudioContext | null {
|
||||
if (typeof window !== 'undefined' && navigator && 'userActivation' in navigator) {
|
||||
if (!(navigator as any).userActivation.hasBeenActive) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioContext) {
|
||||
try {
|
||||
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (AudioCtx) {
|
||||
audioContext = new AudioCtx();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
export function playNotificationSound() {
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Soft, warm notification — lower frequencies, triangle waves, gentle volume
|
||||
// First note — warm mellow tone
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.type = 'triangle';
|
||||
osc1.frequency.setValueAtTime(523.25, now); // C5
|
||||
gain1.gain.setValueAtTime(0.08, now);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(ctx.destination);
|
||||
osc1.start(now);
|
||||
osc1.stop(now + 0.3);
|
||||
|
||||
// Second note — gentle higher tone
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.type = 'triangle';
|
||||
osc2.frequency.setValueAtTime(659.25, now + 0.08); // E5
|
||||
gain2.gain.setValueAtTime(0, now);
|
||||
gain2.gain.setValueAtTime(0.06, now + 0.08);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(ctx.destination);
|
||||
osc2.start(now + 0.08);
|
||||
osc2.stop(now + 0.35);
|
||||
} catch (e) {
|
||||
// Audio context not supported — silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// Muted chats stored in localStorage
|
||||
const MUTED_KEY = 'knot_muted_chats';
|
||||
|
||||
export function getMutedChats(): Set<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(MUTED_KEY);
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleMuteChat(chatId: string): boolean {
|
||||
const muted = getMutedChats();
|
||||
if (muted.has(chatId)) {
|
||||
muted.delete(chatId);
|
||||
} else {
|
||||
muted.add(chatId);
|
||||
}
|
||||
localStorage.setItem(MUTED_KEY, JSON.stringify([...muted]));
|
||||
return muted.has(chatId);
|
||||
}
|
||||
|
||||
export function isChatMuted(chatId: string): boolean {
|
||||
return getMutedChats().has(chatId);
|
||||
}
|
||||
|
||||
// Call ringtone
|
||||
let callAudio: HTMLAudioElement | null = null;
|
||||
|
||||
export function playCallRingtone() {
|
||||
try {
|
||||
if (callAudio) {
|
||||
callAudio.pause();
|
||||
callAudio.currentTime = 0;
|
||||
}
|
||||
callAudio = new Audio('/sounds/call_sound.mp3');
|
||||
callAudio.loop = true;
|
||||
callAudio.volume = 0.5;
|
||||
callAudio.play().catch(() => {});
|
||||
} catch (e) {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
export function stopCallRingtone() {
|
||||
try {
|
||||
if (callAudio) {
|
||||
callAudio.pause();
|
||||
callAudio.currentTime = 0;
|
||||
callAudio = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// "Абонент недоступен" sound
|
||||
export function playUnavailableSound(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const audio = new Audio('/sounds/abonent_nedostupen.mp3');
|
||||
audio.volume = 0.7;
|
||||
audio.onended = () => resolve();
|
||||
audio.onerror = () => resolve();
|
||||
audio.play().catch(() => resolve());
|
||||
} catch (e) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
export function formatTime(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return lang === 'ru' ? 'Сегодня' : 'Today';
|
||||
if (days === 1) return lang === 'ru' ? 'Вчера' : 'Yesterday';
|
||||
if (days < 7) {
|
||||
const weekDaysRu = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'];
|
||||
const weekDaysEn = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
return (lang === 'ru' ? weekDaysRu : weekDaysEn)[d.getDay()];
|
||||
}
|
||||
|
||||
return d.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: days > 365 ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatLastSeen(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
|
||||
if (minutes < 1) return lang === 'ru' ? 'только что' : 'just now';
|
||||
if (minutes < 60) return lang === 'ru' ? `${minutes} мин. назад` : `${minutes}m ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return lang === 'ru' ? `${hours} ч. назад` : `${hours}h ago`;
|
||||
|
||||
const at = lang === 'ru' ? ' в ' : ' at ';
|
||||
return formatDate(date, lang) + at + formatTime(date, lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips markdown syntax (**bold**, *italic*, _italic_, ~strikethrough~, `code`)
|
||||
* and returns plain text for use in previews.
|
||||
*/
|
||||
export function stripMarkdown(text: string): string {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/\*\*([\s\S]*?)\*\*/g, '$1')
|
||||
.replace(/\*([\s\S]*?)\*/g, '$1')
|
||||
.replace(/_([\s\S]*?)_/g, '$1')
|
||||
.replace(/~([\s\S]*?)~/g, '$1')
|
||||
.replace(/`([\s\S]*?)`/g, '$1');
|
||||
}
|
||||
|
||||
export function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
export function generateAvatarColor(name: string): string {
|
||||
const colors = [
|
||||
'from-violet-500 to-purple-600',
|
||||
'from-blue-500 to-indigo-600',
|
||||
'from-emerald-500 to-teal-600',
|
||||
'from-rose-500 to-pink-600',
|
||||
'from-amber-500 to-orange-600',
|
||||
'from-cyan-500 to-blue-600',
|
||||
'from-fuchsia-500 to-purple-600',
|
||||
'from-lime-500 to-green-600',
|
||||
];
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
// Waveform cache so we don't decode the same audio twice
|
||||
const waveformCache = new Map<string, number[]>();
|
||||
|
||||
/**
|
||||
* Decodes an audio file from a URL and extracts normalized waveform peak values.
|
||||
* Returns an array of `bars` values in [0, 1].
|
||||
*/
|
||||
export async function extractWaveform(url: string, bars: number = 28): Promise<number[]> {
|
||||
const cached = waveformCache.get(url);
|
||||
if (cached) return cached;
|
||||
|
||||
let audioCtx: any;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const OfflineCtx = window.OfflineAudioContext || (window as any).webkitOfflineAudioContext;
|
||||
if (OfflineCtx) {
|
||||
audioCtx = new OfflineCtx(1, 1, 44100);
|
||||
} else {
|
||||
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
|
||||
// We only need the buffer, so decode it
|
||||
const audioBuffer = await audioCtx!.decodeAudioData(arrayBuffer);
|
||||
|
||||
// If it was a regular AudioContext, close it.
|
||||
if (audioCtx.close) {
|
||||
await audioCtx.close();
|
||||
}
|
||||
audioCtx = undefined;
|
||||
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
const samplesPerBar = Math.floor(channelData.length / bars);
|
||||
const peaks: number[] = [];
|
||||
|
||||
for (let i = 0; i < bars; i++) {
|
||||
let peak = 0;
|
||||
const start = i * samplesPerBar;
|
||||
// Sample a subset for performance
|
||||
const step = Math.max(1, Math.floor(samplesPerBar / 200));
|
||||
for (let j = 0; j < samplesPerBar; j += step) {
|
||||
const abs = Math.abs(channelData[start + j] || 0);
|
||||
if (abs > peak) peak = abs;
|
||||
}
|
||||
peaks.push(peak);
|
||||
}
|
||||
|
||||
// Normalize to [0, 1]
|
||||
const max = Math.max(...peaks, 0.01);
|
||||
const normalized = peaks.map(p => p / max);
|
||||
waveformCache.set(url, normalized);
|
||||
return normalized;
|
||||
} catch {
|
||||
// Close leaked AudioContext if any
|
||||
if (audioCtx && audioCtx.close) audioCtx.close().catch(() => {});
|
||||
// On error, return uniform bars
|
||||
return Array(bars).fill(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
export function getMediaUrl(url: string | null | undefined): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('data:')) return url;
|
||||
|
||||
// Use VITE_API_URL if defined, otherwise let it be a relative path which the browser
|
||||
// will resolve against the current origin (port).
|
||||
const baseUrl = import.meta.env.VITE_API_URL || '';
|
||||
return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
|
||||
--color-knot-50: #eef2ff;
|
||||
--color-knot-100: #e0e7ff;
|
||||
--color-knot-200: #c7d2fe;
|
||||
--color-knot-300: #a5b4fc;
|
||||
--color-knot-400: #818cf8;
|
||||
--color-knot-500: #6366f1;
|
||||
--color-knot-600: #4f46e5;
|
||||
--color-knot-700: #4338ca;
|
||||
--color-knot-800: #3730a3;
|
||||
--color-knot-900: #312e81;
|
||||
--color-knot-950: #1e1b4b;
|
||||
|
||||
--color-surface: #09090b;
|
||||
--color-surface-secondary: #111113;
|
||||
--color-surface-tertiary: #1a1a1e;
|
||||
--color-surface-hover: #222226;
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-border-light: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-hover: #818cf8;
|
||||
--color-accent-light: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', Arial, sans-serif);
|
||||
background-color: var(--color-surface, #17212b);
|
||||
color: #fafafa;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: rgba(17, 17, 19, 0.85);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.bubble-sent {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.bubble-received {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.bubble-sent ::selection {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* ==================== */
|
||||
/* Animation Keyframes */
|
||||
/* ==================== */
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
|
||||
@keyframes call-wave {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-delayed {
|
||||
animation: float 6s ease-in-out 3s infinite;
|
||||
}
|
||||
|
||||
.animate-call-wave {
|
||||
animation: call-wave 2s ease-out infinite;
|
||||
}
|
||||
|
||||
.animate-call-wave-delayed {
|
||||
animation: call-wave 2s ease-out 1s infinite;
|
||||
}
|
||||
|
||||
/* ==================== */
|
||||
/* Chat Themes */
|
||||
/* ==================== */
|
||||
|
||||
.chat-theme-midnight {
|
||||
background-color: #0f0f13;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3z' fill='%23ffffff' fill-opacity='0.02' fill-rule='evenodd'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.chat-theme-ocean {
|
||||
background-color: #0b172a;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%233b82f6' fill-opacity='0.05' fill-rule='evenodd'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.chat-theme-forest {
|
||||
background-color: #0f1c15;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%2310b981' fill-opacity='0.04' fill-rule='evenodd'%3E%3Ccircle cx='3' cy='3' r='3'/%3E%3Ccircle cx='13' cy='13' r='3'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.chat-theme-sunset {
|
||||
background: linear-gradient(#1f111a, #150a0f);
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-sunset::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle at 50% 150%, rgba(236, 72, 153, 0.1), transparent 60%);
|
||||
}
|
||||
|
||||
.chat-theme-classic {
|
||||
background-color: #121215;
|
||||
}
|
||||
|
||||
.chat-theme-neon {
|
||||
background-color: #0b0f19;
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-neon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 400px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(139, 92, 246, 0.15), transparent 80%);
|
||||
transition: background 0.15s ease-out;
|
||||
}
|
||||
|
||||
.chat-theme-aurora {
|
||||
background: linear-gradient(135deg, #022c22, #064e3b);
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-aurora::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 600px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(16, 185, 129, 0.2), transparent 70%);
|
||||
transition: background 0.15s ease-out;
|
||||
}
|
||||
|
||||
.chat-theme-cyber {
|
||||
background-color: #000;
|
||||
background-image:
|
||||
linear-gradient(rgba(245, 158, 11, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(245, 158, 11, 0.03) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-cyber::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 300px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(245, 158, 11, 0.15), transparent);
|
||||
transition: background 0.15s ease-out;
|
||||
}
|
||||
|
||||
.chat-theme-glass {
|
||||
background-color: #0d1117;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-theme-glass::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 50vw;
|
||||
height: 50vh;
|
||||
left: var(--mouse-x, 50%);
|
||||
top: var(--mouse-y, 50%);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.2), transparent 60%);
|
||||
filter: blur(80px);
|
||||
transition: left 0.5s cubic-bezier(0.2, 0.8, 0.2, 1), top 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.chat-theme-void {
|
||||
background-color: #000;
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-void::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 120px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(255, 255, 255, 0.05), transparent);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.chat-bg {
|
||||
background-color: #09090b;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 60% 80%, rgba(168, 85, 247, 0.04) 0%, transparent 50%),
|
||||
url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%236366f1' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
text-shadow: 0 0 8px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Focus-visible ring for keyboard navigation accessibility */
|
||||
:focus-visible {
|
||||
outline: 2px solid rgba(99, 102, 241, 0.6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible),
|
||||
a:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* emoji-mart dark theme overrides */
|
||||
em-emoji-picker {
|
||||
--em-rgb-background: 23, 33, 43; /* Telegram surface color */
|
||||
--em-rgb-input: 30, 44, 58;
|
||||
--em-rgb-color: 240, 240, 240;
|
||||
--border-radius: 0 0 16px 16px;
|
||||
border: none !important;
|
||||
|
||||
/* Overrides for category navigation to remove blue line and add soft bg */
|
||||
--category-icon-active-border-color: transparent !important;
|
||||
--category-icon-active-color: #5288c1 !important;
|
||||
}
|
||||
|
||||
em-emoji-picker::part(category-icons) {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
em-emoji-picker::part(category-icon) {
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
em-emoji-picker::part(category-icon):hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
em-emoji-picker::part(category-icon-active) {
|
||||
background-color: rgba(82, 136, 193, 0.15); /* Accent light bg */
|
||||
}
|
||||
|
||||
/* Highlight for quoted messages */
|
||||
@keyframes highlight-glow {
|
||||
0% { box-shadow: 0 0 0 3px rgba(82, 136, 193, 0.8), 0 0 20px rgba(82, 136, 193, 0.6); }
|
||||
20% { box-shadow: 0 0 0 4px rgba(82, 136, 193, 1), 0 0 30px rgba(82, 136, 193, 0.8); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(82, 136, 193, 0), 0 0 0 rgba(82, 136, 193, 0); }
|
||||
}
|
||||
|
||||
.highlight-message {
|
||||
animation: highlight-glow 3s ease-out forwards;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
import { create } from 'zustand';
|
||||
import { AuthApi } from '../infrastructure/authApi';
|
||||
import { connectSocket, disconnectSocket } from '../../../core/infrastructure/socket';
|
||||
import type { User } from '../../../core/domain/types';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (username: string, displayName: string, password: string, bio?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
updateUser: (data: Partial<User>) => void;
|
||||
config: any;
|
||||
fetchConfig: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
token: localStorage.getItem('knot_token'),
|
||||
user: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
config: null,
|
||||
|
||||
fetchConfig: async () => {
|
||||
if (!get().token) return;
|
||||
try {
|
||||
const res = await AuthApi.getConfig();
|
||||
set({ config: res });
|
||||
} catch {}
|
||||
},
|
||||
|
||||
login: async (username, password) => {
|
||||
try {
|
||||
set({ error: null, isLoading: true });
|
||||
const { token, user } = await AuthApi.login(username, password);
|
||||
localStorage.setItem('knot_token', token);
|
||||
AuthApi.setToken(token);
|
||||
connectSocket(token);
|
||||
set({ token, user, isLoading: false });
|
||||
await get().fetchConfig();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
set({ error: msg, isLoading: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (username, displayName, password, bio) => {
|
||||
try {
|
||||
set({ error: null, isLoading: true });
|
||||
const { token, user } = await AuthApi.register(username, displayName, password, bio);
|
||||
localStorage.setItem('knot_token', token);
|
||||
AuthApi.setToken(token);
|
||||
connectSocket(token);
|
||||
set({ token, user, isLoading: false });
|
||||
await get().fetchConfig();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
set({ error: msg, isLoading: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('knot_token');
|
||||
AuthApi.setToken(null);
|
||||
disconnectSocket();
|
||||
set({ token: null, user: null });
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const token = get().token;
|
||||
if (!token) {
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Retry up to 3 times in case server is still starting
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
AuthApi.setToken(token);
|
||||
const { user } = await AuthApi.getMe();
|
||||
connectSocket(token);
|
||||
set({ user, isLoading: false });
|
||||
await get().fetchConfig();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
// Only retry on network/server errors, not on auth errors (401/403)
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
if (msg.includes('Требуется авторизация') || msg.includes('Недействительный токен')) {
|
||||
break;
|
||||
}
|
||||
if (attempt < 2) {
|
||||
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.warn('checkAuth failed:', lastError);
|
||||
localStorage.removeItem('knot_token');
|
||||
set({ token: null, user: null, isLoading: false });
|
||||
},
|
||||
|
||||
updateUser: (data) => {
|
||||
const { user } = get();
|
||||
if (user) {
|
||||
set({ user: { ...user, ...data } });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterCredentials {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
bio?: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { httpClient } from '../../../core/infrastructure/httpClient';
|
||||
import type { User } from '../../../core/domain/types';
|
||||
|
||||
export class AuthApi {
|
||||
static async login(username: string, password: string) {
|
||||
return httpClient.request<{ token: string; user: User }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
static async register(username: string, displayName: string, password: string, bio?: string) {
|
||||
return httpClient.request<{ token: string; user: User }>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, displayName, password, bio }),
|
||||
});
|
||||
}
|
||||
|
||||
static async getMe() {
|
||||
return httpClient.request<{ user: User }>('/auth/me');
|
||||
}
|
||||
|
||||
static async getConfig() {
|
||||
return httpClient.request<any>('/config');
|
||||
}
|
||||
|
||||
static setToken(token: string | null) {
|
||||
httpClient.setToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useLang } from '../../../core/infrastructure/i18n';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import LoginForm from './components/LoginForm';
|
||||
import RegisterForm from './components/RegisterForm';
|
||||
import { AuthApi } from '../infrastructure/authApi';
|
||||
|
||||
export default function AuthPage() {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const { lang, setLang } = useLang();
|
||||
const [enableRegistration, setEnableRegistration] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
AuthApi.getConfig()
|
||||
.then(data => {
|
||||
if (data && typeof data.enableRegistration === 'boolean') {
|
||||
setEnableRegistration(data.enableRegistration);
|
||||
if (!data.enableRegistration) setIsLogin(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-full flex flex-col items-center justify-center relative overflow-hidden bg-[#0a0a0c]"
|
||||
>
|
||||
{/* Переключатель языка сверху по центру */}
|
||||
<div className="absolute top-8 left-1/2 -translate-x-1/2 flex gap-4 text-sm font-semibold text-zinc-500 z-50">
|
||||
<button onClick={() => setLang('en')} className={lang === 'en' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>EN</button>
|
||||
<div className="w-px h-4 bg-white/10 self-center" />
|
||||
<button onClick={() => setLang('ru')} className={lang === 'ru' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>RU</button>
|
||||
</div>
|
||||
|
||||
{/* Карточка авторизации */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="relative z-10 w-full max-w-[420px] mx-4"
|
||||
>
|
||||
<div className="bg-[#111113] rounded-[32px] p-10 shadow-2xl border border-white/5">
|
||||
|
||||
{/* Заголовок */}
|
||||
<div className="flex flex-col items-center mb-10">
|
||||
<motion.div
|
||||
initial={{ rotate: -180, scale: 0 }}
|
||||
animate={{ rotate: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
|
||||
className="w-[84px] h-[84px] rounded-[28px] bg-[#1a1625] flex items-center justify-center mb-6 shadow-inner border border-white/5"
|
||||
>
|
||||
<MessageSquare className="w-9 h-9 text-[#8b5cf6]" />
|
||||
</motion.div>
|
||||
<h1 className="text-[28px] font-bold bg-gradient-to-r from-[#9b66ff] to-[#bd99ff] text-transparent bg-clip-text tracking-tight">Knot Messenger</h1>
|
||||
<p className="text-zinc-500 text-[11px] mt-2.5 tracking-widest uppercase font-semibold">
|
||||
{isLogin ? (lang === 'ru' ? 'вход' : 'login') : (lang === 'ru' ? 'регистрация' : 'registration')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={isLogin ? 'login' : 'register'}
|
||||
initial={{ opacity: 0, x: isLogin ? -20 : 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isLogin ? 20 : -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{isLogin ? (
|
||||
<LoginForm
|
||||
enableRegistration={enableRegistration}
|
||||
onRegisterClick={() => setIsLogin(false)}
|
||||
/>
|
||||
) : (
|
||||
<RegisterForm
|
||||
enableRegistration={enableRegistration}
|
||||
onLoginClick={() => setIsLogin(true)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState, FormEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Eye, EyeOff, ArrowRight } from 'lucide-react';
|
||||
import { useAuthStore } from '../../application/authStore';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import type { LoginCredentials } from '../../domain/types';
|
||||
|
||||
interface Props {
|
||||
onRegisterClick?: () => void;
|
||||
enableRegistration?: boolean;
|
||||
}
|
||||
|
||||
export default function LoginForm({ onRegisterClick, enableRegistration }: Props) {
|
||||
const [credentials, setCredentials] = useState<LoginCredentials>({ username: '', password: '' });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { login } = useAuthStore();
|
||||
const { lang } = useLang();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await login(credentials.username, credentials.password);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={credentials.username}
|
||||
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
|
||||
placeholder="username"
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||
{lang === 'ru' ? 'Пароль' : 'Password'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={credentials.password}
|
||||
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
|
||||
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-none text-[15px]"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
disabled={isSubmitting}
|
||||
type="submit"
|
||||
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
|
||||
style={{ marginTop: '32px' }}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{lang === 'ru' ? 'Войти' : 'Login'}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</form>
|
||||
|
||||
{enableRegistration && onRegisterClick && (
|
||||
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
|
||||
<p className="text-zinc-500 text-[13px] font-medium">
|
||||
{lang === 'ru' ? 'Нет аккаунта?' : "Don't have an account?"}
|
||||
</p>
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors"
|
||||
type="button"
|
||||
>
|
||||
{lang === 'ru' ? 'Зарегистрироваться' : 'Register'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useState, FormEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Eye, EyeOff, ArrowRight } from 'lucide-react';
|
||||
import { useAuthStore } from '../../application/authStore';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import type { RegisterCredentials } from '../../domain/types';
|
||||
|
||||
interface Props {
|
||||
onLoginClick: () => void;
|
||||
enableRegistration?: boolean;
|
||||
}
|
||||
|
||||
export default function RegisterForm({ onLoginClick, enableRegistration }: Props) {
|
||||
const [credentials, setCredentials] = useState<RegisterCredentials>({ username: '', displayName: '', password: '', bio: '' });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { register } = useAuthStore();
|
||||
const { lang } = useLang();
|
||||
|
||||
if (!enableRegistration) return null;
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await register(credentials.username, credentials.displayName || credentials.username, credentials.password, credentials.bio);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||
Username <span className="text-zinc-600 font-normal ml-1">({lang === 'ru' ? 'латиница, нельзя изменить' : 'latin, cannot change'})</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={credentials.username}
|
||||
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
|
||||
placeholder="username"
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||
{lang === 'ru' ? 'Отображаемое имя' : 'Display Name'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={credentials.displayName}
|
||||
onChange={(e) => setCredentials({ ...credentials, displayName: e.target.value })}
|
||||
placeholder={lang === 'ru' ? 'Ваше имя (любой язык)' : 'Your name'}
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||
{lang === 'ru' ? 'Пароль' : 'Password'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={credentials.password}
|
||||
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
|
||||
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-none text-[15px]"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[12px] text-zinc-500 flex items-center gap-1.5 font-medium">
|
||||
<span className="w-1 h-1 rounded-full bg-[#9b66ff]" />
|
||||
{lang === 'ru' ? 'Минимум 8 символов, буквы и цифры' : 'Minimum 8 characters, letters and numbers'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||
{lang === 'ru' ? 'О себе' : 'About me'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={credentials.bio}
|
||||
onChange={(e) => setCredentials({ ...credentials, bio: e.target.value })}
|
||||
placeholder={lang === 'ru' ? 'Расскажите о себе (необязательно)' : 'Tell about yourself (optional)'}
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
disabled={isSubmitting}
|
||||
type="submit"
|
||||
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
|
||||
style={{ marginTop: '32px' }}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{lang === 'ru' ? 'Создать аккаунт' : 'Create account'}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</form>
|
||||
|
||||
{enableRegistration && (
|
||||
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
|
||||
<p className="text-zinc-500 text-[13px] font-medium">
|
||||
{lang === 'ru' ? 'Уже есть аккаунт?' : 'Already have an account?'}
|
||||
</p>
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors"
|
||||
type="button"
|
||||
>
|
||||
{lang === 'ru' ? 'Войти' : 'Login'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,549 @@
|
||||
import { create } from 'zustand';
|
||||
import { ChatApi } from '../infrastructure/chatApi';
|
||||
import { useAuthStore } from '../../auth/application/authStore';
|
||||
import type { Chat, ChatMember, Message, TypingUser } from '../../../core/domain/types';
|
||||
|
||||
interface ChatState {
|
||||
chats: Chat[];
|
||||
activeChat: string | null;
|
||||
messages: Record<string, Message[]>;
|
||||
pinnedMessages: Record<string, Message>;
|
||||
typingUsers: TypingUser[];
|
||||
replyTo: Message | null;
|
||||
editingMessage: Message | null;
|
||||
isLoadingChats: boolean;
|
||||
isLoadingMessages: boolean;
|
||||
searchQuery: string;
|
||||
drafts: Record<string, string>;
|
||||
hasMoreMessages: Record<string, boolean>;
|
||||
|
||||
setActiveChat: (chatId: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setDraft: (chatId: string, text: string) => void;
|
||||
getDraft: (chatId: string) => string;
|
||||
loadChats: () => Promise<void>;
|
||||
loadMessages: (chatId: string, reset?: boolean, isHistory?: boolean) => Promise<void>;
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (message: Message) => void;
|
||||
removeMessage: (messageId: string, chatId: string) => void;
|
||||
removeMessages: (messageIds: string[], chatId: string) => void;
|
||||
hideMessages: (messageIds: string[], chatId: string) => void;
|
||||
addReaction: (messageId: string, chatId: string, userId: string, username: string, emoji: string) => void;
|
||||
removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void;
|
||||
markRead: (chatId: string, userId: string, messageIds: string[]) => void;
|
||||
markAllAsRead: (chatId: string) => void;
|
||||
addTypingUser: (chatId: string, userId: string) => void;
|
||||
removeTypingUser: (chatId: string, userId: string) => void;
|
||||
updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void;
|
||||
setReplyTo: (message: Message | null) => void;
|
||||
setEditingMessage: (message: Message | null) => void;
|
||||
addChat: (chat: Chat) => void;
|
||||
updateChat: (chat: Chat) => void;
|
||||
removeChat: (chatId: string) => void;
|
||||
clearMessages: (chatId: string) => void;
|
||||
setPinnedMessage: (chatId: string, message: Message) => void;
|
||||
removePinnedMessage: (chatId: string, messageId: string, newPinned: Message | null) => void;
|
||||
clearStore: () => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
chats: [],
|
||||
activeChat: null,
|
||||
messages: {},
|
||||
pinnedMessages: {},
|
||||
typingUsers: [],
|
||||
replyTo: null,
|
||||
editingMessage: null,
|
||||
isLoadingChats: false,
|
||||
isLoadingMessages: false,
|
||||
searchQuery: '',
|
||||
drafts: JSON.parse(localStorage.getItem('knot_drafts') || '{}'),
|
||||
hasMoreMessages: {},
|
||||
|
||||
setActiveChat: (chatId) => set((state) => ({
|
||||
activeChat: chatId,
|
||||
replyTo: null,
|
||||
editingMessage: null,
|
||||
chats: chatId
|
||||
? state.chats.map((c) => c.id === chatId ? { ...c, unreadCount: 0 } : c)
|
||||
: state.chats,
|
||||
})),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
|
||||
setDraft: (chatId, text) => {
|
||||
set((state) => {
|
||||
const drafts = { ...state.drafts };
|
||||
if (text.trim()) {
|
||||
drafts[chatId] = text;
|
||||
} else {
|
||||
delete drafts[chatId];
|
||||
}
|
||||
localStorage.setItem('knot_drafts', JSON.stringify(drafts));
|
||||
return { drafts };
|
||||
});
|
||||
},
|
||||
|
||||
getDraft: (chatId) => {
|
||||
return get().drafts[chatId] || '';
|
||||
},
|
||||
|
||||
loadChats: async () => {
|
||||
try {
|
||||
set({ isLoadingChats: true });
|
||||
const chats = await ChatApi.getChats();
|
||||
// Auto-create favorites chat if not present
|
||||
if (!chats.some((c: any) => c.type === 'favorites')) {
|
||||
try {
|
||||
const favChat = await ChatApi.getOrCreateFavorites();
|
||||
chats.unshift(favChat);
|
||||
} catch { }
|
||||
}
|
||||
// Extract pinned messages from chats
|
||||
const pinnedMessages: Record<string, Message> = {};
|
||||
for (const chat of chats) {
|
||||
if (chat.pinnedMessages && chat.pinnedMessages.length > 0) {
|
||||
pinnedMessages[chat.id] = chat.pinnedMessages[0].message;
|
||||
}
|
||||
}
|
||||
set({ chats, pinnedMessages, isLoadingChats: false });
|
||||
} catch (error: any) {
|
||||
console.error('Load chats error:', error);
|
||||
set({ isLoadingChats: false });
|
||||
const { addNotification } = (await import('../../../core/application/stores/notificationStore')).useNotificationStore.getState();
|
||||
addNotification('error', error.message || 'Failed to load chats');
|
||||
}
|
||||
},
|
||||
|
||||
loadMessages: async (chatId, reset = false, isHistory = false) => {
|
||||
try {
|
||||
const state = get();
|
||||
if (!reset && !isHistory && typeof state.hasMoreMessages[chatId] !== 'undefined') return;
|
||||
if (!reset && isHistory && state.messages[chatId] && state.hasMoreMessages[chatId] === false) return;
|
||||
if (state.isLoadingMessages) return;
|
||||
|
||||
set({ isLoadingMessages: true });
|
||||
const currentMessages = state.messages[chatId] || [];
|
||||
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].createdAt : undefined;
|
||||
|
||||
const fetched = await ChatApi.getMessages(chatId, cursor);
|
||||
|
||||
set((state) => {
|
||||
// Merge fetched messages with any that arrived via socket
|
||||
const existing = reset ? [] : (state.messages[chatId] || []);
|
||||
const fetchedIds = new Set(fetched.map(m => m.id));
|
||||
const socketOnly = existing.filter(m => !fetchedIds.has(m.id));
|
||||
const merged = [...fetched, ...socketOnly].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
return {
|
||||
messages: { ...state.messages, [chatId]: merged },
|
||||
hasMoreMessages: { ...state.hasMoreMessages, [chatId]: fetched.length === 100 },
|
||||
isLoadingMessages: false,
|
||||
};
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Load messages error:', error);
|
||||
set({ isLoadingMessages: false });
|
||||
const { addNotification } = (await import('../../../core/application/stores/notificationStore')).useNotificationStore.getState();
|
||||
addNotification('error', error.message || 'Failed to load messages');
|
||||
}
|
||||
},
|
||||
|
||||
addMessage: (message) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[message.chatId] || [];
|
||||
if (chatMessages.some((m) => m.id === message.id)) return state;
|
||||
|
||||
const updatedMessages = {
|
||||
...state.messages,
|
||||
[message.chatId]: [...chatMessages, message],
|
||||
};
|
||||
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === message.chatId) {
|
||||
return {
|
||||
...chat,
|
||||
messages: [message],
|
||||
unreadCount: (chat.id === state.activeChat || message.senderId === userId) ? chat.unreadCount : chat.unreadCount + 1,
|
||||
};
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
updatedChats.sort((a, b) => {
|
||||
const aPin = a.members?.find((m) => m.user?.id === userId)?.isPinned ? 1 : 0;
|
||||
const bPin = b.members?.find((m) => m.user?.id === userId)?.isPinned ? 1 : 0;
|
||||
if (aPin !== bPin) return bPin - aPin;
|
||||
const aTime = a.messages[0]?.createdAt || a.createdAt;
|
||||
const bTime = b.messages[0]?.createdAt || b.createdAt;
|
||||
return new Date(bTime).getTime() - new Date(aTime).getTime();
|
||||
});
|
||||
|
||||
return { messages: updatedMessages, chats: updatedChats };
|
||||
});
|
||||
},
|
||||
|
||||
updateMessage: (message) => {
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[message.chatId] || [];
|
||||
const updatedMessages = chatMessages.map((m) => (m.id === message.id ? message : m));
|
||||
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === message.chatId) {
|
||||
return {
|
||||
...chat,
|
||||
messages: chat.messages?.map((m) => (m.id === message.id ? message : m)),
|
||||
};
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
return {
|
||||
messages: {
|
||||
...state.messages,
|
||||
[message.chatId]: updatedMessages,
|
||||
},
|
||||
chats: updatedChats,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeMessage: (messageId, chatId) => {
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
const updatedMessages = chatMessages.map((m) =>
|
||||
m.id === messageId ? { ...m, isDeleted: true, content: null } : m
|
||||
);
|
||||
|
||||
// Find the latest non-deleted message to show in sidebar
|
||||
const latestVisible = updatedMessages
|
||||
.filter(m => !m.isDeleted)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
|
||||
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === chatId) {
|
||||
// If the deleted message was the last message shown, replace with previous one
|
||||
const currentLast = chat.messages?.[0];
|
||||
if (currentLast?.id === messageId) {
|
||||
return {
|
||||
...chat,
|
||||
messages: latestVisible ? [latestVisible] : [{ ...currentLast, isDeleted: true, content: null }],
|
||||
};
|
||||
}
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
return {
|
||||
messages: {
|
||||
...state.messages,
|
||||
[chatId]: updatedMessages,
|
||||
},
|
||||
chats: updatedChats,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeMessages: (messageIds, chatId) => {
|
||||
const idsSet = new Set(messageIds);
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
const updatedMessages = chatMessages.map((m) =>
|
||||
idsSet.has(m.id) ? { ...m, isDeleted: true, content: null } : m
|
||||
);
|
||||
|
||||
const latestVisible = updatedMessages
|
||||
.filter(m => !m.isDeleted)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
|
||||
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === chatId) {
|
||||
const currentLast = chat.messages?.[0];
|
||||
if (currentLast && idsSet.has(currentLast.id)) {
|
||||
return {
|
||||
...chat,
|
||||
messages: latestVisible ? [latestVisible] : [{ ...currentLast, isDeleted: true, content: null }],
|
||||
};
|
||||
}
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
return {
|
||||
messages: { ...state.messages, [chatId]: updatedMessages },
|
||||
chats: updatedChats,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
hideMessages: (messageIds, chatId) => {
|
||||
const idsSet = new Set(messageIds);
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
const updatedMessages = chatMessages.filter((m) => !idsSet.has(m.id));
|
||||
|
||||
const latestVisible = updatedMessages
|
||||
.filter(m => !m.isDeleted)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
|
||||
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === chatId) {
|
||||
const currentLast = chat.messages?.[0];
|
||||
if (currentLast && idsSet.has(currentLast.id)) {
|
||||
return {
|
||||
...chat,
|
||||
messages: latestVisible ? [latestVisible] : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
return {
|
||||
messages: { ...state.messages, [chatId]: updatedMessages },
|
||||
chats: updatedChats,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addReaction: (messageId, chatId, userId, username, emoji) => {
|
||||
console.log('[ChatStore] addReaction called:', { messageId, chatId, userId, username, emoji });
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
const updateMsg = (m: Message) => {
|
||||
if (m.id === messageId) {
|
||||
const reactions = m.reactions || [];
|
||||
const exists = reactions.some((r) => r.userId === userId && r.emoji === emoji);
|
||||
if (exists) {
|
||||
console.log('[ChatStore] Reaction already exists, skipping');
|
||||
return m;
|
||||
}
|
||||
console.log('[ChatStore] Adding reaction to message:', m.id);
|
||||
return {
|
||||
...m,
|
||||
reactions: [
|
||||
...reactions,
|
||||
{ id: `${messageId}-${userId}-${emoji}`, emoji, userId, user: { id: userId, username, displayName: username } },
|
||||
],
|
||||
};
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
const updatedMessages = chatMessages.map(updateMsg);
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === chatId) {
|
||||
return {
|
||||
...chat,
|
||||
messages: chat.messages?.map(updateMsg),
|
||||
};
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
console.log('[ChatStore] State updated for chatId:', chatId);
|
||||
return {
|
||||
messages: {
|
||||
...state.messages,
|
||||
[chatId]: updatedMessages,
|
||||
},
|
||||
chats: updatedChats,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeReaction: (messageId, chatId, userId, emoji) => {
|
||||
console.log('[ChatStore] removeReaction called:', { messageId, chatId, userId, emoji });
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
const updateMsg = (m: Message) => {
|
||||
if (m.id === messageId) {
|
||||
console.log('[ChatStore] Removing reaction from message:', m.id);
|
||||
return {
|
||||
...m,
|
||||
reactions: (m.reactions || []).filter((r) => !(r.userId === userId && r.emoji === emoji)),
|
||||
};
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
const updatedMessages = chatMessages.map(updateMsg);
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === chatId) {
|
||||
return {
|
||||
...chat,
|
||||
messages: chat.messages?.map(updateMsg),
|
||||
};
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
console.log('[ChatStore] State updated for chatId:', chatId);
|
||||
return {
|
||||
messages: {
|
||||
...state.messages,
|
||||
[chatId]: updatedMessages,
|
||||
},
|
||||
chats: updatedChats,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
markRead: (chatId, userId, messageIds) => {
|
||||
const currentUserId = useAuthStore.getState().user?.id;
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
let newlyReadCount = 0;
|
||||
const updateMsg = (m: Message) => {
|
||||
if (messageIds.includes(m.id)) {
|
||||
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
|
||||
if (alreadyRead) return m;
|
||||
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
|
||||
return { ...m, readBy: [...(m.readBy || []), { userId }] };
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
const updatedMessages = chatMessages.map(updateMsg);
|
||||
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === chatId) {
|
||||
const updatedLastMessages = chat.messages?.map(updateMsg);
|
||||
if (userId === currentUserId) {
|
||||
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
|
||||
}
|
||||
return { ...chat, messages: updatedLastMessages };
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
return {
|
||||
messages: {
|
||||
...state.messages,
|
||||
[chatId]: updatedMessages,
|
||||
},
|
||||
chats: updatedChats,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
markAllAsRead: (chatId) => {
|
||||
set((state) => {
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === chatId) {
|
||||
return { ...chat, unreadCount: 0 };
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
return { chats: updatedChats };
|
||||
});
|
||||
},
|
||||
|
||||
addTypingUser: (chatId, userId) => {
|
||||
set((state) => {
|
||||
const exists = state.typingUsers.some((t) => t.chatId === chatId && t.userId === userId);
|
||||
if (exists) return state;
|
||||
return { typingUsers: [...state.typingUsers, { chatId, userId }] };
|
||||
});
|
||||
},
|
||||
|
||||
removeTypingUser: (chatId, userId) => {
|
||||
set((state) => ({
|
||||
typingUsers: state.typingUsers.filter((t) => !(t.chatId === chatId && t.userId === userId)),
|
||||
}));
|
||||
},
|
||||
|
||||
updateUserOnlineStatus: (userId, isOnline, lastSeen) => {
|
||||
set((state) => ({
|
||||
chats: state.chats.map((chat) => ({
|
||||
...chat,
|
||||
members: chat.members.map((m) =>
|
||||
m.user.id === userId
|
||||
? { ...m, user: { ...m.user, isOnline, lastSeen: lastSeen || m.user.lastSeen } }
|
||||
: m
|
||||
),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
setReplyTo: (message) => set({ replyTo: message, editingMessage: null }),
|
||||
setEditingMessage: (message) => set({ editingMessage: message, replyTo: null }),
|
||||
|
||||
addChat: (chat) => {
|
||||
set((state) => {
|
||||
const existing = state.chats.find((c) => c.id === chat.id);
|
||||
|
||||
const messagesFromState = state.messages[chat.id] || [];
|
||||
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
|
||||
|
||||
let unreadCount = chat.unreadCount || 0;
|
||||
if (!existing && messagesFromState.length > 0) {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length;
|
||||
}
|
||||
|
||||
const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount };
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...updatedChat } : c)),
|
||||
};
|
||||
}
|
||||
return { chats: [updatedChat, ...state.chats] };
|
||||
});
|
||||
},
|
||||
|
||||
updateChat: (chat) => {
|
||||
set((state) => ({
|
||||
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...chat } : c)),
|
||||
}));
|
||||
},
|
||||
|
||||
removeChat: (chatId) => {
|
||||
set((state) => ({
|
||||
chats: state.chats.filter((c) => c.id !== chatId),
|
||||
activeChat: state.activeChat === chatId ? null : state.activeChat,
|
||||
messages: (() => { const m = { ...state.messages }; delete m[chatId]; return m; })(),
|
||||
}));
|
||||
},
|
||||
|
||||
clearMessages: (chatId) => {
|
||||
set((state) => ({
|
||||
messages: { ...state.messages, [chatId]: [] },
|
||||
chats: state.chats.map((c) =>
|
||||
c.id === chatId ? { ...c, messages: [] } : c
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
setPinnedMessage: (chatId, message) => {
|
||||
set((state) => ({
|
||||
pinnedMessages: { ...state.pinnedMessages, [chatId]: message },
|
||||
}));
|
||||
},
|
||||
|
||||
removePinnedMessage: (chatId, _messageId, newPinned) => {
|
||||
set((state) => {
|
||||
const updated = { ...state.pinnedMessages };
|
||||
if (newPinned) {
|
||||
updated[chatId] = newPinned;
|
||||
} else {
|
||||
delete updated[chatId];
|
||||
}
|
||||
return { pinnedMessages: updated };
|
||||
});
|
||||
},
|
||||
|
||||
clearStore: () => {
|
||||
set({
|
||||
chats: [],
|
||||
activeChat: null,
|
||||
messages: {},
|
||||
pinnedMessages: {},
|
||||
typingUsers: [],
|
||||
replyTo: null,
|
||||
editingMessage: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,112 @@
|
||||
import { httpClient } from '../../../core/infrastructure/httpClient';
|
||||
import type { Chat, Message } from '../../../core/domain/types';
|
||||
|
||||
export class ChatApi {
|
||||
static async getChats() {
|
||||
return httpClient.request<Chat[]>('/chats');
|
||||
}
|
||||
|
||||
static async createPersonalChat(userId: string) {
|
||||
return httpClient.request<Chat>('/chats/personal', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
}
|
||||
|
||||
static async createGroupChat(name: string, memberIds: string[]) {
|
||||
return httpClient.request<Chat>('/chats/group', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, memberIds }),
|
||||
});
|
||||
}
|
||||
|
||||
static async getMessages(chatId: string, cursor?: string) {
|
||||
const params = cursor ? `?cursor=${cursor}` : '';
|
||||
return httpClient.request<Message[]>(`/messages/chat/${chatId}${params}`);
|
||||
}
|
||||
|
||||
static async uploadFile(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return httpClient.request<{ url: string; filename: string; size: number }>('/messages/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
static async updateGroup(chatId: string, data: { name?: string; description?: string }) {
|
||||
return httpClient.request<Chat>(`/chats/${chatId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
static async uploadGroupAvatar(chatId: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
return httpClient.request<Chat>(`/chats/${chatId}/avatar`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
static async cropGroupAvatar(chatId: string, file: File, cropData: { x: number; y: number; width: number; height: number }) {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
formData.append('x', cropData.x.toString());
|
||||
formData.append('y', cropData.y.toString());
|
||||
formData.append('width', cropData.width.toString());
|
||||
formData.append('height', cropData.height.toString());
|
||||
|
||||
return httpClient.request<Chat>(`/chats/${chatId}/avatar/crop`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
static async removeGroupAvatar(chatId: string) {
|
||||
return httpClient.request<Chat>(`/chats/${chatId}/avatar`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
static async addGroupMembers(chatId: string, userIds: string[]) {
|
||||
return httpClient.request<Chat>(`/chats/${chatId}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userIds }),
|
||||
});
|
||||
}
|
||||
|
||||
static async removeGroupMember(chatId: string, userId: string) {
|
||||
return httpClient.request<Chat>(`/chats/${chatId}/members/${userId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
static async clearChat(chatId: string) {
|
||||
return httpClient.request<{ message: string }>(`/chats/${chatId}/clear`, { method: 'POST' });
|
||||
}
|
||||
|
||||
static async deleteChat(chatId: string) {
|
||||
return httpClient.request<{ message: string }>(`/chats/${chatId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
static async togglePinChat(chatId: string) {
|
||||
return httpClient.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' });
|
||||
}
|
||||
|
||||
static async searchMessages(query: string, chatId?: string) {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (chatId) params.append('chatId', chatId);
|
||||
return httpClient.request<Message[]>(`/messages/search?${params}`);
|
||||
}
|
||||
|
||||
static async getSharedMedia(chatId: string, type: 'media' | 'gifs' | 'files' | 'links') {
|
||||
return httpClient.request<any[]>(`/messages/chat/${chatId}/shared?type=${type}`);
|
||||
}
|
||||
|
||||
static async getOrCreateFavorites() {
|
||||
return httpClient.request<Chat>('/chats/favorites', { method: 'POST' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useChatStore } from '../application/chatStore';
|
||||
import { useAuthStore } from '../../auth/application/authStore';
|
||||
import { getSocket, disconnectSocket } from '../../../core/infrastructure/socket';
|
||||
import { ChatApi } from '../infrastructure/chatApi';
|
||||
import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../../../core/utils/sounds';
|
||||
import { useLang } from '../../../core/infrastructure/i18n';
|
||||
import type { Message, UserBasic, CallInfo } from '../../../core/domain/types';
|
||||
import { Send, Check, Phone, PhoneOff } from 'lucide-react';
|
||||
import Sidebar from '../../../core/presentation/layouts/Sidebar';
|
||||
import ChatView from './components/ChatView';
|
||||
import CallModal from '../../calls/presentation/components/CallModal';
|
||||
import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
|
||||
|
||||
export default function ChatPage() {
|
||||
const {
|
||||
loadChats,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
removeMessage,
|
||||
removeMessages,
|
||||
hideMessages,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
markRead,
|
||||
addTypingUser,
|
||||
removeTypingUser,
|
||||
updateUserOnlineStatus,
|
||||
setPinnedMessage,
|
||||
removePinnedMessage,
|
||||
clearStore,
|
||||
addChat,
|
||||
} = useChatStore();
|
||||
const { user } = useAuthStore();
|
||||
const initialized = useRef(false);
|
||||
|
||||
// Call state
|
||||
const [callOpen, setCallOpen] = useState(false);
|
||||
const [callTarget, setCallTarget] = useState<UserBasic | null>(null);
|
||||
const [callType, setCallType] = useState<'voice' | 'video'>('voice');
|
||||
const [incomingCall, setIncomingCall] = useState<CallInfo | null>(null);
|
||||
const [callSessionId, setCallSessionId] = useState(0);
|
||||
const [deliveryNotification, setDeliveryNotification] = useState<string | null>(null);
|
||||
const deliveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Group call state
|
||||
const [groupCallOpen, setGroupCallOpen] = useState(false);
|
||||
const [groupCallChatId, setGroupCallChatId] = useState('');
|
||||
const [groupCallChatName, setGroupCallChatName] = useState('');
|
||||
const [groupCallType, setGroupCallType] = useState<'voice' | 'video'>('voice');
|
||||
const [groupCallSessionId, setGroupCallSessionId] = useState(0);
|
||||
|
||||
const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
|
||||
|
||||
const groupCallOpenRef = useRef(false);
|
||||
const groupCallChatIdRef = useRef('');
|
||||
|
||||
const { t } = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
groupCallOpenRef.current = groupCallOpen;
|
||||
groupCallChatIdRef.current = groupCallChatId;
|
||||
}, [groupCallOpen, groupCallChatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
loadChats();
|
||||
}, [loadChats]);
|
||||
|
||||
// Обработка закрытия вкладки — отправить disconnect
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
if (!socket) return;
|
||||
|
||||
socket.on('new_message', async (message: Message) => {
|
||||
// If this chat isn't in our store yet (e.g. someone just created it and sent a message),
|
||||
// fetch chats so the new chat appears in the sidebar immediately
|
||||
const { chats } = useChatStore.getState();
|
||||
if (!chats.some(c => c.id === message.chatId)) {
|
||||
try {
|
||||
const allChats = await ChatApi.getChats();
|
||||
const newChat = allChats.find(c => c.id === message.chatId);
|
||||
if (newChat) {
|
||||
// Reset unreadCount to 0 because addMessage below will increment it by 1
|
||||
useChatStore.getState().addChat({ ...newChat, unreadCount: 0 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch new chat:', e);
|
||||
}
|
||||
}
|
||||
addMessage(message);
|
||||
// Play notification sound for messages from others
|
||||
if (message.senderId !== user?.id && !message.storyId && !isChatMuted(message.chatId)) {
|
||||
playNotificationSound();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('scheduled_delivered', async (message: Message & { _recipientName?: string; _deliveredAt?: string }) => {
|
||||
// If chat unknown, fetch it first
|
||||
const { chats } = useChatStore.getState();
|
||||
if (!chats.some(c => c.id === message.chatId)) {
|
||||
try {
|
||||
const allChats = await ChatApi.getChats();
|
||||
const newChat = allChats.find(c => c.id === message.chatId);
|
||||
if (newChat) useChatStore.getState().addChat(newChat);
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
// A scheduled message was delivered: update it in store (remove scheduledAt)
|
||||
updateMessage({ ...message, scheduledAt: null });
|
||||
|
||||
// Show delivery notification to the sender
|
||||
if (message.senderId === user?.id && message._recipientName) {
|
||||
const time = message._deliveredAt
|
||||
? new Date(message._deliveredAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
const notifText = `${useLang.getState().t('scheduledDelivered')} ${message._recipientName} ${useLang.getState().t('scheduledDeliveredAt')} ${time}`;
|
||||
setDeliveryNotification(notifText);
|
||||
if (deliveryTimerRef.current) clearTimeout(deliveryTimerRef.current);
|
||||
deliveryTimerRef.current = setTimeout(() => setDeliveryNotification(null), 5000);
|
||||
}
|
||||
|
||||
// Notify others with sound
|
||||
if (message.senderId !== user?.id && !isChatMuted(message.chatId)) {
|
||||
playNotificationSound();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('message_edited', (message: Message) => {
|
||||
updateMessage(message);
|
||||
});
|
||||
|
||||
socket.on('new_chat', (chat: any) => {
|
||||
addChat(chat);
|
||||
socket.emit('join_chat', chat.id);
|
||||
});
|
||||
|
||||
socket.on('message_deleted', (data: { messageId: string; chatId: string }) => {
|
||||
removeMessage(data.messageId, data.chatId);
|
||||
});
|
||||
|
||||
socket.on('messages_deleted', (data: { messageIds: string[]; chatId: string }) => {
|
||||
removeMessages(data.messageIds, data.chatId);
|
||||
});
|
||||
|
||||
socket.on('messages_hidden', (data: { messageIds: string[]; chatId: string }) => {
|
||||
hideMessages(data.messageIds, data.chatId);
|
||||
});
|
||||
|
||||
socket.on('reaction_added', (data: { messageId: string; chatId: string; userId: string; username: string; emoji: string }) => {
|
||||
console.log('[Socket] reaction_added received:', data);
|
||||
addReaction(data.messageId, data.chatId, data.userId, data.username, data.emoji);
|
||||
});
|
||||
|
||||
socket.on('reaction_removed', (data: { messageId: string; chatId: string; userId: string; emoji: string }) => {
|
||||
console.log('[Socket] reaction_removed received:', data);
|
||||
removeReaction(data.messageId, data.chatId, data.userId, data.emoji);
|
||||
});
|
||||
|
||||
socket.on('messages_read', (data: any) => {
|
||||
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.messageIds || data.MessageIds || []);
|
||||
});
|
||||
|
||||
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
|
||||
if (data.userId !== user?.id) {
|
||||
addTypingUser(data.chatId, data.userId);
|
||||
setTimeout(() => removeTypingUser(data.chatId, data.userId), 3000);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('user_stopped_typing', (data: { chatId: string; userId: string }) => {
|
||||
removeTypingUser(data.chatId, data.userId);
|
||||
});
|
||||
|
||||
socket.on('user_online', (data: { userId: string }) => {
|
||||
updateUserOnlineStatus(data.userId, true);
|
||||
});
|
||||
|
||||
socket.on('user_offline', (data: { userId: string; lastSeen?: string }) => {
|
||||
updateUserOnlineStatus(data.userId, false, data.lastSeen);
|
||||
});
|
||||
|
||||
socket.on('message_pinned', (data: { chatId: string; message: Message }) => {
|
||||
setPinnedMessage(data.chatId, data.message);
|
||||
});
|
||||
|
||||
socket.on('message_unpinned', (data: { chatId: string; messageId: string; newPinnedMessage: Message | null }) => {
|
||||
removePinnedMessage(data.chatId, data.messageId, data.newPinnedMessage);
|
||||
});
|
||||
|
||||
socket.on('call_incoming', async (data: CallInfo) => {
|
||||
// Use callerInfo from server if available, otherwise look up from chats
|
||||
let callerInfo: UserBasic | null = data.callerInfo || null;
|
||||
if (!callerInfo) {
|
||||
const { chats } = useChatStore.getState();
|
||||
for (const chat of chats) {
|
||||
const member = chat.members.find((m) => m.user.id === data.from);
|
||||
if (member) {
|
||||
callerInfo = member.user;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
setCallTarget(null); // Clear any previous outgoing target
|
||||
setIncomingCall({
|
||||
from: data.from,
|
||||
offer: data.offer,
|
||||
callType: data.callType,
|
||||
chatId: data.chatId,
|
||||
callerInfo,
|
||||
});
|
||||
setCallType(data.callType);
|
||||
setCallSessionId(id => id + 1);
|
||||
setCallOpen(true);
|
||||
});
|
||||
|
||||
// Story events - registered globally so they work even when StoryViewer is closed
|
||||
socket.on('story_viewed', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string; viewCount: number; ownerId: string }) => {
|
||||
console.log('[Socket] story_viewed received:', data);
|
||||
// Only process if this user is the owner
|
||||
if (data.ownerId === user?.id) {
|
||||
console.log('[Socket] This is my story, updating view count');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('story_reply', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string; ownerId: string }) => {
|
||||
console.log('[Socket] story_reply received:', data);
|
||||
// Only process if this user is the owner
|
||||
if (data.ownerId === user?.id) {
|
||||
console.log('[Socket] This is my story, got reply:', data.content);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('story_reaction', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; emoji: string; createdAt: string; ownerId: string }) => {
|
||||
console.log('[Socket] story_reaction received:', data);
|
||||
// Only process if this user is the owner
|
||||
if (data.ownerId === user?.id) {
|
||||
console.log('[Socket] This is my story, got reaction:', data.emoji);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('group_call_incoming', (data: { chatId: string; from: string; callerInfo: any; callType: string }) => {
|
||||
if (data.from === user?.id) return;
|
||||
if (groupCallOpenRef.current && groupCallChatIdRef.current === data.chatId) return;
|
||||
|
||||
const { chats } = useChatStore.getState();
|
||||
const chat = chats.find(c => c.id === data.chatId);
|
||||
if (!chat) return;
|
||||
|
||||
playCallRingtone();
|
||||
setIncomingGroupCall({
|
||||
chatId: data.chatId,
|
||||
from: data.from,
|
||||
callerInfo: data.callerInfo,
|
||||
callType: data.callType,
|
||||
chatName: chat.name || 'Group',
|
||||
});
|
||||
|
||||
// Auto-dismiss after 15 seconds if ignored
|
||||
setTimeout(() => {
|
||||
setIncomingGroupCall(prev => {
|
||||
if (prev?.chatId === data.chatId) {
|
||||
stopCallRingtone();
|
||||
return null;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
socket.on('group_call_ended', (data: { chatId: string }) => {
|
||||
setIncomingGroupCall(prev => {
|
||||
if (prev?.chatId === data.chatId) {
|
||||
stopCallRingtone();
|
||||
return null;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off('new_message');
|
||||
socket.off('scheduled_delivered');
|
||||
socket.off('message_edited');
|
||||
socket.off('new_chat');
|
||||
socket.off('message_deleted');
|
||||
socket.off('messages_deleted');
|
||||
socket.off('messages_hidden');
|
||||
socket.off('reaction_added');
|
||||
socket.off('reaction_removed');
|
||||
socket.off('messages_read');
|
||||
socket.off('user_typing');
|
||||
socket.off('user_stopped_typing');
|
||||
socket.off('user_online');
|
||||
socket.off('user_offline');
|
||||
socket.off('message_pinned');
|
||||
socket.off('message_unpinned');
|
||||
socket.off('call_incoming');
|
||||
socket.off('story_viewed');
|
||||
socket.off('story_reply');
|
||||
socket.off('story_reaction');
|
||||
socket.off('group_call_incoming');
|
||||
socket.off('group_call_ended');
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
|
||||
setCallTarget(targetUser);
|
||||
setCallType(type);
|
||||
setIncomingCall(null);
|
||||
setCallSessionId(id => id + 1);
|
||||
setCallOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleCustomCallEvent = ((e: CustomEvent) => {
|
||||
if (e.detail?.targetUser && e.detail?.type) {
|
||||
handleStartCall(e.detail.targetUser, e.detail.type);
|
||||
}
|
||||
}) as EventListener;
|
||||
window.addEventListener('START_CALL', handleCustomCallEvent);
|
||||
return () => window.removeEventListener('START_CALL', handleCustomCallEvent);
|
||||
}, []);
|
||||
|
||||
const handleStartGroupCall = (chatId: string, chatName: string, type: 'voice' | 'video') => {
|
||||
setGroupCallChatId(chatId);
|
||||
setGroupCallChatName(chatName);
|
||||
setGroupCallType(type);
|
||||
setGroupCallSessionId(id => id + 1);
|
||||
setGroupCallOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseCall = () => {
|
||||
setCallOpen(false);
|
||||
setCallTarget(null);
|
||||
setIncomingCall(null);
|
||||
};
|
||||
|
||||
const handleCloseGroupCall = () => {
|
||||
setGroupCallOpen(false);
|
||||
};
|
||||
|
||||
const activeChat = useChatStore((state) => state.activeChat);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-full flex bg-surface sm:p-3 sm:gap-3 overflow-hidden"
|
||||
>
|
||||
<div className={`${activeChat ? 'hidden sm:block' : 'block'} w-full sm:w-[340px] flex-shrink-0`}>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className={`${activeChat ? 'block' : 'hidden sm:block'} flex-1 h-full min-w-0`}>
|
||||
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
||||
</div>
|
||||
<CallModal
|
||||
key={callSessionId}
|
||||
isOpen={callOpen}
|
||||
onClose={handleCloseCall}
|
||||
targetUser={callTarget}
|
||||
callType={callType}
|
||||
incoming={incomingCall}
|
||||
/>
|
||||
<GroupCallModal
|
||||
key={`gc-${groupCallSessionId}`}
|
||||
isOpen={groupCallOpen}
|
||||
onClose={handleCloseGroupCall}
|
||||
chatId={groupCallChatId}
|
||||
chatName={groupCallChatName}
|
||||
callType={groupCallType}
|
||||
/>
|
||||
|
||||
{/* Scheduled message delivery notification */}
|
||||
<AnimatePresence>
|
||||
{deliveryNotification && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
className="fixed top-6 left-1/2 -translate-x-1/2 z-[9999] px-5 py-3 rounded-2xl bg-surface-secondary shadow-2xl border border-border flex items-center gap-3"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Send size={14} className="text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-sm text-zinc-200">{deliveryNotification}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Incoming Group Call Overlay */}
|
||||
<AnimatePresence>
|
||||
{incomingGroupCall && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-zinc-900 border border-white/10 p-8 rounded-3xl w-full max-w-sm flex flex-col items-center shadow-2xl"
|
||||
>
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 rounded-full bg-emerald-500/20 animate-call-wave" />
|
||||
<div className="relative w-24 h-24 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-4xl font-bold text-white uppercase overflow-hidden">
|
||||
{incomingGroupCall.callerInfo?.avatar ? (
|
||||
<img src={incomingGroupCall.callerInfo.avatar} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<>{incomingGroupCall.chatName.charAt(0)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl text-white font-semibold mb-2 text-center break-words w-full max-w-full">
|
||||
{incomingGroupCall.chatName}
|
||||
</h2>
|
||||
<p className="text-emerald-400 font-medium mb-1 truncate w-full text-center">
|
||||
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('calling' as any) || 'звонит...'}
|
||||
</p>
|
||||
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
|
||||
{t('group' as any) || 'Групповой звонок'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-8 w-full justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
stopCallRingtone();
|
||||
setIncomingGroupCall(null);
|
||||
}}
|
||||
className="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-colors shadow-lg shadow-red-500/20"
|
||||
>
|
||||
<PhoneOff size={28} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
stopCallRingtone();
|
||||
handleStartGroupCall(incomingGroupCall.chatId, incomingGroupCall.chatName, incomingGroupCall.callType as any);
|
||||
setIncomingGroupCall(null);
|
||||
}}
|
||||
className="w-16 h-16 rounded-full bg-emerald-500 hover:bg-emerald-600 flex items-center justify-center text-white transition-colors animate-pulse shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
<Phone size={28} className="animate-wiggle" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState, useRef, useEffect, memo } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ru, enUS } from 'date-fns/locale';
|
||||
import { Check, CheckCheck, Image, FileText, Mic, Video, Pin, Trash2, Bookmark } from 'lucide-react';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { useChatStore } from '../../application/chatStore';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { stripMarkdown } from '../../../../core/utils/utils';
|
||||
import { ChatApi } from '../../infrastructure/chatApi';
|
||||
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
|
||||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||
import type { Chat } from '../../../../core/domain/types';
|
||||
|
||||
interface ChatListItemProps {
|
||||
chat: Chat;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { setActiveChat, loadMessages, typingUsers, drafts, loadChats } = useChatStore();
|
||||
const { t, lang } = useLang();
|
||||
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const ctxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const myMember = chat.members.find((m) => m.user.id === user?.id);
|
||||
const isPinned = myMember?.isPinned ?? false;
|
||||
|
||||
const draft = drafts[chat.id] || '';
|
||||
|
||||
const otherMember = chat.members.find((m) => m.user.id !== user?.id);
|
||||
const isFavorites = chat.type === 'favorites';
|
||||
const chatName = isFavorites
|
||||
? t('favorites')
|
||||
: chat.type === 'personal'
|
||||
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
|
||||
: chat.name || t('group');
|
||||
|
||||
const chatAvatar = isFavorites
|
||||
? null
|
||||
: chat.type === 'personal'
|
||||
? otherMember?.user.avatar
|
||||
: chat.avatar;
|
||||
|
||||
const isOnline = chat.type === 'personal' && otherMember?.user.isOnline;
|
||||
|
||||
// Check if someone is typing in this chat
|
||||
const typingInChat = typingUsers.filter((t) => t.chatId === chat.id && t.userId !== user?.id);
|
||||
const isTyping = typingInChat.length > 0;
|
||||
|
||||
const lastMessage = chat.messages?.[0];
|
||||
const lastMessageText = lastMessage
|
||||
? lastMessage.isDeleted
|
||||
? t('messageDeleted')
|
||||
: lastMessage.type === 'voice'
|
||||
? t('voice')
|
||||
: lastMessage.type === 'file' || lastMessage.type === 'image' || lastMessage.type === 'video'
|
||||
? lastMessage.media?.[0]?.type === 'image'
|
||||
? t('photo')
|
||||
: lastMessage.media?.[0]?.type === 'video'
|
||||
? t('video')
|
||||
: t('file')
|
||||
: lastMessage.content || ''
|
||||
: '';
|
||||
|
||||
const previewText = stripMarkdown(lastMessageText);
|
||||
|
||||
const isMine = lastMessage?.senderId === user?.id;
|
||||
|
||||
// Галочки прочтения
|
||||
const isRead = lastMessage?.readBy?.some((r) => r.userId !== user?.id);
|
||||
|
||||
const timeStr = lastMessage
|
||||
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
|
||||
: '';
|
||||
|
||||
const handleClick = () => {
|
||||
setActiveChat(chat.id);
|
||||
loadMessages(chat.id);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctxMenu) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ctxRef.current && !ctxRef.current.contains(e.target as Node)) setCtxMenu(null);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [ctxMenu]);
|
||||
|
||||
const handlePin = async () => {
|
||||
setCtxMenu(null);
|
||||
try {
|
||||
await ChatApi.togglePinChat(chat.id);
|
||||
loadChats();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setCtxMenu(null);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setShowDeleteConfirm(false);
|
||||
try {
|
||||
await ChatApi.deleteChat(chat.id);
|
||||
useChatStore.getState().removeChat(chat.id);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const initials = chatName
|
||||
.split(' ')
|
||||
.map((w: string) => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={`w-full flex items-center gap-3 px-3 py-3 transition-colors text-left ${
|
||||
isActive ? 'bg-accent/15 border-r-2 border-accent' : 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{/* Аватар */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{isFavorites ? (
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-lg">
|
||||
<Bookmark size={22} className="text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar src={chatAvatar} name={chatName} size="lg" online={isOnline ? true : undefined} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Инфо */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isPinned && <Pin size={12} className="text-knot-400 flex-shrink-0 rotate-45" />}
|
||||
<span className="text-sm font-medium text-white truncate">{chatName}</span>
|
||||
</div>
|
||||
{timeStr && <span className="text-xs text-zinc-500 flex-shrink-0 ml-2">{timeStr}</span>}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
{isMine && lastMessage && !lastMessage.isDeleted && (
|
||||
<span className="flex-shrink-0">
|
||||
{isRead ? (
|
||||
<CheckCheck size={14} className="text-knot-400" />
|
||||
) : (
|
||||
<Check size={14} className="text-zinc-500" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<p className={`text-xs truncate ${isTyping ? 'text-knot-400 font-medium' : draft ? 'text-red-400' : 'text-zinc-400'}`}>
|
||||
{isTyping ? t('typing') : draft ? <><span className="font-medium">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
|
||||
</p>
|
||||
</div>
|
||||
{chat.unreadCount > 0 && !isActive && (
|
||||
<span className="ml-2 flex-shrink-0 min-w-[20px] h-5 px-1.5 rounded-full bg-accent flex items-center justify-center text-[11px] text-white font-medium">
|
||||
{chat.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Context Menu */}
|
||||
{ctxMenu && (
|
||||
<div
|
||||
ref={ctxRef}
|
||||
className="fixed z-[9999] min-w-[180px] py-1 rounded-xl bg-surface-secondary border border-border shadow-xl animate-in fade-in zoom-in-95 duration-100"
|
||||
style={{ top: ctxMenu.y, left: ctxMenu.x }}
|
||||
>
|
||||
<button
|
||||
onClick={handlePin}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Pin size={16} className={isPinned ? 'rotate-45' : ''} />
|
||||
{isPinned ? t('unpinChat') : t('pinChat')}
|
||||
</button>
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t('deleteChat')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={showDeleteConfirm}
|
||||
message={t('deleteChatConfirm')}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ChatListItem);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import data from '@emoji-mart/data';
|
||||
import { Search, TrendingUp, Loader2 } from 'lucide-react';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { AppApi } from '../../../../core/infrastructure/appApi';
|
||||
|
||||
interface KlipyGif {
|
||||
id: string;
|
||||
files?: any;
|
||||
file?: any;
|
||||
media_formats?: any;
|
||||
media?: any;
|
||||
images?: any;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void;
|
||||
onSelectGif?: (url: string, preview: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
|
||||
const { lang, t } = useLang();
|
||||
const { config } = useAuthStore();
|
||||
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
|
||||
const [gifQuery, setGifQuery] = useState('');
|
||||
const [gifs, setGifs] = useState<KlipyGif[]>([]);
|
||||
const [gifLoading, setGifLoading] = useState(false);
|
||||
const [trendingGifs, setTrendingGifs] = useState<KlipyGif[]>([]);
|
||||
const gifSearchRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const initialFetchDone = useRef(false);
|
||||
|
||||
// Helper to safely extract GIF array from various possible Klipy API responses
|
||||
const extractGifs = (d: any): KlipyGif[] => {
|
||||
if (!d) return [];
|
||||
if (d.data && Array.isArray(d.data.data)) return d.data.data;
|
||||
if (Array.isArray(d)) return d;
|
||||
if (Array.isArray(d.data)) return d.data;
|
||||
if (Array.isArray(d.result)) return d.result;
|
||||
if (d.result && Array.isArray(d.result.data)) return d.result.data;
|
||||
if (Array.isArray(d.gifs)) return d.gifs;
|
||||
return [];
|
||||
};
|
||||
|
||||
// Load trending GIFs (Klipy)
|
||||
useEffect(() => {
|
||||
if (tab === 'gif' && config?.enableKlipy && !initialFetchDone.current) {
|
||||
initialFetchDone.current = true;
|
||||
setGifLoading(true);
|
||||
AppApi.getTrendingGifs()
|
||||
.then(d => {
|
||||
setTrendingGifs(extractGifs(d));
|
||||
setGifLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Klipy trending error:', e);
|
||||
setTrendingGifs([]);
|
||||
setGifLoading(false);
|
||||
});
|
||||
}
|
||||
}, [tab, config?.enableKlipy]);
|
||||
|
||||
const searchGifs = useCallback((q: string) => {
|
||||
if (!config?.enableKlipy || !q.trim()) {
|
||||
setGifs([]);
|
||||
setGifLoading(false);
|
||||
return;
|
||||
}
|
||||
setGifLoading(true);
|
||||
AppApi.searchKlipyGifs(q)
|
||||
.then(d => {
|
||||
setGifs(extractGifs(d));
|
||||
setGifLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Klipy search error:', e);
|
||||
setGifs([]);
|
||||
setGifLoading(false);
|
||||
});
|
||||
}, [config?.enableKlipy]);
|
||||
|
||||
const handleGifSearch = (q: string) => {
|
||||
setGifQuery(q);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => searchGifs(q), 400);
|
||||
};
|
||||
|
||||
const getGifUrl = (gif: any): string => {
|
||||
return gif.files?.hd?.gif?.url || gif.files?.sd?.gif?.url
|
||||
|| gif.file?.hd?.gif?.url || gif.file?.sd?.gif?.url
|
||||
|| gif.media_formats?.gif?.url || gif.media?.[0]?.gif?.url
|
||||
|| gif.images?.original?.url || '';
|
||||
};
|
||||
|
||||
const getGifPreview = (gif: any, fullUrl: string): string => {
|
||||
return gif.files?.sd?.webp?.url || gif.files?.sd?.gif?.url
|
||||
|| gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url
|
||||
|| gif.media_formats?.tinygif?.url || gif.media?.[0]?.tinygif?.url
|
||||
|| gif.images?.fixed_height_small?.url || fullUrl;
|
||||
};
|
||||
|
||||
const pickGif = (gif: KlipyGif) => {
|
||||
const url = getGifUrl(gif);
|
||||
const preview = getGifPreview(gif, url);
|
||||
if (onSelectGif && url) {
|
||||
onSelectGif(url, preview);
|
||||
}
|
||||
};
|
||||
|
||||
const displayGifs = gifQuery.trim() ? gifs : trendingGifs;
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const el = anchorRef.current?.parentElement;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const w = tab === 'gif' ? 360 : 352;
|
||||
let left = rect.right - w;
|
||||
if (left < 8) left = 8;
|
||||
setPos({ top: rect.top - 8, left });
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [tab]);
|
||||
|
||||
const pickerWidth = tab === 'gif' ? 360 : 352;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={anchorRef} className="hidden" />
|
||||
{createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9990]" onClick={onClose} />
|
||||
<div
|
||||
className="fixed z-[9991] rounded-xl shadow-2xl border border-border/40"
|
||||
style={{
|
||||
width: pickerWidth,
|
||||
height: tab === 'gif' ? 435 : undefined,
|
||||
bottom: pos ? `${window.innerHeight - pos.top}px` : undefined,
|
||||
left: pos ? pos.left : undefined,
|
||||
background: '#17212b',
|
||||
visibility: pos ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border/40">
|
||||
<button
|
||||
onClick={() => setTab('emoji')}
|
||||
className={`flex-1 py-3 text-[14px] font-medium transition-colors ${tab === 'emoji' ? 'text-accent border-b-[2px] border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
{lang === 'ru' ? 'Эмодзи' : 'Emoji'}
|
||||
</button>
|
||||
{config?.enableKlipy && onSelectGif && (
|
||||
<button
|
||||
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
|
||||
className={`flex-1 py-3 text-[14px] font-medium transition-colors ${tab === 'gif' ? 'text-accent border-b-[2px] border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
GIF
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emoji tab */}
|
||||
{tab === 'emoji' && (
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(e: { native: string }) => onSelect(e.native)}
|
||||
theme="dark"
|
||||
locale={lang === 'ru' ? 'ru' : 'en'}
|
||||
set="native"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
perLine={9}
|
||||
emojiSize={28}
|
||||
emojiButtonSize={36}
|
||||
maxFrequentRows={2}
|
||||
navPosition="top"
|
||||
dynamicWidth={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* GIF tab */}
|
||||
{config?.enableKlipy && tab === 'gif' && (
|
||||
<div className="flex flex-col h-[calc(100%-41px)]">
|
||||
<div className="p-2">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
ref={gifSearchRef}
|
||||
value={gifQuery}
|
||||
onChange={(e) => handleGifSearch(e.target.value)}
|
||||
placeholder={t('searchGifs')}
|
||||
className="w-full pl-8 pr-3 py-2 rounded-lg bg-surface-tertiary/80 text-sm text-white placeholder-zinc-500 border border-border/30 focus:border-accent/50 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!gifQuery.trim() && !gifLoading && (
|
||||
<div className="flex items-center gap-1.5 px-3 pb-1">
|
||||
<TrendingUp size={12} className="text-zinc-500" />
|
||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold">{t('trending')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-1.5">
|
||||
{gifLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
) : displayGifs.length === 0 ? (
|
||||
<p className="text-center text-xs text-zinc-500 py-10">{gifQuery ? t('nothingFound') : ''}</p>
|
||||
) : (
|
||||
<div className="columns-4 gap-1.5">
|
||||
{displayGifs.map((gif) => (
|
||||
<button
|
||||
key={gif.id}
|
||||
onClick={() => { pickGif(gif); onClose(); }}
|
||||
className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
|
||||
>
|
||||
<img
|
||||
src={getGifPreview(gif, getGifUrl(gif))}
|
||||
alt={gif.title || 'GIF'}
|
||||
className="w-full h-auto rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Search } from 'lucide-react';
|
||||
import { useChatStore } from '../../application/chatStore';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||
|
||||
interface ForwardModalProps {
|
||||
onClose: () => void;
|
||||
onForward: (chatId: string) => void;
|
||||
}
|
||||
|
||||
export default function ForwardModal({ onClose, onForward }: ForwardModalProps) {
|
||||
const { chats } = useChatStore();
|
||||
const { user } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredChats = chats
|
||||
.filter((chat) => {
|
||||
const otherMember = chat.members.find((m) => m.userId !== user?.id);
|
||||
const chatName = chat.type === 'personal'
|
||||
? otherMember?.user.displayName || otherMember?.user.username || t('favorites')
|
||||
: chat.name || t('group');
|
||||
const finalName = chat.type === 'favorites' ? t('favorites') : chatName;
|
||||
return finalName.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.type === 'favorites') return -1;
|
||||
if (b.type === 'favorites') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('forward')}
|
||||
className="relative w-full max-w-md bg-surface-secondary/90 glass-strong rounded-3xl overflow-hidden shadow-2xl border border-border"
|
||||
>
|
||||
<div className="p-4 flex items-center justify-between border-b border-white/5">
|
||||
<h2 className="text-lg font-semibold text-white">{t('forwardMessage')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X size={20} className="text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchChats') || 'Поиск чатов'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-black/20 border border-white/10 rounded-xl py-2.5 pl-10 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:border-knot-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto space-y-1 pr-2 custom-scrollbar">
|
||||
{filteredChats.map((chat) => {
|
||||
const otherMember = chat.members.find((m) => m.userId !== user?.id);
|
||||
const chatName =
|
||||
chat.type === 'favorites' ? t('favorites') :
|
||||
chat.type === 'personal'
|
||||
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
|
||||
: chat.name || t('group');
|
||||
const chatAvatar = chat.type === 'personal'
|
||||
? otherMember?.user.avatar
|
||||
: chat.avatar;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={chat.id}
|
||||
onClick={() => onForward(chat.id)}
|
||||
className="w-full flex items-center gap-3 p-2 rounded-xl hover:bg-white/5 transition-colors text-left"
|
||||
>
|
||||
<Avatar src={chatAvatar} name={chatName} size="md" />
|
||||
<span className="text-white font-medium flex-1 truncate">{chatName}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredChats.length === 0 && (
|
||||
<p className="text-center text-zinc-500 py-4 text-sm">{t('nothingFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
Camera,
|
||||
Edit3,
|
||||
Check,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Search,
|
||||
Crown,
|
||||
Users,
|
||||
ImageIcon,
|
||||
FileText,
|
||||
Link as LinkIcon,
|
||||
Play,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Video
|
||||
} from 'lucide-react';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { ChatApi } from '../../infrastructure/chatApi';
|
||||
import { UserApi } from '../../../users/infrastructure/userApi';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { useChatStore } from '../../application/chatStore';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { Chat, UserPresence, Message } from '../../../../core/domain/types';
|
||||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
|
||||
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
|
||||
import { getMediaUrl } from '../../../../core/utils/utils';
|
||||
import { getCroppedImg } from '../../../../core/infrastructure/imageCrop';
|
||||
|
||||
interface GroupSettingsProps {
|
||||
chat: Chat;
|
||||
onClose: () => void;
|
||||
onGoToMessage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSettingsProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { updateChat } = useChatStore();
|
||||
const { t, lang } = useLang();
|
||||
|
||||
const currentMember = chat.members.find((m) => m.user.id === user?.id);
|
||||
const [removeTargetId, setRemoveTargetId] = useState<string | null>(null);
|
||||
const isAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(currentMember?.role || '');
|
||||
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [isEditingDesc, setIsEditingDesc] = useState(false);
|
||||
const [groupName, setGroupName] = useState(chat.name || '');
|
||||
const [groupDesc, setGroupDesc] = useState(chat.description || '');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [showAddMember, setShowAddMember] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<UserPresence[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'members' | 'gifs' | 'media' | 'files' | 'links'>('members');
|
||||
const [tabLoading, setTabLoading] = useState(false);
|
||||
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
|
||||
const [sharedGifs, setSharedGifs] = useState<Message[]>([]);
|
||||
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
|
||||
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set());
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
// Cropping states
|
||||
const [isCropping, setIsCropping] = useState(false);
|
||||
const [cropImage, setCropImage] = useState<string | null>(null);
|
||||
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Keep local state in sync with chat prop
|
||||
useEffect(() => {
|
||||
setGroupName(chat.name || '');
|
||||
setGroupDesc(chat.description || '');
|
||||
}, [chat.name, chat.description]);
|
||||
|
||||
// Search users to add
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
setIsSearching(true);
|
||||
const results = await UserApi.searchUsers(searchQuery);
|
||||
// Filter out users already in the group
|
||||
const memberIds = new Set(chat.members.map((m) => m.user.id));
|
||||
setSearchResults(results.filter((u) => !memberIds.has(u.id)));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, chat.members]);
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!groupName.trim()) return;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const updatedChat = await ChatApi.updateGroup(chat.id, { name: groupName.trim() });
|
||||
updateChat(updatedChat);
|
||||
setIsEditingName(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDesc = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const updatedChat = await ChatApi.updateGroup(chat.id, { description: groupDesc.trim() });
|
||||
updateChat(updatedChat);
|
||||
setIsEditingDesc(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setCropImage(reader.result as string);
|
||||
setCropFile(file);
|
||||
setIsCropping(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropSave = async () => {
|
||||
if (!cropImage || !croppedAreaPixels) return;
|
||||
setAvatarUploading(true);
|
||||
try {
|
||||
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
|
||||
if (!croppedFile) throw new Error("Could not crop image");
|
||||
|
||||
const updatedChat = await ChatApi.uploadGroupAvatar(chat.id, croppedFile);
|
||||
|
||||
useChatStore.getState().updateChat({ ...chat, avatar: updatedChat.avatar });
|
||||
setIsCropping(false);
|
||||
setCropImage(null);
|
||||
setCropFile(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to crop group avatar:', err);
|
||||
alert(t('error'));
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setAvatarUploading(true);
|
||||
const updatedChat = await ChatApi.uploadGroupAvatar(chat.id, file);
|
||||
updateChat(updatedChat);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = async () => {
|
||||
try {
|
||||
setAvatarUploading(true);
|
||||
const updatedChat = await ChatApi.removeGroupAvatar(chat.id);
|
||||
updateChat(updatedChat);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async (userId: string) => {
|
||||
try {
|
||||
const updatedChat = await ChatApi.addGroupMembers(chat.id, [userId]);
|
||||
updateChat(updatedChat);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
setRemoveTargetId(userId);
|
||||
};
|
||||
|
||||
const confirmRemoveMember = async () => {
|
||||
if (!removeTargetId) return;
|
||||
try {
|
||||
const updatedChat = await ChatApi.removeGroupMember(chat.id, removeTargetId);
|
||||
updateChat(updatedChat);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setRemoveTargetId(null);
|
||||
};
|
||||
|
||||
const initials = (chat.name || 'G')
|
||||
.split(' ')
|
||||
.map((w: string) => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const loadTabData = async (tab: 'gifs' | 'media' | 'files' | 'links') => {
|
||||
if (loadedTabs.has(tab)) return;
|
||||
setTabLoading(true);
|
||||
try {
|
||||
const data = await ChatApi.getSharedMedia(chat.id, tab);
|
||||
if (tab === 'media') setSharedMedia(data);
|
||||
else if (tab === 'gifs') setSharedGifs(data);
|
||||
else if (tab === 'files') setSharedFiles(data);
|
||||
else setSharedLinks(data);
|
||||
setLoadedTabs(prev => new Set(prev).add(tab));
|
||||
} catch (e) {
|
||||
console.error('Failed to load shared', tab, e);
|
||||
} finally {
|
||||
setTabLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTabData('gifs');
|
||||
loadTabData('media');
|
||||
loadTabData('files');
|
||||
loadTabData('links');
|
||||
}, [chat.id]);
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
||||
...m,
|
||||
url: getMediaUrl(m.url),
|
||||
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
|
||||
messageId: msg.id,
|
||||
createdAt: msg.createdAt
|
||||
})));
|
||||
|
||||
const allGifs = sharedGifs.flatMap(msg => (msg.media || []).map(m => ({
|
||||
...m,
|
||||
url: getMediaUrl(m.url),
|
||||
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
|
||||
messageId: msg.id,
|
||||
createdAt: msg.createdAt
|
||||
})));
|
||||
|
||||
const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
const sortedGifs = [...allGifs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
const renderGrouped = <T extends { createdAt: string }>(
|
||||
sortedItems: T[],
|
||||
renderItem: (item: T, originalIndex: number) => React.ReactNode,
|
||||
gridClass?: string
|
||||
) => {
|
||||
let currentGroup: { dateStr: string; items: {item: T, idx: number}[] } | null = null;
|
||||
const groups: { dateStr: string; items: {item: T, idx: number}[] }[] = [];
|
||||
|
||||
sortedItems.forEach((item, idx) => {
|
||||
const date = new Date(item.createdAt);
|
||||
const dateStr = date.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
if (currentGroup?.dateStr !== dateStr) {
|
||||
currentGroup = { dateStr, items: [] };
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup.items.push({item, idx});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pb-4 px-1">
|
||||
{groups.map((g, i) => (
|
||||
<div key={i}>
|
||||
<div className="sticky top-0 z-10 bg-black/60 backdrop-blur-md px-3 py-1.5 mb-1.5 shadow-sm border-y border-white/5">
|
||||
<span className="text-[10px] font-bold text-knot-300 uppercase tracking-widest">{g.dateStr}</span>
|
||||
</div>
|
||||
<div className={gridClass || "flex flex-col gap-0.5"}>
|
||||
{g.items.map(({item, idx}) => renderItem(item, idx))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const tabsConfig = [
|
||||
{ key: 'members' as const, label: t('membersCount') || 'Участники', icon: Users, count: chat.members.length },
|
||||
{ key: 'gifs' as const, label: t('gifs') || 'GIF', icon: Play, count: sortedGifs.length },
|
||||
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length },
|
||||
{ key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length },
|
||||
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length },
|
||||
];
|
||||
|
||||
const availableTabs = tabsConfig.filter(tab => tab.key === 'members' || !loadedTabs.has(tab.key) || tab.count > 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedTabs.size === 4 && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
|
||||
setActiveTab(availableTabs[0].key);
|
||||
}
|
||||
}, [loadedTabs, activeTab]); // availableTabs removed from dependencies to avoid infinite loops since its reference runs on every render
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 z-50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 50, scale: 0.95 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed right-3 top-3 bottom-3 w-[650px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border/40">
|
||||
<h2 className="text-lg font-semibold text-white">{t('groupSettings')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-xl text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0 flex flex-col items-center py-6 px-6 overflow-y-auto max-h-[50%] custom-scrollbar">
|
||||
<div className="relative group">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-40 h-40 bg-knot-500/20 rounded-full blur-[40px] pointer-events-none" />
|
||||
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
|
||||
{chat.avatar ? (
|
||||
<img
|
||||
src={getMediaUrl(chat.avatar)}
|
||||
alt=""
|
||||
className="w-32 h-32 rounded-full object-cover shadow-inner"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-4xl shadow-inner">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={avatarUploading}
|
||||
className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
|
||||
>
|
||||
{avatarUploading ? (
|
||||
<Loader2 size={32} className="text-white animate-spin" />
|
||||
) : (
|
||||
<Camera size={32} className="text-white" />
|
||||
)}
|
||||
</button>
|
||||
{chat.avatar && !avatarUploading && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const updatedChat = await ChatApi.removeGroupAvatar(chat.id);
|
||||
updateChat(updatedChat);
|
||||
} catch (e) {
|
||||
console.error('Failed to remove avatar', e);
|
||||
}
|
||||
}}
|
||||
className="absolute bottom-0 right-0 p-2 rounded-full bg-red-500/90 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col items-center gap-2">
|
||||
{isEditingName ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
className="bg-surface-tertiary border border-accent/30 rounded-xl px-4 py-2 text-lg font-bold text-white text-center focus:outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveName}
|
||||
disabled={isSaving}
|
||||
className="p-2 rounded-lg bg-accent text-white hover:bg-accent-light transition-colors"
|
||||
>
|
||||
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsEditingName(false); setGroupName(chat.name || ''); }}
|
||||
className="p-2 rounded-lg bg-surface-tertiary text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="group/name flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => isAdmin && setIsEditingName(true)}
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-white tracking-tight">
|
||||
{chat.name || t('group')}
|
||||
</h3>
|
||||
{isAdmin && (
|
||||
<Edit3 size={16} className="text-knot-400 opacity-0 group-hover/name:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-zinc-500 text-sm">
|
||||
{chat.members.length} {t('members')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mt-6 w-full space-y-2">
|
||||
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest px-1">
|
||||
{t('groupDescription')}
|
||||
</label>
|
||||
{isEditingDesc ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<textarea
|
||||
value={groupDesc}
|
||||
onChange={(e) => setGroupDesc(e.target.value)}
|
||||
className="flex-1 bg-surface-tertiary border border-accent/30 rounded-xl px-3 py-2 text-sm text-white focus:outline-none min-h-[80px]"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleSaveDesc}
|
||||
disabled={isSaving}
|
||||
className="p-2 rounded-lg bg-accent text-white hover:bg-accent-light transition-colors"
|
||||
>
|
||||
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsEditingDesc(false); setGroupDesc(chat.description || ''); }}
|
||||
className="p-2 rounded-lg bg-surface-tertiary text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => isAdmin && setIsEditingDesc(true)}
|
||||
className={`group/desc relative p-3 rounded-xl border border-white/5 bg-white/5 transition-all ${isAdmin ? 'cursor-pointer hover:bg-white/10 hover:border-white/10' : ''}`}
|
||||
>
|
||||
<p className={`text-sm ${groupDesc ? 'text-zinc-300' : 'text-zinc-600 italic'}`}>
|
||||
{groupDesc || t('noDescription')}
|
||||
</p>
|
||||
{isAdmin && (
|
||||
<div className="absolute top-3 right-3 opacity-0 group-hover/desc:opacity-100 transition-opacity">
|
||||
<Edit3 size={14} className="text-knot-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* Media / Files / Links Tabs */}
|
||||
{availableTabs.length > 0 ? (
|
||||
<div className="mx-4 mb-6 border border-white/5 bg-black/20 rounded-2xl overflow-hidden backdrop-blur-xl flex flex-col flex-1 min-h-0">
|
||||
<div className="flex border-b border-white/5">
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-bold uppercase tracking-widest transition-all ${
|
||||
activeTab === tab.key
|
||||
? 'bg-white/5 text-knot-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<tab.icon size={16} />
|
||||
{(loadedTabs.has(tab.key) || tab.key === 'members') && <span className="text-xs bg-black/40 px-1.5 rounded-full">{tab.count}</span>}
|
||||
</div>
|
||||
<span className="truncate w-full px-1">{tab.label as string}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{tabLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
) : activeTab === 'members' ? (
|
||||
<div className="px-4 py-4 pt-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
|
||||
{t('membersCount')}
|
||||
</h4>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddMember(!showAddMember);
|
||||
if (!showAddMember) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 100);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-knot-400 hover:text-knot-300 transition-colors"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
{t('addMember')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add member search */}
|
||||
<AnimatePresence>
|
||||
{showAddMember && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden mb-3"
|
||||
>
|
||||
<div className="relative mb-2">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('findUser')}
|
||||
className="w-full pl-8 pr-3 py-2 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{isSearching && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 size={16} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{searchResults.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleAddMember(u.id)}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{u.avatar ? (
|
||||
<img src={getMediaUrl(u.avatar)} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
{(u.displayName || u.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p className="text-sm text-white truncate">{u.displayName || u.username}</p>
|
||||
<p className="text-xs text-zinc-500">@{u.username}</p>
|
||||
</div>
|
||||
<UserPlus size={14} className="text-knot-400 flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
{searchQuery.trim() && !isSearching && searchResults.length === 0 && (
|
||||
<p className="text-xs text-zinc-500 text-center py-2">{t('usersNotFound')}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Member list */}
|
||||
<div className="space-y-1">
|
||||
{[...chat.members]
|
||||
.sort((a, b) => {
|
||||
if (a.user.id === user?.id) return -1;
|
||||
if (b.user.id === user?.id) return 1;
|
||||
const aIsAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(a.role || '');
|
||||
const bIsAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(b.role || '');
|
||||
if (aIsAdmin && !bIsAdmin) return -1;
|
||||
if (bIsAdmin && !aIsAdmin) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((member) => (
|
||||
<div
|
||||
key={member.user.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-surface-hover/50 transition-colors group"
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
{member.user.avatar ? (
|
||||
<img src={getMediaUrl(member.user.avatar)} alt="" className="w-9 h-9 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{member.user.isOnline && (
|
||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{member.user.displayName || member.user.username}
|
||||
{member.user.id === user?.id && (
|
||||
<span className="text-zinc-500 ml-1 text-xs">({t('you') || 'вы'})</span>
|
||||
)}
|
||||
</p>
|
||||
{['admin', 'owner', 'Admin', 'Owner'].includes(member.role || '') && (
|
||||
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-400 text-[10px] font-medium flex-shrink-0">
|
||||
<Crown size={10} />
|
||||
{t('adminBadge') || 'Админ'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">@{member.user.username}</p>
|
||||
</div>
|
||||
{isAdmin && member.user.id !== user?.id && !['admin', 'owner', 'Admin', 'Owner'].includes(member.role || '') && (
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.user.id)}
|
||||
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-all flex-shrink-0"
|
||||
title={t('removeMember')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'gifs' ? (
|
||||
sortedGifs.length > 0 ? (
|
||||
renderGrouped(sortedGifs, (m, idx) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
const eContext = { stopPropagation: () => {} } as any;
|
||||
onGoToMessage?.(m.messageId);
|
||||
}}
|
||||
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
|
||||
>
|
||||
<video
|
||||
src={getMediaUrl(m.url)}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
|
||||
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
|
||||
>
|
||||
{t('showInChat')}
|
||||
</button>
|
||||
</div>
|
||||
), "grid grid-cols-3 gap-0.5 px-1")
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">Нет GIF файлов</p>
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'media' ? (
|
||||
sortedMedia.length > 0 ? (
|
||||
renderGrouped(sortedMedia, (m, idx) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
|
||||
>
|
||||
{m.type === 'video' ? (
|
||||
<>
|
||||
<div
|
||||
className="w-full h-full bg-zinc-800 flex items-center justify-center relative group-hover:scale-105 transition-transform duration-200"
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
>
|
||||
{m.thumbnail ? (
|
||||
<img src={getMediaUrl(m.thumbnail)} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-zinc-800 to-zinc-900 flex items-center justify-center">
|
||||
<Video size={32} className="text-white/20" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/40 transition-colors">
|
||||
<Play size={24} className="text-white fill-white" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={getMediaUrl(m.url)}
|
||||
alt=""
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
|
||||
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
|
||||
>
|
||||
{t('showInChat')}
|
||||
</button>
|
||||
</div>
|
||||
), "grid grid-cols-3 gap-0.5 px-1")
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedPhotos')}</p>
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'files' ? (
|
||||
sortedFiles.length > 0 ? (
|
||||
renderGrouped(sortedFiles, (msg, idx) => (
|
||||
<div key={msg.id} className="divide-y divide-border border-b border-border">
|
||||
{(msg.media || []).map((m) => (
|
||||
<div key={m.id} className="relative group/file">
|
||||
<a
|
||||
href={getMediaUrl(m.url)}
|
||||
download={m.filename || 'file'}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-knot-500/10 flex items-center justify-center flex-shrink-0 text-knot-400">
|
||||
<FileText size={16} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] text-zinc-200 truncate">{m.filename || 'File'}</p>
|
||||
<p className="text-[10px] text-zinc-500">{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}</p>
|
||||
</div>
|
||||
<Download size={14} className="text-zinc-600" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(msg.id)}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-300 text-[11px] font-medium opacity-0 group-hover/file:opacity-100 transition-opacity"
|
||||
>
|
||||
{t('showInChat')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedFiles')}</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
sortedLinks.length > 0 ? (
|
||||
renderGrouped(sortedLinks, (msg, idx) => (
|
||||
<div key={msg.id} className="p-4 hover:bg-white/5 transition-colors border-b border-white/5 relative group">
|
||||
{msg.links?.map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-[13px] text-knot-400 hover:underline truncate mb-1"
|
||||
>
|
||||
<ExternalLink size={12} className="flex-shrink-0" />
|
||||
{link}
|
||||
</a>
|
||||
))}
|
||||
{msg.content && <p className="text-[11px] text-zinc-500 line-clamp-1">{msg.content}</p>}
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(msg.id)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg bg-black/40 hover:bg-knot-500/20 text-zinc-300 hover:text-white text-[11px] font-medium opacity-0 group-hover:opacity-100 transition-all shadow-md z-10"
|
||||
>
|
||||
{t('showInChat')}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedLinks')}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : loadedTabs.size === 4 ? (
|
||||
<div className="mx-4 mb-6 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||||
<ImageIcon size={32} className="text-zinc-600 mb-3" />
|
||||
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-4 mb-6 flex items-center justify-center py-10 border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!removeTargetId}
|
||||
message={t('confirmRemoveMember')}
|
||||
onConfirm={confirmRemoveMember}
|
||||
onCancel={() => setRemoveTargetId(null)}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={sortedMedia.map((m) => ({ url: getMediaUrl(m.url), type: m.type }))}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCropping && cropImage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-black/90 backdrop-blur-xl flex flex-col items-center justify-center p-6"
|
||||
>
|
||||
<div className="w-full max-w-[400px] bg-surface-secondary rounded-[2rem] border border-white/10 overflow-hidden shadow-2xl">
|
||||
<div className="p-6 border-b border-white/5 flex items-center justify-between">
|
||||
<h3 className="text-xl font-bold text-white">{t('changePhoto')}</h3>
|
||||
<button onClick={() => setIsCropping(false)} className="text-zinc-400 hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-80 bg-black">
|
||||
<Cropper
|
||||
image={cropImage}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<span className="text-zinc-500 text-xs font-medium uppercase">Zoom</span>
|
||||
<input
|
||||
type="range"
|
||||
value={zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 accent-knot-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 w-full">
|
||||
<button
|
||||
onClick={() => setIsCropping(false)}
|
||||
className="flex-1 py-3 px-4 rounded-xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCropSave}
|
||||
disabled={avatarUploading}
|
||||
className="flex-1 py-3 px-4 rounded-xl bg-accent hover:bg-accent-light text-white font-bold transition-all shadow-lg shadow-accent/20 flex items-center justify-center gap-2"
|
||||
>
|
||||
{avatarUploading ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
|
||||
{t('save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface LinkPreviewProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface MicrolinkData {
|
||||
publisher?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: { url: string };
|
||||
logo?: { url: string };
|
||||
}
|
||||
|
||||
export default function LinkPreview({ url }: LinkPreviewProps) {
|
||||
const [data, setData] = useState<MicrolinkData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
setLoading(true);
|
||||
|
||||
// Check cache first to avoid rate limiting
|
||||
const cacheKey = `link_preview_${url}`;
|
||||
const cached = sessionStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (isMounted) {
|
||||
setData(parsed);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const fetchWithRetry = async (targetUrl: string, attempts = 2) => {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const res = await fetch(`https://api.microlink.io?url=${encodeURIComponent(targetUrl)}`);
|
||||
if (res.ok) {
|
||||
return await res.json();
|
||||
}
|
||||
} catch (error) {
|
||||
if (i === attempts - 1) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error('Max retries reached');
|
||||
};
|
||||
|
||||
fetchWithRetry(url)
|
||||
.then((res) => {
|
||||
if (isMounted && res.status === 'success' && res.data) {
|
||||
setData(res.data);
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(res.data));
|
||||
}
|
||||
if (isMounted) setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
// Suppress errors and stop after max attempts
|
||||
if (isMounted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mt-2 text-xs text-knot-400 opacity-70 italic border-l-[3px] border-knot-500/50 pl-2">
|
||||
Загрузка предпросмотра...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || (!data.title && !data.description && !data.image)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Domain for publisher fallback
|
||||
let domain = data.publisher;
|
||||
if (!domain) {
|
||||
try {
|
||||
domain = new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mt-2 border-l-[3px] border-knot-500 bg-black/20 rounded-r-lg overflow-hidden hover:bg-black/30 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-2.5 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-knot-400">
|
||||
{data.logo?.url && <img src={data.logo.url} alt="" className="w-3.5 h-3.5 rounded-sm object-cover" />}
|
||||
<span className="truncate">{domain}</span>
|
||||
</div>
|
||||
{data.title && <div className="text-sm font-bold text-white leading-tight break-words">{data.title}</div>}
|
||||
{data.description && <div className="text-[13px] text-zinc-300 line-clamp-3 leading-snug">{data.description}</div>}
|
||||
</div>
|
||||
{data.image?.url && (
|
||||
<div className="w-full relative overflow-hidden bg-black/20" style={{ maxHeight: '300px' }}>
|
||||
<img src={data.image.url} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,994 @@
|
||||
import { useState, useRef, useEffect, memo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Check,
|
||||
CheckCheck,
|
||||
Play,
|
||||
Pause,
|
||||
Download,
|
||||
FileText,
|
||||
Copy,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Reply,
|
||||
Smile,
|
||||
MoreHorizontal,
|
||||
X,
|
||||
Volume2,
|
||||
Pin,
|
||||
Clock,
|
||||
Forward,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { useChatStore } from '../../application/chatStore';
|
||||
import { getSocket } from '../../../../core/infrastructure/socket';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { extractWaveform, getMediaUrl } from '../../../../core/utils/utils';
|
||||
import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types';
|
||||
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
|
||||
import LinkPreview from './LinkPreview';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
isMine: boolean;
|
||||
showAvatar: boolean;
|
||||
onViewProfile?: (userId: string) => void;
|
||||
selectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
onStartSelectionMode?: (id: string) => void;
|
||||
onForward?: (id: string) => void;
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
isMine,
|
||||
showAvatar,
|
||||
onViewProfile,
|
||||
selectionMode,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
onStartSelectionMode,
|
||||
onForward
|
||||
}: MessageBubbleProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { setReplyTo, setEditingMessage, pinnedMessages, chats } = useChatStore();
|
||||
const { t, lang } = useLang();
|
||||
const [showContext, setShowContext] = useState(false);
|
||||
const [contextPos, setContextPos] = useState({ x: 0, y: 0 });
|
||||
const [deleteMenuMode, setDeleteMenuMode] = useState(false);
|
||||
const [lightboxData, setLightboxData] = useState<{ index: number } | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [audioProgress, setAudioProgress] = useState(0);
|
||||
const [audioDuration, setAudioDuration] = useState(0);
|
||||
const [waveformBars, setWaveformBars] = useState<number[] | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
const [quotedText, setQuotedText] = useState<string | null>(null);
|
||||
|
||||
// Прочитано
|
||||
const isRead = message.readBy?.some((r) => r.userId !== user?.id);
|
||||
|
||||
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (selectionMode) {
|
||||
onToggleSelect?.(message.id);
|
||||
return;
|
||||
}
|
||||
const rect = bubbleRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
const text = selection?.toString().trim();
|
||||
if (text && bubbleRef.current?.contains(selection?.anchorNode || null)) {
|
||||
setQuotedText(text);
|
||||
} else {
|
||||
setQuotedText(null);
|
||||
}
|
||||
|
||||
const menuWidth = 208;
|
||||
const menuHeight = 350;
|
||||
let x = e.clientX;
|
||||
let y = e.clientY;
|
||||
|
||||
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
|
||||
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
||||
|
||||
setContextPos({ x, y });
|
||||
setShowContext(true);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (message.content) {
|
||||
navigator.clipboard.writeText(message.content);
|
||||
}
|
||||
setShowContext(false);
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
setReplyTo({ ...message, quote: quotedText });
|
||||
setShowContext(false);
|
||||
setQuotedText(null);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditingMessage(message);
|
||||
setShowContext(false);
|
||||
};
|
||||
|
||||
const handleDeleteForAll = () => {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
socket.emit('delete_messages', {
|
||||
messageIds: [message.id],
|
||||
chatId: message.chatId,
|
||||
deleteForAll: true,
|
||||
});
|
||||
}
|
||||
setShowContext(false);
|
||||
setDeleteMenuMode(false);
|
||||
};
|
||||
|
||||
const handleDeleteForMe = () => {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
socket.emit('delete_messages', {
|
||||
messageIds: [message.id],
|
||||
chatId: message.chatId,
|
||||
deleteForAll: false,
|
||||
});
|
||||
}
|
||||
useChatStore.getState().hideMessages([message.id], message.chatId);
|
||||
setShowContext(false);
|
||||
setDeleteMenuMode(false);
|
||||
};
|
||||
|
||||
const chatForDelete = chats.find(c => c.id === message.chatId);
|
||||
const otherMemberName = chatForDelete?.type === 'personal'
|
||||
? chatForDelete.members.find(m => m.user.id !== user?.id)?.user.displayName
|
||||
|| chatForDelete.members.find(m => m.user.id !== user?.id)?.user.username
|
||||
|| ''
|
||||
: '';
|
||||
|
||||
const isPinned = pinnedMessages[message.chatId]?.id === message.id;
|
||||
|
||||
const handlePin = () => {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
if (isPinned) {
|
||||
socket.emit('unpin_message', { messageId: message.id, chatId: message.chatId });
|
||||
} else {
|
||||
socket.emit('pin_message', { messageId: message.id, chatId: message.chatId });
|
||||
}
|
||||
}
|
||||
setShowContext(false);
|
||||
};
|
||||
|
||||
const handleReaction = (emoji: string) => {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
const existingReaction = message.reactions?.find(
|
||||
(r) => r.userId === user?.id && r.emoji === emoji
|
||||
);
|
||||
console.log('[Reaction] handleReaction:', {
|
||||
emoji,
|
||||
messageId: message.id,
|
||||
chatId: message.chatId,
|
||||
existingReaction: !!existingReaction,
|
||||
userId: user?.id
|
||||
});
|
||||
if (existingReaction) {
|
||||
console.log('[Reaction] Emitting remove_reaction');
|
||||
socket.emit('remove_reaction', { messageId: message.id, chatId: message.chatId, emoji });
|
||||
} else {
|
||||
console.log('[Reaction] Emitting add_reaction');
|
||||
socket.emit('add_reaction', { messageId: message.id, chatId: message.chatId, emoji });
|
||||
}
|
||||
} else {
|
||||
console.warn('[Reaction] Socket not available');
|
||||
}
|
||||
setShowContext(false);
|
||||
};
|
||||
|
||||
const toggleAudio = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
if (audio.readyState < 2) {
|
||||
audio.load();
|
||||
}
|
||||
audio.play().then(() => {
|
||||
setIsPlaying(true);
|
||||
}).catch((err) => {
|
||||
console.error('Audio play error:', err);
|
||||
audio.load();
|
||||
audio.play().then(() => setIsPlaying(true)).catch(console.error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (audio.duration) {
|
||||
setAudioProgress((audio.currentTime / audio.duration) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
setAudioDuration(audio.duration);
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
setAudioProgress(0);
|
||||
};
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
||||
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate);
|
||||
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const voiceUrl = message.media?.find((m) => m.type === 'voice')?.url;
|
||||
if (!voiceUrl) return;
|
||||
extractWaveform(voiceUrl, 28).then(setWaveformBars);
|
||||
}, [message.media]);
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
if (!sec || isNaN(sec) || !isFinite(sec)) return '0:00';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showContext) return;
|
||||
const hideMenu = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setShowContext(false);
|
||||
setDeleteMenuMode(false);
|
||||
};
|
||||
window.addEventListener('click', hideMenu, true);
|
||||
window.addEventListener('contextmenu', hideMenu, true);
|
||||
return () => {
|
||||
window.removeEventListener('click', hideMenu, true);
|
||||
window.removeEventListener('contextmenu', hideMenu, true);
|
||||
};
|
||||
}, [showContext]);
|
||||
|
||||
if (message.isDeleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const media = message.media || [];
|
||||
const hasImage = media.some((m) => m.type === 'image');
|
||||
const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice');
|
||||
const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio'));
|
||||
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio');
|
||||
const hasVideo = media.some((m) => m.type === 'video');
|
||||
|
||||
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string }[] }> = {};
|
||||
(message.reactions || []).forEach((r) => {
|
||||
if (!reactionGroups[r.emoji]) {
|
||||
reactionGroups[r.emoji] = { count: 0, users: [], isMine: false, avatars: [] };
|
||||
}
|
||||
reactionGroups[r.emoji].count++;
|
||||
const displayName = r.user?.displayName || r.user?.username || '?';
|
||||
reactionGroups[r.emoji].users.push(displayName);
|
||||
if (reactionGroups[r.emoji].avatars.length < 3) {
|
||||
reactionGroups[r.emoji].avatars.push({
|
||||
url: r.user?.avatar,
|
||||
initials: displayName[0].toUpperCase()
|
||||
});
|
||||
}
|
||||
if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
|
||||
});
|
||||
|
||||
const senderName = message.sender?.displayName || message.sender?.username || '';
|
||||
const senderAvatar = message.sender?.avatar;
|
||||
|
||||
const firstUrlMatch = message.content?.match(/https?:\/\/[^\s]+/);
|
||||
const firstUrl = firstUrlMatch ? firstUrlMatch[0] : null;
|
||||
|
||||
const renderFormattedText = (text: string) => {
|
||||
if (!text) return text;
|
||||
const parts = text.split(/(\*\*[\s\S]*?\*\*|\*[\s\S]*?\*|_[\s\S]*?_|~[\s\S]*?~|`[\s\S]*?`|@\w+|https?:\/\/[^\s]+)/g);
|
||||
|
||||
return parts.map((part, i) => {
|
||||
if (part.match(/^https?:\/\/[^\s]+$/)) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sky-400 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (part.startsWith('**') && part.endsWith('**')) return <strong key={i} className="font-bold">{part.slice(2, -2)}</strong>;
|
||||
if (part.startsWith('_') && part.endsWith('_')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
|
||||
if (part.startsWith('*') && part.endsWith('*')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
|
||||
if (part.startsWith('~') && part.endsWith('~')) return <del key={i} className="line-through opacity-80">{part.slice(1, -1)}</del>;
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return <code key={i} className="font-mono text-[13px] bg-black/20 px-1 py-0.5 rounded-[0.35rem]">{part.slice(1, -1)}</code>;
|
||||
}
|
||||
if (part.startsWith('@') && part.length > 1) {
|
||||
const mentionUsername = part.slice(1);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="font-semibold text-sky-300 cursor-pointer hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const chat = chats.find(c => c.id === message.chatId);
|
||||
const members = chat?.members || [];
|
||||
const found = members.find((m) => m.user?.username === mentionUsername);
|
||||
if (found) {
|
||||
onViewProfile?.(found.user.id);
|
||||
}
|
||||
}}
|
||||
>{part}</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
className={`flex ${isMine ? 'justify-end' : 'justify-start'} group mb-0.5 relative transition-colors duration-200 ${selectionMode ? 'px-4 -mx-4 cursor-pointer hover:bg-white/5 rounded-xl' : ''
|
||||
} ${isSelected ? 'bg-knot-500/10 hover:bg-knot-500/20' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectionMode) onToggleSelect?.(message.id);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border border-white/30 flex items-center justify-center transition-colors">
|
||||
{isSelected && <div className="w-5 h-5 rounded-full bg-knot-500 flex items-center justify-center">
|
||||
<Check size={12} className="text-white" />
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMine && (
|
||||
<div className="w-8 flex-shrink-0 mr-2 self-end">
|
||||
{showAvatar ? (
|
||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||
{senderAvatar ? (
|
||||
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
|
||||
{senderName[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`max-[500px]:max-w-[85%] max-w-[75%] lg:max-w-[65%] min-w-0 ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||
{!isMine && showAvatar && (
|
||||
<button
|
||||
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
|
||||
onClick={() => onViewProfile?.(message.senderId)}
|
||||
>
|
||||
{senderName}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
id={`msg-${message.id}`}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDoubleClick={handleReply}
|
||||
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
||||
className={`cursor-pointer max-w-full min-w-0 rounded-[1.25rem] overflow-hidden transition-all duration-300 ${
|
||||
hasImage && !message.content && !message.forwardedFrom && !message.replyTo
|
||||
? 'p-0 shadow-none border-none'
|
||||
: isMine
|
||||
? 'bubble-sent text-white shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-br-sm'
|
||||
: 'bubble-received text-zinc-100 shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-bl-[4px]'
|
||||
}`}
|
||||
>
|
||||
|
||||
{/* Reply */}
|
||||
{message.replyTo && (
|
||||
<div
|
||||
className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] cursor-pointer transition-colors -mx-1 px-1 rounded-sm ${
|
||||
isMine ? 'border-l-white/80 hover:bg-white/10' : 'border-l-knot-500 hover:bg-knot-500/10'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const el = document.getElementById(`msg-${message.replyToId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('highlight-message');
|
||||
setTimeout(() => el.classList.remove('highlight-message'), 5000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className={`text-[13.5px] font-semibold mb-0.5 truncate ${isMine ? 'text-white' : 'text-knot-500'}`}>
|
||||
{message.replyTo.sender?.displayName || message.replyTo.sender?.username}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{message.replyTo.isDeleted ? (
|
||||
<p className="text-[13px] text-white/50 italic truncate">{t('messageDeleted')}</p>
|
||||
) : (
|
||||
<>
|
||||
{message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (() => {
|
||||
const m = message.replyTo.media[0];
|
||||
const isMp4 = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4');
|
||||
return (
|
||||
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0 relative">
|
||||
{m.type === 'image' ? (
|
||||
isMp4 ? (
|
||||
<video src={m.url} className="w-full h-full object-cover" muted playsInline />
|
||||
) : (
|
||||
<img src={m.url} className="w-full h-full object-cover" alt="" />
|
||||
)
|
||||
) : m.type === 'video' ? (
|
||||
<>
|
||||
<video src={m.url} className="w-full h-full object-cover" muted playsInline />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40"><Play size={10} className="text-white" /></div>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center"><FileText size={10} className="text-white/50" /></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
|
||||
{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? (() => {
|
||||
const m = message.replyTo.media[0];
|
||||
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return 'GIF';
|
||||
if (m.type === 'image') return t('photo');
|
||||
if (m.type === 'video') return t('video');
|
||||
if (m.type === 'voice') return t('voice');
|
||||
return t('media');
|
||||
})() : '')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Story Reply Quote */}
|
||||
{message.storyId && (
|
||||
<div
|
||||
className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] transition-colors -mx-1 px-1 rounded-sm ${
|
||||
isMine ? 'border-l-white/80 hover:bg-white/10' : 'border-l-knot-500 hover:bg-knot-500/10'
|
||||
}`}
|
||||
>
|
||||
<p className={`text-[11px] font-bold uppercase tracking-wider mb-1 ${isMine ? 'text-white/80' : 'text-knot-500/80'}`}>
|
||||
{t('story')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{message.storyMediaUrl && (
|
||||
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0">
|
||||
{message.storyMediaType === 'video' ? (
|
||||
<div className="w-full h-full relative">
|
||||
<video src={getMediaUrl(message.storyMediaUrl)} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20"><Play size={10} className="text-white fill-white" /></div>
|
||||
</div>
|
||||
) : message.storyMediaType === 'image' ? (
|
||||
<img src={getMediaUrl(message.storyMediaUrl)} className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-knot-500/20"><FileText size={10} className="text-knot-400" /></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
|
||||
{message.quote}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Рендер пересланного сообщения */}
|
||||
{message.forwardedFrom && (
|
||||
<div
|
||||
className="mb-1.5 text-[14px] opacity-90 border-l-[3px] border-white/40 pl-2.5 py-0.5 cursor-pointer hover:bg-white/5 transition-colors -mx-1 px-1 rounded-sm"
|
||||
onClick={() => onViewProfile?.(message.forwardedFromId!)}
|
||||
>
|
||||
<div className={`font-semibold ${isMine ? 'text-white' : 'text-knot-500'}`}>
|
||||
{message.forwardedFrom.displayName || message.forwardedFrom.username}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Изображения и Видео (Галерея) */}
|
||||
{(hasImage || hasVideo) && (() => {
|
||||
const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video');
|
||||
const isSingleGif = galleryMedia.length === 1 && (
|
||||
galleryMedia[0].filename === 'gif' ||
|
||||
galleryMedia[0].filename === 'gif.gif' ||
|
||||
galleryMedia[0].url?.includes('klipy') ||
|
||||
galleryMedia[0].url?.endsWith('.gif')
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
${(message.content || message.forwardedFrom) ? '-mx-4' : ''}
|
||||
${(message.content || message.forwardedFrom) ? (message.forwardedFrom ? 'mt-2' : '-mt-2.5') : ''}
|
||||
${(message.content || message.forwardedFrom) ? (message.content ? 'mb-2' : '-mb-2.5') : ''}
|
||||
${isSingleGif && !(message.content || message.forwardedFrom) ? 'max-w-[260px] rounded-[1.25rem]' : ''}
|
||||
${isSingleGif && (message.content || message.forwardedFrom) ? 'max-h-[260px] mx-auto' : ''}
|
||||
bg-black/20 overflow-hidden relative
|
||||
`}>
|
||||
<div className={`grid gap-[2px] ${galleryMedia.length >= 3
|
||||
? 'grid-cols-3'
|
||||
: galleryMedia.length === 2
|
||||
? 'grid-cols-2'
|
||||
: 'grid-cols-1'
|
||||
}`}>
|
||||
{galleryMedia.map((m, idx) => {
|
||||
const isMp4Gif = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4');
|
||||
return m.type === 'image' ? (
|
||||
isMp4Gif ? (
|
||||
<video
|
||||
key={m.id}
|
||||
src={m.url}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'}`}
|
||||
onClick={() => setLightboxData({ index: idx })}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={m.id}
|
||||
src={m.url}
|
||||
alt=""
|
||||
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'}`}
|
||||
onClick={() => setLightboxData({ index: idx })}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`relative cursor-pointer group/video ${galleryMedia.length > 1 ? 'aspect-square' : ''
|
||||
}`}
|
||||
onClick={() => setLightboxData({ index: idx })}
|
||||
>
|
||||
<video
|
||||
src={m.url}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover/video:bg-black/40 transition-colors">
|
||||
<Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Голосовое */}
|
||||
{hasVoice && (
|
||||
<div className="flex items-center gap-3 min-w-[200px]">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={media.find((m) => m.type === 'voice')?.url}
|
||||
preload="auto"
|
||||
onError={(e) => console.error('Audio load error:', e)}
|
||||
/>
|
||||
<button
|
||||
onClick={toggleAudio}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-knot-500/20 hover:bg-knot-500/30'
|
||||
} transition-colors`}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} className={isMine ? 'text-white' : 'text-knot-400'} />
|
||||
) : (
|
||||
<Play size={16} className={`${isMine ? 'text-white' : 'text-knot-400'} ml-0.5`} />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="flex items-end gap-[2px] h-6 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !audio.duration) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
audio.currentTime = pct * audio.duration;
|
||||
setAudioProgress(pct * 100);
|
||||
if (!isPlaying) toggleAudio();
|
||||
}}
|
||||
>
|
||||
{(waveformBars || Array(28).fill(0.5)).map((val, i) => {
|
||||
const barHeight = Math.max(10, val * 100);
|
||||
const progress = audioProgress / 100;
|
||||
const barProgress = i / 28;
|
||||
const isActive = barProgress < progress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
|
||||
? isMine ? 'bg-white/80' : 'bg-knot-400'
|
||||
: isMine ? 'bg-white/20' : 'bg-white/10'
|
||||
}`}
|
||||
style={{ height: `${barHeight}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
|
||||
{isPlaying
|
||||
? formatDuration(audioRef.current?.currentTime || 0)
|
||||
: formatDuration(audioDuration || message.media?.find((m) => m.type === 'voice')?.duration || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Аудио (mp3 файлы) */}
|
||||
{hasAudio && (() => {
|
||||
const audioMedia = media.find((m) => m.type === 'audio');
|
||||
return (
|
||||
<div className="min-w-[220px]">
|
||||
{audioMedia?.filename && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Volume2 size={14} className={isMine ? 'text-white/60' : 'text-knot-400'} />
|
||||
<span className={`text-xs truncate ${isMine ? 'text-white/70' : 'text-zinc-400'}`}>{audioMedia.filename}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioMedia?.url}
|
||||
preload="auto"
|
||||
onError={(e) => console.error('Audio load error:', e)}
|
||||
/>
|
||||
<button
|
||||
onClick={toggleAudio}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-knot-500/20 hover:bg-knot-500/30'
|
||||
} transition-colors`}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} className={isMine ? 'text-white' : 'text-knot-400'} />
|
||||
) : (
|
||||
<Play size={16} className={`${isMine ? 'text-white' : 'text-knot-400'} ml-0.5`} />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-[2px] h-6">
|
||||
{Array.from({ length: 28 }).map((_, i) => {
|
||||
const barHeight = [40, 65, 35, 80, 50, 90, 45, 70, 55, 85, 30, 75, 60, 95, 40, 80, 50, 70, 35, 90, 55, 65, 45, 85, 60, 75, 50, 40][i] || 50;
|
||||
const progress = audioProgress / 100;
|
||||
const barProgress = i / 28;
|
||||
const isActive = barProgress < progress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
|
||||
? isMine ? 'bg-white/80' : 'bg-knot-400'
|
||||
: isMine ? 'bg-white/20' : 'bg-white/10'
|
||||
}`}
|
||||
style={{ height: `${barHeight}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
|
||||
{isPlaying
|
||||
? formatDuration(audioRef.current?.currentTime || 0)
|
||||
: formatDuration(audioDuration || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Файлы */}
|
||||
{hasFile &&
|
||||
media
|
||||
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio')
|
||||
.map((m) => (
|
||||
<a
|
||||
key={m.id}
|
||||
href={m.url}
|
||||
download={m.filename || 'file'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-3 p-2 rounded-xl ${isMine ? 'bg-white/10 hover:bg-white/15' : 'bg-surface-tertiary hover:bg-surface-hover'
|
||||
} transition-colors mb-1`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isMine ? 'bg-white/20' : 'bg-knot-500/20'
|
||||
}`}>
|
||||
<FileText size={20} className={isMine ? 'text-white' : 'text-knot-400'} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{m.filename || t('fileLabel')}</p>
|
||||
<p className={`text-xs ${isMine ? 'text-white/50' : 'text-zinc-500'}`}>
|
||||
{m.size ? `${(m.size / 1024).toFixed(1)} ${t('kb')}` : t('download')}
|
||||
</p>
|
||||
</div>
|
||||
<Download size={16} className={isMine ? 'text-white/50' : 'text-zinc-500'} />
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Текст */}
|
||||
{message.content && (() => {
|
||||
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
|
||||
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
|
||||
return (
|
||||
<div className="flex items-end gap-2 text-sm w-full">
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''}`}>
|
||||
{renderFormattedText(message.content)}
|
||||
</p>
|
||||
{firstUrl && !hasImage && !hasVideo && !hasFile && (
|
||||
<div className="w-full mt-1 mb-1 relative overflow-hidden">
|
||||
<LinkPreview url={firstUrl} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10.5px] flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-white/60' : 'text-zinc-500'
|
||||
}`}>
|
||||
{message.isEdited && <span className="mr-0.5">{t('edited')}</span>}
|
||||
{message.scheduledAt && <Clock size={11} className="text-amber-400 mr-0.5" />}
|
||||
{timeStr}
|
||||
{isMine && !message.scheduledAt && (
|
||||
isRead ? (
|
||||
<CheckCheck size={14} className="text-sky-300 ml-0.5" />
|
||||
) : (
|
||||
<Check size={14} className="ml-0.5" />
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{!message.content && (hasImage || hasVideo) && (
|
||||
<div className={`flex justify-end px-3 py-1 ${hasImage ? '-mt-8 relative z-10' : ''}`}>
|
||||
<span className="text-[10px] text-white/70 bg-black/40 px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-sm">
|
||||
{timeStr}
|
||||
{isMine && (
|
||||
isRead ? (
|
||||
<CheckCheck size={13} className="text-sky-300" />
|
||||
) : (
|
||||
<Check size={13} />
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Реакции */}
|
||||
{Object.keys(reactionGroups).length > 0 && (
|
||||
<div className={`flex flex-wrap gap-1 mt-1.5 ${isMine ? 'justify-end' : 'justify-start'}`}>
|
||||
{Object.entries(reactionGroups).map(([emoji, data]) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 ${hasImage && !message.content ? 'backdrop-blur-md bg-black/40 text-white' : (isMine ? 'glass-panel text-white border-white/10 shadow-sm' : 'bg-surface-tertiary text-zinc-200 border-white/5 shadow-sm')} rounded-full transition-colors border ${
|
||||
data.isMine
|
||||
? (isMine ? 'bg-white/20 border-white/30' : 'bg-knot-500/20 border-knot-500/40')
|
||||
: (isMine ? 'hover:bg-white/10' : 'hover:border-white/20')
|
||||
}`}
|
||||
title={data.users.join(', ')}
|
||||
>
|
||||
<span className="text-[17px] leading-none">{emoji}</span>
|
||||
{(data.avatars && data.avatars.length > 0) ? (
|
||||
<div className="flex -space-x-1.5 ml-0.5">
|
||||
{data.avatars.map((av, idx) => (
|
||||
av.url ? (
|
||||
<img key={idx} src={av.url} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 object-cover" />
|
||||
) : (
|
||||
<div key={idx} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[9px] font-bold">
|
||||
{av.initials}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[12px] font-medium opacity-80 tabular-nums">{data.count}</span>
|
||||
)}
|
||||
{data.count > 1 && data.avatars && data.avatars.length > 0 && (
|
||||
<span className="text-[12px] font-bold opacity-80 tabular-nums ml-1.5 mr-0.5">{data.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMine && (
|
||||
<div className="w-8 flex-shrink-0 ml-2 self-end">
|
||||
{showAvatar ? (
|
||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||
{senderAvatar ? (
|
||||
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
|
||||
{senderName[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{typeof document !== 'undefined' && createPortal(
|
||||
<AnimatePresence>
|
||||
{showContext && (
|
||||
<motion.div
|
||||
ref={contextMenuRef}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="fixed z-[9999] w-52 rounded-[1.25rem] glass-strong shadow-2xl py-1.5 overflow-hidden border border-white/10"
|
||||
style={{ left: contextPos.x, top: contextPos.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{deleteMenuMode ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
|
||||
<button
|
||||
onClick={() => setDeleteMenuMode(false)}
|
||||
className="p-1 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
|
||||
</button>
|
||||
<span className="text-sm font-medium text-zinc-300">{t('delete')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDeleteForMe}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Trash2 size={16} className="text-zinc-400" />
|
||||
{t('deleteForMe')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteForAll}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{chatForDelete?.type === 'personal' && otherMemberName
|
||||
? `${t('deleteAlsoFor')} ${otherMemberName}`
|
||||
: t('deleteForAll')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1 px-3 py-2 border-b border-border">
|
||||
{['👍', '❤️', '😂', '😮', '😢', '🔥'].map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => handleReaction(emoji)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-surface-hover transition-colors text-lg"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReply}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Reply size={16} />
|
||||
{quotedText ? t('replyWithQuote') : t('reply')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowContext(false);
|
||||
onStartSelectionMode?.(message.id);
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<CheckCheck size={16} />
|
||||
{t('select')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowContext(false);
|
||||
onForward?.(message.id);
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Forward size={16} />
|
||||
{t('forward')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handlePin}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Pin size={16} />
|
||||
{isPinned ? t('unpinMessage') : t('pinMessage')}
|
||||
</button>
|
||||
|
||||
{message.content && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Copy size={16} />
|
||||
{t('copy')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isMine && message.content && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
{t('edit')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
onClick={() => setDeleteMenuMode(true)}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t('delete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{lightboxData && (
|
||||
<ImageLightbox
|
||||
images={media.filter(m => m.type === 'image' || m.type === 'video').map(m => ({ url: m.url, type: m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4') ? 'video' : m.type }))}
|
||||
initialIndex={lightboxData.index}
|
||||
onClose={() => setLightboxData(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MessageBubble);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,380 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { ChatApi } from '../../infrastructure/chatApi';
|
||||
import { UserApi } from '../../../users/infrastructure/userApi';
|
||||
import { FriendApi } from '../../../friends/infrastructure/friendApi';
|
||||
import { useChatStore } from '../../application/chatStore';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import type { UserPresence, FriendWithId } from '../../../../core/domain/types';
|
||||
|
||||
interface NewChatModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Mode = 'personal' | 'group-select' | 'group-name';
|
||||
|
||||
export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
const { user, config } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
const { addChat, setActiveChat, loadMessages } = useChatStore();
|
||||
const [mode, setMode] = useState<Mode>('personal');
|
||||
const [query, setQuery] = useState('');
|
||||
const [users, setUsers] = useState<UserPresence[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserPresence[]>([]);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [friends, setFriends] = useState<FriendWithId[]>([]);
|
||||
|
||||
// Load friends on mount
|
||||
useEffect(() => {
|
||||
FriendApi.getFriends().then(setFriends).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim() || query.trim().length < 3) {
|
||||
setUsers([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const results = await UserApi.searchUsers(query);
|
||||
setUsers(results.filter((u) => u.id !== user?.id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, user?.id]);
|
||||
|
||||
const handleSelectUser = async (selectedUser: UserPresence) => {
|
||||
if (mode === 'personal') {
|
||||
try {
|
||||
const chat = await ChatApi.createPersonalChat(selectedUser.id);
|
||||
addChat(chat);
|
||||
import('../../../../core/infrastructure/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
|
||||
setActiveChat(chat.id);
|
||||
loadMessages(chat.id);
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
}
|
||||
} else {
|
||||
// Toggle selection
|
||||
setSelectedUsers((prev) => {
|
||||
const exists = prev.find((u) => u.id === selectedUser.id);
|
||||
if (exists) return prev.filter((u) => u.id !== selectedUser.id);
|
||||
return [...prev, selectedUser];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
if (!groupName.trim() || selectedUsers.length === 0) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const chat = await ChatApi.createGroupChat(
|
||||
groupName.trim(),
|
||||
selectedUsers.map((u) => u.id)
|
||||
);
|
||||
addChat(chat);
|
||||
import('../../../../core/infrastructure/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
|
||||
setActiveChat(chat.id);
|
||||
loadMessages(chat.id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (userId: string) => selectedUsers.some((u) => u.id === userId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 z-50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-2xl glass-strong shadow-2xl overflow-hidden" role="dialog" aria-modal="true" aria-label={t('newChat')}>
|
||||
{/* Шапка */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
{mode !== 'personal' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (mode === 'group-name') setMode('group-select');
|
||||
else {
|
||||
setMode('personal');
|
||||
setSelectedUsers([]);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{mode === 'personal'
|
||||
? t('newChatTitle')
|
||||
: mode === 'group-select'
|
||||
? t('selectMembers')
|
||||
: t('newGroup')}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'group-name' ? (
|
||||
/* Шаг 2: Назвать группу */
|
||||
<div className="p-4 space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('groupNamePlaceholder')}
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2">
|
||||
{t('membersCount')} ({selectedUsers.length}):
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedUsers.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-knot-500/20 border border-knot-500/30"
|
||||
>
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[9px] font-semibold">
|
||||
{(u.displayName || u.username)?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-white">{u.displayName || u.username}</span>
|
||||
<button
|
||||
onClick={() => setSelectedUsers((prev) => prev.filter((p) => p.id !== u.id))}
|
||||
className="text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateGroup}
|
||||
disabled={!groupName.trim() || isCreating}
|
||||
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Users size={16} />
|
||||
{t('createGroup')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Переключатель режима + Поиск */}
|
||||
<div className="p-4 space-y-3">
|
||||
{mode === 'personal' && (
|
||||
<button
|
||||
onClick={() => setMode('group-select')}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl bg-surface-tertiary hover:bg-surface-hover transition-colors border border-border"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center">
|
||||
<Users size={18} className="text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">{t('createGroup')}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{t('upTo200').replace('200', String(config?.maxGroupMembers || 500))}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Выбранные (в режиме группы) */}
|
||||
{mode === 'group-select' && selectedUsers.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedUsers.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => setSelectedUsers((prev) => prev.filter((p) => p.id !== u.id))}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-knot-500/20 border border-knot-500/30 text-xs text-white hover:bg-knot-500/30 transition-colors"
|
||||
>
|
||||
{(u.displayName || u.username)}
|
||||
<X size={11} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
mode === 'personal'
|
||||
? t('findUser')
|
||||
: t('addMembers')
|
||||
}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Результаты */}
|
||||
<div className="max-h-72 overflow-y-auto px-2 pb-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-5 h-5 border-2 border-knot-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : query.trim().length >= 3 && users.length > 0 ? (
|
||||
users.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleSelectUser(u)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors ${
|
||||
isSelected(u.id)
|
||||
? 'bg-knot-500/15 border border-knot-500/30'
|
||||
: 'hover:bg-surface-hover border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-semibold text-sm">
|
||||
{(u.displayName || u.username)?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
{u.isOnline && (
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{u.displayName || u.username}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 truncate">@{u.username}</p>
|
||||
</div>
|
||||
{mode === 'group-select' && (
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
isSelected(u.id)
|
||||
? 'bg-knot-500 border-knot-500'
|
||||
: 'border-zinc-600'
|
||||
}`}>
|
||||
{isSelected(u.id) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : query.trim().length >= 3 && users.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-500">
|
||||
<p className="text-sm">{t('usersNotFound')}</p>
|
||||
</div>
|
||||
) : query.trim().length > 0 && query.trim().length < 3 ? (
|
||||
<div className="text-center py-6 text-zinc-500">
|
||||
<p className="text-sm">{t('minCharsHint')}</p>
|
||||
</div>
|
||||
) : friends.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider px-2 mb-2 font-semibold">{t('friends')}</p>
|
||||
{friends.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleSelectUser(u)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors ${
|
||||
isSelected(u.id)
|
||||
? 'bg-knot-500/15 border border-knot-500/30'
|
||||
: 'hover:bg-surface-hover border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-semibold text-sm">
|
||||
{(u.displayName || u.username)?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
{u.isOnline && (
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{u.displayName || u.username}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 truncate">@{u.username}</p>
|
||||
</div>
|
||||
{mode === 'group-select' && (
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
isSelected(u.id)
|
||||
? 'bg-knot-500 border-knot-500'
|
||||
: 'border-zinc-600'
|
||||
}`}>
|
||||
{isSelected(u.id) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-zinc-500">
|
||||
<MessageSquare size={32} className="opacity-30" />
|
||||
<p className="text-sm">{t('enterNameOrUsername')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка "Далее" для группы */}
|
||||
{mode === 'group-select' && selectedUsers.length > 0 && (
|
||||
<div className="p-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setMode('group-name')}
|
||||
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{t('next')}
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
|
||||
export default function TypingIndicator() {
|
||||
const { t } = useLang();
|
||||
return (
|
||||
<div className="flex items-center gap-1 py-1">
|
||||
<span className="text-xs text-knot-400 font-medium">{t('typingText')}</span>
|
||||
<div className="flex gap-0.5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="w-1 h-1 rounded-full bg-knot-400"
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { create } from 'zustand';
|
||||
import { UserApi } from '../../users/infrastructure/userApi';
|
||||
import { FriendApi } from '../infrastructure/friendApi';
|
||||
import type { FriendWithId, FriendRequest, UserPresence } from '../../../core/domain/types';
|
||||
import { getSocket } from '../../../core/infrastructure/socket';
|
||||
|
||||
interface FriendState {
|
||||
friends: FriendWithId[];
|
||||
friendRequests: FriendRequest[];
|
||||
isLoading: boolean;
|
||||
searchQuery: string;
|
||||
searchResults: UserPresence[];
|
||||
isSearching: boolean;
|
||||
|
||||
setSearchQuery: (query: string) => void;
|
||||
loadFriends: () => Promise<void>;
|
||||
acceptRequest: (requestId: string) => Promise<void>;
|
||||
declineRequest: (requestId: string) => Promise<void>;
|
||||
removeFriend: (friendshipId: string) => Promise<void>;
|
||||
sendRequest: (friendId: string) => Promise<void>;
|
||||
searchFriends: (query: string, currentUserId?: string) => Promise<void>;
|
||||
clearSearch: () => void;
|
||||
initializeSocketEvents: () => () => void;
|
||||
}
|
||||
|
||||
export const useFriendStore = create<FriendState>((set, get) => ({
|
||||
friends: [],
|
||||
friendRequests: [],
|
||||
isLoading: false,
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
|
||||
loadFriends: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const [friendsList, requests] = await Promise.all([
|
||||
FriendApi.getFriends(),
|
||||
FriendApi.getFriendRequests(),
|
||||
]);
|
||||
set({ friends: friendsList, friendRequests: requests });
|
||||
} catch (e) {
|
||||
console.error('Load friends error:', e);
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
acceptRequest: async (requestId) => {
|
||||
try {
|
||||
await FriendApi.acceptFriendRequest(requestId);
|
||||
const req = get().friendRequests.find(r => r.id === requestId);
|
||||
if (req) {
|
||||
const socket = getSocket();
|
||||
if (socket) socket.emit('friend_accepted', { friendId: req.user.id });
|
||||
}
|
||||
await get().loadFriends();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
declineRequest: async (requestId) => {
|
||||
try {
|
||||
await FriendApi.declineFriendRequest(requestId);
|
||||
set((state) => ({
|
||||
friendRequests: state.friendRequests.filter(r => r.id !== requestId)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
removeFriend: async (friendshipId) => {
|
||||
try {
|
||||
const friend = get().friends.find(f => f.friendshipId === friendshipId);
|
||||
await FriendApi.removeFriend(friendshipId);
|
||||
if (friend) {
|
||||
const socket = getSocket();
|
||||
if (socket) socket.emit('friend_removed', { friendId: friend.id });
|
||||
}
|
||||
set((state) => ({
|
||||
friends: state.friends.filter(f => f.friendshipId !== friendshipId)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
sendRequest: async (friendId) => {
|
||||
try {
|
||||
const result = await FriendApi.sendFriendRequest(friendId);
|
||||
const socket = getSocket();
|
||||
if (socket) socket.emit('friend_request', { friendId });
|
||||
|
||||
if (result.status === 'accepted') {
|
||||
await get().loadFriends();
|
||||
}
|
||||
set((state) => ({
|
||||
searchResults: state.searchResults.filter(u => u.id !== friendId)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
searchFriends: async (query, currentUserId) => {
|
||||
const raw = query.trim();
|
||||
const q = raw.startsWith('@') ? raw.slice(1) : raw;
|
||||
|
||||
if (q.length < 3) {
|
||||
set({ searchResults: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isSearching: true });
|
||||
try {
|
||||
const results = await UserApi.searchUsers(q);
|
||||
const { friends } = get();
|
||||
const friendIds = new Set(friends.map(f => f.id));
|
||||
|
||||
set({
|
||||
searchResults: results.filter(u => u.id !== currentUserId && !friendIds.has(u.id))
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
set({ isSearching: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearSearch: () => set({ searchQuery: '', searchResults: [] }),
|
||||
|
||||
initializeSocketEvents: () => {
|
||||
const socket = getSocket();
|
||||
if (!socket) return () => {};
|
||||
|
||||
const onFriendRequestReceived = () => {
|
||||
FriendApi.getFriendRequests()
|
||||
.then(reqs => set({ friendRequests: reqs }))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const onFriendRequestAccepted = () => {
|
||||
get().loadFriends();
|
||||
};
|
||||
|
||||
const onFriendRemoved = (data: { userId: string }) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.filter(f => f.id !== data.userId)
|
||||
}));
|
||||
};
|
||||
|
||||
socket.on('friend_request_received', onFriendRequestReceived);
|
||||
socket.on('friend_request_accepted', onFriendRequestAccepted);
|
||||
socket.on('friend_removed', onFriendRemoved);
|
||||
|
||||
return () => {
|
||||
socket.off('friend_request_received', onFriendRequestReceived);
|
||||
socket.off('friend_request_accepted', onFriendRequestAccepted);
|
||||
socket.off('friend_removed', onFriendRemoved);
|
||||
};
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,39 @@
|
||||
import { httpClient } from '../../../core/infrastructure/httpClient';
|
||||
import type { FriendshipStatus, FriendRequest, FriendWithId } from '../../../core/domain/types';
|
||||
|
||||
export class FriendApi {
|
||||
static async getFriends() {
|
||||
return httpClient.request<FriendWithId[]>('/friends');
|
||||
}
|
||||
|
||||
static async getFriendRequests() {
|
||||
return httpClient.request<FriendRequest[]>('/friends/requests');
|
||||
}
|
||||
|
||||
static async getOutgoingRequests() {
|
||||
return httpClient.request<FriendRequest[]>('/friends/outgoing');
|
||||
}
|
||||
|
||||
static async getFriendshipStatus(userId: string) {
|
||||
return httpClient.request<FriendshipStatus>(`/friends/status/${userId}`);
|
||||
}
|
||||
|
||||
static async sendFriendRequest(friendId: string) {
|
||||
return httpClient.request<{ status: string }>('/friends/request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ friendId }),
|
||||
});
|
||||
}
|
||||
|
||||
static async acceptFriendRequest(friendshipId: string) {
|
||||
return httpClient.request<{ id: string }>(`/friends/${friendshipId}/accept`, { method: 'POST' });
|
||||
}
|
||||
|
||||
static async declineFriendRequest(friendshipId: string) {
|
||||
return httpClient.request<{ success: boolean }>(`/friends/${friendshipId}/decline`, { method: 'POST' });
|
||||
}
|
||||
|
||||
static async removeFriend(friendshipId: string) {
|
||||
return httpClient.request<{ success: boolean }>(`/friends/${friendshipId}`, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
import type { StoryGroup } from '../../../core/domain/types';
|
||||
|
||||
interface StoryState {
|
||||
storyGroups: StoryGroup[];
|
||||
viewerIndex: number | null;
|
||||
viewerStoryIndex: number;
|
||||
setStoryGroups: (groups: StoryGroup[]) => void;
|
||||
openViewer: (userIndex: number, storyIndex?: number, groups?: StoryGroup[]) => void;
|
||||
closeViewer: () => void;
|
||||
}
|
||||
|
||||
export const useStoryStore = create<StoryState>((set, get) => ({
|
||||
storyGroups: [],
|
||||
viewerIndex: null,
|
||||
viewerStoryIndex: 0,
|
||||
setStoryGroups: (storyGroups) => set({ storyGroups }),
|
||||
openViewer: (userIndex, storyIndex = 0, groups) => set({
|
||||
storyGroups: groups || get().storyGroups,
|
||||
viewerIndex: userIndex,
|
||||
viewerStoryIndex: storyIndex
|
||||
}),
|
||||
closeViewer: () => set({ viewerIndex: null, viewerStoryIndex: 0 }),
|
||||
}));
|
||||
@@ -0,0 +1,66 @@
|
||||
import { httpClient } from '../../../core/infrastructure/httpClient';
|
||||
import type { StoryGroup } from '../../../core/domain/types';
|
||||
|
||||
export class StoryApi {
|
||||
static async getStories() {
|
||||
return httpClient.request<StoryGroup[]>('/stories');
|
||||
}
|
||||
|
||||
static async getUserStories(userId: string) {
|
||||
return httpClient.request<StoryGroup>(`/stories/user/${userId}`);
|
||||
}
|
||||
|
||||
static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
|
||||
return httpClient.request<{ id: string }>('/stories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
static async uploadVideoToStory(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return httpClient.request<{ url: string }>('/stories/video', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
static async viewStory(storyId: string) {
|
||||
return httpClient.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
|
||||
}
|
||||
|
||||
static async deleteStory(storyId: string) {
|
||||
return httpClient.request<{ message: string }>(`/stories/${storyId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
static async getStoryViewers(storyId: string) {
|
||||
return httpClient.request<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>(`/stories/${storyId}/viewers`);
|
||||
}
|
||||
|
||||
static async addStoryReaction(storyId: string, emoji: string) {
|
||||
return httpClient.request<{ message: string }>(`/stories/${storyId}/reaction`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ emoji }),
|
||||
});
|
||||
}
|
||||
|
||||
static async removeStoryReaction(storyId: string, emoji: string) {
|
||||
return httpClient.request<{ message: string }>(`/stories/${storyId}/reaction`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ emoji }),
|
||||
});
|
||||
}
|
||||
|
||||
static async addStoryReply(storyId: string, content: string) {
|
||||
return httpClient.request<{ message: string }>(`/stories/${storyId}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
static async getStoryReplies(storyId: string) {
|
||||
return httpClient.request<Array<{ id: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string }>>(`/stories/${storyId}/replies`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp, Volume2, VolumeX, MessageCircle, Smile } from 'lucide-react';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { StoryApi } from '../../infrastructure/storyApi';
|
||||
import { ChatApi } from '../../../chats/infrastructure/chatApi';
|
||||
import { getSocket } from '../../../../core/infrastructure/socket';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||
import { StoryGroup } from '../../../../core/domain/types';
|
||||
import { getMediaUrl } from '../../../../core/utils/utils';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
const STORY_BG_COLORS = [
|
||||
'#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#ef4444',
|
||||
'#f97316', '#eab308', '#22c55e', '#14b8a6', '#0ea5e9',
|
||||
'#3b82f6', '#1e1e2e',
|
||||
];
|
||||
|
||||
const STORY_EMOJIS = ['❤️', '🔥', '😂', '😮', '😢', '👏', '🎉', '💪'];
|
||||
|
||||
interface StoryViewerProps {
|
||||
stories: StoryGroup[];
|
||||
initialUserIndex: number;
|
||||
initialStoryIndex?: number;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function StoryViewer({ stories, initialUserIndex, initialStoryIndex, onClose, onRefresh }: StoryViewerProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
const [userIndex, setUserIndex] = useState(initialUserIndex);
|
||||
const [storyIndex, setStoryIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const viewedRef = useRef<Set<string>>(new Set());
|
||||
const [viewOverrides, setViewOverrides] = useState<Record<string, { viewCount: number; viewed: boolean }>>({});
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isMuted, setIsMuted] = useState(true);
|
||||
|
||||
const STORY_DURATION = 5000;
|
||||
const TICK = 50;
|
||||
|
||||
const [showViewers, setShowViewers] = useState(false);
|
||||
const [viewers, setViewers] = useState<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>([]);
|
||||
const [viewersLoading, setViewersLoading] = useState(false);
|
||||
|
||||
const [showReplyInput, setShowReplyInput] = useState(false);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [sendingReply, setSendingReply] = useState(false);
|
||||
|
||||
const [showReactions, setShowReactions] = useState(false);
|
||||
const [localReactions, setLocalReactions] = useState<Record<string, Array<{ id: string; userId: string; emoji: string; createdAt: string }>>>({});
|
||||
|
||||
const currentUser = stories[userIndex];
|
||||
const rawStory = currentUser?.stories?.[storyIndex];
|
||||
const currentStory = rawStory ? {
|
||||
...rawStory,
|
||||
...viewOverrides[rawStory.id],
|
||||
reactions: localReactions[rawStory.id] || rawStory.reactions || []
|
||||
} : null;
|
||||
|
||||
// Calculate isVideo before using it in effects
|
||||
const isVideo = currentStory?.type === 'video' || (currentStory?.mediaUrl && (currentStory.mediaUrl.endsWith('.mp4') || currentStory.mediaUrl.endsWith('.mov') || currentStory.mediaUrl.endsWith('.webm')));
|
||||
|
||||
// Pause when showing reactions or reply input or state changed
|
||||
useEffect(() => {
|
||||
if (showReactions || showReplyInput || showViewers || paused) {
|
||||
if (videoRef.current && !videoRef.current.paused) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
} else {
|
||||
if (videoRef.current && videoRef.current.paused && isVideo) {
|
||||
videoRef.current.play().catch(() => { });
|
||||
}
|
||||
}
|
||||
}, [showReactions, showReplyInput, showViewers, paused, isVideo]);
|
||||
|
||||
// Handle video play/pause sync with paused state
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !isVideo) return;
|
||||
if (paused) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play().catch(() => { });
|
||||
}
|
||||
}, [paused, isVideo]);
|
||||
|
||||
// Mute by default for viewers, unmute for story owner
|
||||
useEffect(() => {
|
||||
if (currentUser?.user.id === user?.id) {
|
||||
setIsMuted(false);
|
||||
}
|
||||
}, [currentUser?.user.id, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setUserIndex(initialUserIndex);
|
||||
setStoryIndex(initialStoryIndex || 0);
|
||||
setProgress(0);
|
||||
setPaused(false);
|
||||
viewedRef.current.clear();
|
||||
setViewOverrides({});
|
||||
}, [initialUserIndex, initialStoryIndex]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (!currentUser) return;
|
||||
if (storyIndex < currentUser.stories.length - 1) {
|
||||
setStoryIndex(s => s + 1);
|
||||
setProgress(0);
|
||||
} else if (userIndex < stories.length - 1) {
|
||||
setUserIndex(u => u + 1);
|
||||
setStoryIndex(0);
|
||||
setProgress(0);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [storyIndex, userIndex, currentUser, stories.length, onClose]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (storyIndex > 0) {
|
||||
setStoryIndex(s => s - 1);
|
||||
setProgress(0);
|
||||
} else if (userIndex > 0) {
|
||||
setUserIndex(u => u - 1);
|
||||
const prevUser = stories[userIndex - 1];
|
||||
setStoryIndex(prevUser.stories.length - 1);
|
||||
setProgress(0);
|
||||
}
|
||||
}, [storyIndex, userIndex, stories]);
|
||||
|
||||
const canGoPrev = storyIndex > 0 || userIndex > 0;
|
||||
const canGoNext = (currentUser && storyIndex < currentUser.stories.length - 1) || userIndex < stories.length - 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStory || !currentStory.id) return;
|
||||
if (currentUser.user.id === user?.id) return;
|
||||
if (currentStory.viewed || viewedRef.current.has(currentStory.id)) return;
|
||||
|
||||
// console.log('[StoryViewer] Calling viewStory for:', currentStory.id);
|
||||
viewedRef.current.add(currentStory.id);
|
||||
const storyId = currentStory.id;
|
||||
const viewCount = currentStory.viewCount || 0;
|
||||
|
||||
StoryApi.viewStory(storyId).then(() => {
|
||||
// console.log('[StoryViewer] viewStory success, updating count to', viewCount + 1);
|
||||
setViewOverrides(prev => ({
|
||||
...prev,
|
||||
[storyId]: {
|
||||
viewCount: viewCount + 1,
|
||||
viewed: true,
|
||||
},
|
||||
}));
|
||||
}).catch(e => {
|
||||
console.error('[StoryViewer] viewStory error:', e);
|
||||
});
|
||||
}, [currentStory?.id, currentUser?.user?.id, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setProgress(0);
|
||||
}, [storyIndex, userIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || showReactions || showReplyInput || showViewers || !currentStory || isVideo) return;
|
||||
|
||||
const duration = STORY_DURATION;
|
||||
const step = (TICK / duration) * 100;
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
if (prev >= 100) {
|
||||
goNext();
|
||||
return 0;
|
||||
}
|
||||
return prev + step;
|
||||
});
|
||||
}, TICK);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [storyIndex, userIndex, paused, showReactions, showReplyInput, showViewers, goNext, isVideo, currentStory]);
|
||||
|
||||
// Handle video progress
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isVideo || paused || showReactions || showReplyInput || showViewers) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (video.duration) {
|
||||
const p = (video.currentTime / video.duration) * 100;
|
||||
setProgress(p);
|
||||
}
|
||||
}, TICK);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isVideo, paused, showReactions, showReplyInput, showViewers, storyIndex, userIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowRight') goNext();
|
||||
if (e.key === 'ArrowLeft') goPrev();
|
||||
};
|
||||
|
||||
const socket = getSocket();
|
||||
|
||||
const handleStoryViewed = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string; viewCount: number; ownerId: string }) => {
|
||||
// console.log('[StoryViewer] story_viewed received:', data);
|
||||
if (!currentStory || data.storyId !== currentStory.id) return;
|
||||
// Only process if this user is the owner
|
||||
if (data.ownerId !== user?.id) return;
|
||||
|
||||
setViewOverrides(prev => ({
|
||||
...prev,
|
||||
[data.storyId]: {
|
||||
viewCount: data.viewCount,
|
||||
viewed: prev[data.storyId]?.viewed || false
|
||||
}
|
||||
}));
|
||||
|
||||
if (showViewers) {
|
||||
setViewers(prev => {
|
||||
if (prev.some(v => v.userId === data.userId)) return prev;
|
||||
return [...prev, {
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
displayName: data.displayName,
|
||||
avatar: data.avatar,
|
||||
viewedAt: data.viewedAt
|
||||
}].sort((a, b) => new Date(b.viewedAt).getTime() - new Date(a.viewedAt).getTime());
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStoryReply = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string; ownerId: string }) => {
|
||||
// console.log('[StoryViewer] story_reply received:', data);
|
||||
// Only process if this user is the owner
|
||||
if (data.ownerId !== user?.id) return;
|
||||
// Could show notification or update UI
|
||||
};
|
||||
|
||||
const handleStoryReaction = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; emoji: string; createdAt: string; ownerId: string }) => {
|
||||
// console.log('[StoryViewer] story_reaction received:', data);
|
||||
// Only process if this user is the owner
|
||||
if (data.ownerId !== user?.id) return;
|
||||
// Could show notification or update UI
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
socket?.on('story_viewed', handleStoryViewed);
|
||||
socket?.on('story_reply', handleStoryReply);
|
||||
socket?.on('story_reaction', handleStoryReaction);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
socket?.off('story_viewed', handleStoryViewed);
|
||||
socket?.off('story_reply', handleStoryReply);
|
||||
socket?.off('story_reaction', handleStoryReaction);
|
||||
};
|
||||
}, [goNext, goPrev, onClose, currentStory?.id, showViewers]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!currentStory) return;
|
||||
const storyId = currentStory.id;
|
||||
try {
|
||||
await StoryApi.deleteStory(storyId);
|
||||
|
||||
if (currentUser.stories.length > 1) {
|
||||
if (storyIndex >= currentUser.stories.length - 1) {
|
||||
setStoryIndex(s => s - 1);
|
||||
}
|
||||
} else {
|
||||
if (userIndex < stories.length - 1) {
|
||||
setUserIndex(u => u + 1);
|
||||
setStoryIndex(0);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
setIsMuted(!isMuted);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddReaction = async (emoji: string) => {
|
||||
if (!currentStory) return;
|
||||
setShowReactions(false);
|
||||
|
||||
setLocalReactions(prev => ({
|
||||
...prev,
|
||||
[currentStory.id]: [...(prev[currentStory.id] || []), {
|
||||
id: `${currentStory.id}-${user?.id}-${emoji}`,
|
||||
userId: user?.id || '',
|
||||
emoji,
|
||||
createdAt: new Date().toISOString()
|
||||
}]
|
||||
}));
|
||||
|
||||
try {
|
||||
await StoryApi.addStoryReaction(currentStory.id, emoji);
|
||||
} catch (e) {
|
||||
console.error('Add reaction error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendReply = async () => {
|
||||
if (!currentStory || !replyText.trim() || sendingReply) return;
|
||||
setSendingReply(true);
|
||||
|
||||
try {
|
||||
await StoryApi.addStoryReply(currentStory.id, replyText.trim());
|
||||
setReplyText('');
|
||||
setShowReplyInput(false);
|
||||
} catch (e) {
|
||||
console.error('Send reply error:', e);
|
||||
} finally {
|
||||
setSendingReply(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentUser || !currentStory) {
|
||||
onClose();
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeAgo = (date: string) => {
|
||||
const diff = (Date.now() - new Date(date).getTime()) / 1000;
|
||||
if (diff < 60) return `${Math.floor(diff)}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
return `${Math.floor(diff / 3600)}h`;
|
||||
};
|
||||
|
||||
const avatarUrl = currentUser.user.avatar
|
||||
? getMediaUrl(currentUser.user.avatar)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-[420px] h-full max-h-[85vh] rounded-2xl overflow-hidden select-none"
|
||||
>
|
||||
{isVideo ? (
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={getMediaUrl(currentStory.mediaUrl)}
|
||||
className="w-full h-full object-contain"
|
||||
autoPlay
|
||||
muted={isMuted}
|
||||
playsInline
|
||||
onEnded={goNext}
|
||||
/>
|
||||
</div>
|
||||
) : currentStory.type === 'image' && currentStory.mediaUrl ? (
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<img
|
||||
src={getMediaUrl(currentStory.mediaUrl)}
|
||||
alt="story"
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center p-8"
|
||||
style={{ background: currentStory.bgColor || '#6366f1' }}
|
||||
>
|
||||
<p className="text-white text-2xl font-bold text-center leading-relaxed drop-shadow-lg"
|
||||
style={{ maxWidth: '90%', wordBreak: 'break-word' }}>
|
||||
{currentStory.content}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-0 left-0 right-0 flex gap-1 p-2 z-10">
|
||||
{currentUser.stories.map((_, i) => (
|
||||
<div key={i} className="flex-1 h-[3px] bg-white/30 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-white rounded-full transition-none"
|
||||
style={{
|
||||
width: i < storyIndex ? '100%' : i === storyIndex ? `${progress}%` : '0%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 left-0 right-0 flex items-center gap-3 px-4 pt-2 z-10">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={currentUser.user.displayName || currentUser.user.username}
|
||||
size="sm"
|
||||
className="ring-2 ring-white/20 rounded-full"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-semibold truncate drop-shadow">
|
||||
{currentUser.user.id === user?.id ? t('myStory') : currentUser.user.displayName || currentUser.user.username}
|
||||
</p>
|
||||
<p className="text-white/60 text-xs drop-shadow">{timeAgo(currentStory.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentUser.user.id === user?.id && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (showViewers) {
|
||||
setShowViewers(false);
|
||||
setPaused(false);
|
||||
} else {
|
||||
setPaused(true);
|
||||
setShowViewers(true);
|
||||
setViewersLoading(true);
|
||||
StoryApi.getStoryViewers(currentStory.id).then(v => {
|
||||
setViewers(v);
|
||||
setViewersLoading(false);
|
||||
}).catch(() => setViewersLoading(false));
|
||||
}
|
||||
}}
|
||||
className="text-white/60 hover:text-white text-xs flex items-center gap-1 transition-colors p-1"
|
||||
>
|
||||
<Eye size={12} /> {currentStory.viewCount}
|
||||
<ChevronUp size={10} className={`transition-transform ${showViewers ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<button onClick={handleDelete} className="text-white/60 hover:text-red-400 transition-colors p-1">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={onClose} className="text-white/60 hover:text-white transition-colors p-1">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute inset-0 flex z-[5]"
|
||||
onMouseDown={() => setPaused(true)}
|
||||
onMouseUp={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
|
||||
onMouseLeave={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
|
||||
onTouchStart={() => setPaused(true)}
|
||||
onTouchEnd={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
|
||||
>
|
||||
<div className="w-1/3 h-full cursor-pointer" onClick={(e) => { e.stopPropagation(); goPrev(); }} />
|
||||
<div className="w-1/3 h-full" />
|
||||
<div className="w-1/3 h-full cursor-pointer" onClick={(e) => { e.stopPropagation(); goNext(); }} />
|
||||
</div>
|
||||
|
||||
{canGoPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center text-white/70 hover:bg-white/20 hover:text-white transition-all"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
{canGoNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center text-white/70 hover:bg-white/20 hover:text-white transition-all"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bottom actions */}
|
||||
<div className="absolute bottom-4 left-0 right-0 flex items-center justify-center gap-4 z-10 px-4">
|
||||
{/* Sound toggle for video - show for everyone */}
|
||||
{isVideo && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleMute(); }}
|
||||
className="w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Reply and reactions - only for non-owners */}
|
||||
{currentUser.user.id !== user?.id && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowReplyInput(!showReplyInput); setShowReactions(false); }}
|
||||
className={`w-10 h-10 rounded-full backdrop-blur-sm flex items-center justify-center transition-colors ${showReplyInput ? 'bg-accent text-white' : 'bg-black/50 text-white/70 hover:text-white'}`}
|
||||
>
|
||||
<MessageCircle size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowReactions(!showReactions); setShowReplyInput(false); }}
|
||||
className={`w-10 h-10 rounded-full backdrop-blur-sm flex items-center justify-center transition-colors ${showReactions ? 'bg-accent text-white' : 'bg-black/50 text-white/70 hover:text-white'}`}
|
||||
>
|
||||
<Smile size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showReactions && currentUser.user.id !== user?.id && (
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 50, opacity: 0 }}
|
||||
className="absolute bottom-20 left-0 right-0 z-20 flex justify-center gap-2 px-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{STORY_EMOJIS.map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={(e) => { e.stopPropagation(); handleAddReaction(emoji); }}
|
||||
className="w-12 h-12 rounded-full bg-black/70 backdrop-blur-sm flex items-center justify-center text-2xl hover:scale-125 transition-transform"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showReplyInput && currentUser.user.id !== user?.id && (
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 50, opacity: 0 }}
|
||||
className="absolute bottom-20 left-0 right-0 z-20 px-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSendReply(); }}
|
||||
placeholder={t('replyToStory') || 'Reply to story...'}
|
||||
className="flex-1 bg-black/70 backdrop-blur-sm border border-white/20 rounded-full px-4 py-2 text-sm text-white placeholder-white/50 focus:outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendReply}
|
||||
disabled={!replyText.trim() || sendingReply}
|
||||
className="px-4 py-2 rounded-full bg-accent text-white text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('send') || 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showViewers && currentUser.user.id === user?.id && (
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="absolute bottom-0 left-0 right-0 z-20 bg-black/90 backdrop-blur-xl rounded-t-2xl border-t border-white/10 max-h-[50%] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-white text-sm font-semibold flex items-center gap-2">
|
||||
<Eye size={14} /> {t('storyViewers')} ({currentStory.viewCount})
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => { setShowViewers(false); setPaused(false); }}
|
||||
className="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{viewersLoading ? (
|
||||
<div className="text-white/40 text-sm text-center py-4">{t('sending')}</div>
|
||||
) : viewers.length === 0 ? (
|
||||
<div className="text-white/40 text-sm text-center py-4">{t('noViewers')}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{viewers.map((v) => (
|
||||
<div key={v.userId} className="flex items-center gap-3 py-1.5">
|
||||
<Avatar
|
||||
src={v.avatar ? getMediaUrl(v.avatar) : null}
|
||||
name={v.displayName || v.username}
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm truncate">{v.displayName || v.username}</p>
|
||||
<p className="text-white/40 text-xs">@{v.username}</p>
|
||||
</div>
|
||||
<span className="text-white/30 text-xs">{timeAgo(v.viewedAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateStoryModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) {
|
||||
const { t } = useLang();
|
||||
const [mode, setMode] = useState<'text' | 'image'>('text');
|
||||
const [text, setText] = useState('');
|
||||
const [bgColor, setBgColor] = useState('#6366f1');
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImageFile(file);
|
||||
if (file.type.startsWith('video/')) {
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
setMode('image');
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImagePreview(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
setMode('image');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (mode === 'text' && !text.trim()) return;
|
||||
if (mode === 'image' && !imageFile) return;
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
let mediaUrl: string | undefined;
|
||||
if (imageFile) {
|
||||
const result = await ChatApi.uploadFile(imageFile);
|
||||
mediaUrl = result.url;
|
||||
}
|
||||
|
||||
await StoryApi.createStory({
|
||||
type: imageFile?.type.startsWith('video/') ? 'video' : mode,
|
||||
content: mode === 'text' ? text.trim() : undefined,
|
||||
bgColor: mode === 'text' ? bgColor : undefined,
|
||||
mediaUrl,
|
||||
});
|
||||
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error('Create story error:', e);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-black/80 flex items-center justify-center"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="w-full max-w-[400px] rounded-2xl glass-strong border border-white/10 overflow-hidden"
|
||||
>
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">{t('newStory')}</h3>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-white">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-white/10">
|
||||
<button
|
||||
onClick={() => setMode('text')}
|
||||
className={`flex-1 py-2.5 text-sm font-medium transition-colors ${mode === 'text' ? 'text-knot-400 border-b-2 border-knot-400' : 'text-zinc-400'}`}
|
||||
>
|
||||
{t('textStory')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('image')}
|
||||
className={`flex-1 py-2.5 text-sm font-medium transition-colors ${mode === 'image' ? 'text-knot-400 border-b-2 border-knot-400' : 'text-zinc-400'}`}
|
||||
>
|
||||
{t('mediaStory')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
{mode === 'text' ? (
|
||||
<>
|
||||
<div
|
||||
className="w-full h-48 rounded-xl flex items-center justify-center p-4 mb-4 transition-colors"
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
<p className="text-white text-lg font-bold text-center break-words max-w-full">
|
||||
{text || t('typeYourStory')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
placeholder={t('typeYourStory')}
|
||||
maxLength={200}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-zinc-200 resize-none h-20 mb-3 focus:outline-none focus:border-knot-500/50"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{STORY_BG_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setBgColor(c)}
|
||||
className={`w-7 h-7 rounded-full transition-transform ${bgColor === c ? 'scale-125 ring-2 ring-white/50' : 'hover:scale-110'}`}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{imagePreview ? (
|
||||
<div className="relative w-full h-48 rounded-xl mb-4 overflow-hidden bg-black flex items-center justify-center">
|
||||
{imageFile?.type.startsWith('video/') ? (
|
||||
<video src={imagePreview} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<img src={imagePreview} className="w-full h-full object-cover" alt="preview" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setImageFile(null); setImagePreview(null); }}
|
||||
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 flex items-center justify-center text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full h-48 rounded-xl border-2 border-dashed border-white/20 flex items-center justify-center mb-4 text-zinc-400 hover:text-white hover:border-white/40 transition-colors"
|
||||
>
|
||||
<Plus size={32} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={isUploading || (mode === 'text' && !text.trim()) || (mode === 'image' && !imageFile)}
|
||||
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? '...' : t('publishStory')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { httpClient } from '../../../core/infrastructure/httpClient';
|
||||
import type { User, UserPresence } from '../../../core/domain/types';
|
||||
|
||||
export class UserApi {
|
||||
static async searchUsers(query: string) {
|
||||
return httpClient.request<UserPresence[]>(`/users/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
static async getUser(id: string) {
|
||||
return httpClient.request<User>(`/users/${id}`);
|
||||
}
|
||||
|
||||
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
|
||||
return httpClient.request<User>('/users/profile', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
static async updateSettings(settings: any) {
|
||||
return httpClient.request('/users/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
static async uploadAvatar(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
return httpClient.request<User>('/users/avatar', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
static async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number }) {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
formData.append('cropX', cropData.x.toString());
|
||||
formData.append('cropY', cropData.y.toString());
|
||||
formData.append('cropWidth', cropData.width.toString());
|
||||
formData.append('cropHeight', cropData.height.toString());
|
||||
|
||||
return httpClient.request<User>('/users/avatar/crop', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
static async removeAvatar() {
|
||||
return httpClient.request<User>('/users/avatar', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
static async getIceServers() {
|
||||
return httpClient.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Upload, Check, Loader2, MessageSquare, AlertCircle } from 'lucide-react';
|
||||
import { AppApi } from '../../../../core/infrastructure/appApi';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import type { User as UserType, FriendWithId } from '../../../../core/domain/types';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
|
||||
interface TelegramImportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
friends: FriendWithId[];
|
||||
}
|
||||
|
||||
export default function TelegramImportModal({ isOpen, onClose, friends }: TelegramImportModalProps) {
|
||||
const { t } = useLang();
|
||||
const { user } = useAuthStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1); // 1: upload, 2: map, 3: loading/done
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [names, setNames] = useState<string[]>([]);
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importedState, setImportedState] = useState<{ count: number; text: string } | null>(null);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
|
||||
if (!selectedFile.name.endsWith('.zip')) {
|
||||
setError('Пожалуйста, выберите ZIP-архив экспорта Telegram.');
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const data = await AppApi.analyzeTelegramImport(selectedFile) as any;
|
||||
setToken(data.token);
|
||||
setNames(data.names);
|
||||
|
||||
// Auto-map if possible
|
||||
const initialMap: Record<string, string> = {};
|
||||
data.names.forEach((name: string) => {
|
||||
initialMap[name] = '';
|
||||
});
|
||||
setMapping(initialMap);
|
||||
setStep(2);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.message || 'Ошибка загрузки файла');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await AppApi.executeTelegramImport({ token, mapping, groupName }) as any;
|
||||
setImportedState({ count: res.messagesImported, text: 'Успешно импортировано' });
|
||||
setStep(3);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.message || 'Ошибка импорта');
|
||||
setStep(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setStep(1);
|
||||
setFile(null);
|
||||
setToken(null);
|
||||
setNames([]);
|
||||
setMapping({});
|
||||
setGroupName('');
|
||||
setError(null);
|
||||
setImportedState(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={step === 3 && importedState ? handleClose : undefined}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="relative w-full max-w-lg bg-surface-secondary border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col max-h-[90vh]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-14 px-4 flex items-center justify-between border-b border-border bg-surface-secondary/50 backdrop-blur-md shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={20} className="text-knot-400" />
|
||||
<h3 className="font-semibold text-white">Импорт из Telegram</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 -mr-2 text-zinc-400 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{error && (
|
||||
<div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20 flex gap-3 text-red-400">
|
||||
<AlertCircle size={20} className="shrink-0" />
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="w-20 h-20 mx-auto bg-surface-tertiary rounded-full flex items-center justify-center border border-border">
|
||||
<Upload size={32} className="text-knot-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-white mb-2">Загрузите архив с историей</h4>
|
||||
<p className="text-sm text-zinc-400 leading-relaxed max-w-sm mx-auto">
|
||||
Скачайте историю чата из Telegram в формате HTML (сняв галочку с формата JSON). Убедитесь, что медиафайлы тоже скачаны, если хотите перенести их. Загрузите полученный ZIP архив.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={loading}
|
||||
className="h-12 px-6 bg-knot-500 hover:bg-knot-600 active:bg-knot-700 text-white font-medium rounded-xl transition-colors inline-flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mx-auto"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Upload size={18} />
|
||||
Выбрать ZIP-архив
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-white mb-2">Кто есть кто?</h4>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Мы нашли {names.length} имён в архиве. Укажите, какому контакту в Knot они соответствуют. Одно из имён должно принадлежать вам.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{names.map((name) => (
|
||||
<div key={name} className="flex flex-col gap-2 p-4 rounded-xl border border-border bg-surface-tertiary">
|
||||
<span className="text-sm font-medium text-white">Сообщения от: "{name}"</span>
|
||||
<select
|
||||
value={mapping[name] || ''}
|
||||
onChange={(e) => setMapping({ ...mapping, [name]: e.target.value })}
|
||||
className="w-full h-11 px-3 bg-surface-secondary text-sm text-white rounded-lg border border-border focus:border-knot-500 outline-none transition-colors"
|
||||
>
|
||||
<option value="">-- Выберите пользователя --</option>
|
||||
<option value={user?.id}>Это я ({user?.displayName || user?.username})</option>
|
||||
{friends.map(f => (
|
||||
<option key={f.id} value={f.id}>
|
||||
Контакт: {f.displayName || f.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{names.length > 2 && (
|
||||
<div className="pt-2">
|
||||
<h4 className="text-sm font-medium text-white mb-2">Название для группового чата</h4>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Например, Моя группа"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
className="w-full h-11 px-3 bg-surface-secondary text-sm text-white rounded-lg border border-border focus:border-knot-500 outline-none transition-colors placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
disabled={loading}
|
||||
className="px-5 py-2.5 text-sm font-medium text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={loading || names.some(n => !mapping[n]) || (names.length > 2 && !groupName.trim())}
|
||||
className="px-6 py-2.5 bg-knot-500 hover:bg-knot-600 disabled:bg-surface-tertiary disabled:text-zinc-500 text-white text-sm font-medium rounded-xl transition-colors flex items-center gap-2"
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||
Импортировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && importedState && (
|
||||
<div className="text-center py-8 space-y-4">
|
||||
<div className="w-16 h-16 mx-auto bg-green-500/20 text-green-400 rounded-full flex items-center justify-center border border-green-500/30">
|
||||
<Check size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xl font-medium text-white mb-2">Готово!</h4>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Импорт завершен. Сообщений: <strong className="text-white">{importedState.count}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="mt-6 h-11 px-6 bg-surface-tertiary hover:bg-surface-hover active:bg-surface-secondary text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
envDir: '../../',
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5059',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:5059',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/hubs': {
|
||||
target: 'http://localhost:5059',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user