12 Commits
316 changed files with 1650 additions and 1177 deletions
-3
View File
@@ -2,9 +2,6 @@ node_modules
dist dist
.git .git
*.log *.log
apps/web/dist
apps/server/dist
apps/server/uploads
.vscode .vscode
.env .env
bin/ bin/
-30
View File
@@ -1,30 +0,0 @@
# 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;"]
-418
View File
@@ -1,418 +0,0 @@
import type { User, UserBasic, UserPresence, Chat, Message, MediaItem, StoryGroup, FriendRequest, FriendWithId, FriendshipStatus } from './types';
const API_BASE = '/api';
class ApiClient {
private token: string | null = null;
setToken(token: string | null) {
this.token = token;
}
private 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();
}
// \u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f
async login(username: string, password: string) {
return this.request<{ token: string; user: User }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
}
async register(username: string, displayName: string, password: string, bio?: string) {
return this.request<{ token: string; user: User }>('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, displayName, password, bio }),
});
}
async getMe() {
return this.request<{ user: User }>('/auth/me');
}
async getConfig() {
return this.request<any>('/config');
}
// \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438
async searchUsers(query: string) {
return this.request<UserPresence[]>(`/users/search?q=${encodeURIComponent(query)}`);
}
async getUser(id: string) {
return this.request<User>(`/users/${id}`);
}
async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
return this.request<User>('/users/profile', {
method: 'PUT',
body: JSON.stringify(data),
});
}
async uploadAvatar(file: File) {
const formData = new FormData();
formData.append('avatar', file);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);
const response = await fetch(`${API_BASE}/users/avatar`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) throw new Error('Ошибка загрузки аватара');
return response.json() as Promise<User>;
}
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());
const response = await fetch(`${API_BASE}/users/avatar/crop`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
});
if (!response.ok) throw new Error('Ошибка кропа аватара');
return response.json() as Promise<User>;
}
async removeAvatar() {
return this.request<User>('/users/avatar', { method: 'DELETE' });
}
async searchMessages(query: string, chatId?: string) {
const params = new URLSearchParams({ q: query });
if (chatId) params.append('chatId', chatId);
return this.request<Message[]>(`/messages/search?${params}`);
}
// \u0427\u0430\u0442\u044b
async getChats() {
return this.request<Chat[]>('/chats');
}
async createPersonalChat(userId: string) {
return this.request<Chat>('/chats/personal', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}
async createGroupChat(name: string, memberIds: string[]) {
return this.request<Chat>('/chats/group', {
method: 'POST',
body: JSON.stringify({ name, memberIds }),
});
}
// \u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f
async getMessages(chatId: string, cursor?: string) {
const params = cursor ? `?cursor=${cursor}` : '';
return this.request<Message[]>(`/messages/chat/${chatId}${params}`);
}
async uploadFile(file: File) {
const formData = new FormData();
formData.append('file', file);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);
const response = await fetch(`${API_BASE}/messages/upload`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) throw new Error('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0444\u0430\u0439\u043b\u0430');
return response.json() as Promise<{ url: string; filename: string; size: number }>;
}
// \u0413\u0440\u0443\u043f\u043f\u044b
async updateGroup(chatId: string, data: { name?: string; description?: string }) {
return this.request<Chat>(`/chats/${chatId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async uploadGroupAvatar(chatId: string, file: File) {
const formData = new FormData();
formData.append('avatar', file);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);
const response = await fetch(`${API_BASE}/chats/${chatId}/avatar`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) throw new Error('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0430\u0432\u0430\u0442\u0430\u0440\u0430');
return response.json() as Promise<Chat>;
}
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());
const response = await fetch(`${API_BASE}/chats/${chatId}/avatar/crop`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
});
if (!response.ok) throw new Error('Ошибка кропа аватара');
return response.json() as Promise<Chat>;
}
async removeGroupAvatar(chatId: string) {
return this.request<Chat>(`/chats/${chatId}/avatar`, { method: 'DELETE' });
}
async addGroupMembers(chatId: string, userIds: string[]) {
return this.request<Chat>(`/chats/${chatId}/members`, {
method: 'POST',
body: JSON.stringify({ userIds }),
});
}
async removeGroupMember(chatId: string, userId: string) {
return this.request<Chat>(`/chats/${chatId}/members/${userId}`, {
method: 'DELETE',
});
}
async clearChat(chatId: string) {
return this.request<{ message: string }>(`/chats/${chatId}/clear`, { method: 'POST' });
}
async deleteChat(chatId: string) {
return this.request<{ message: string }>(`/chats/${chatId}`, { method: 'DELETE' });
}
async togglePinChat(chatId: string) {
return this.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' });
}
async getSharedMedia(chatId: string, type: 'media' | 'gifs' | 'files' | 'links') {
return this.request<any[]>(`/messages/chat/${chatId}/shared?type=${type}`);
}
// Stories
async getStories() {
return this.request<StoryGroup[]>('/stories');
}
async getUserStories(userId: string) {
return this.request<StoryGroup>(`/stories/user/${userId}`);
}
async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
return this.request<{ id: string }>('/stories', {
method: 'POST',
body: JSON.stringify(data),
});
}
async uploadVideoToStory(file: File) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/stories/video`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
});
if (!response.ok) throw new Error('Ошибка загрузки видео истории');
return response.json() as Promise<{ url: string }>;
}
async viewStory(storyId: string) {
return this.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
}
async deleteStory(storyId: string) {
return this.request<{ message: string }>(`/stories/${storyId}`, { method: 'DELETE' });
}
async getStoryViewers(storyId: string) {
return this.request<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>(`/stories/${storyId}/viewers`);
}
async addStoryReaction(storyId: string, emoji: string) {
return this.request<{ message: string }>(`/stories/${storyId}/reaction`, {
method: 'POST',
body: JSON.stringify({ emoji }),
});
}
async removeStoryReaction(storyId: string, emoji: string) {
return this.request<{ message: string }>(`/stories/${storyId}/reaction`, {
method: 'DELETE',
body: JSON.stringify({ emoji }),
});
}
async addStoryReply(storyId: string, content: string) {
return this.request<{ message: string }>(`/stories/${storyId}/reply`, {
method: 'POST',
body: JSON.stringify({ content }),
});
}
async getStoryReplies(storyId: string) {
return this.request<Array<{ id: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string }>>(`/stories/${storyId}/replies`);
}
// Favorites chat
async getOrCreateFavorites() {
return this.request<Chat>('/chats/favorites', { method: 'POST' });
}
// User settings
async updateSettings(settings: any) {
const res = await this.request('/users/settings', {
method: 'PUT',
body: JSON.stringify(settings),
});
return res;
}
async analyzeTelegramImport(file: File) {
const formData = new FormData();
formData.append('file', file);
const res = await this.request('/import/telegram/analyze', {
method: 'POST',
body: formData,
});
return res;
}
async executeTelegramImport(req: { token: string; mapping: Record<string, string>; groupName?: string }) {
return this.request('/import/telegram/execute', {
method: 'POST',
body: JSON.stringify(req),
});
}
// Friends
async getFriends() {
return this.request<FriendWithId[]>('/friends');
}
async getFriendRequests() {
return this.request<FriendRequest[]>('/friends/requests');
}
async getOutgoingRequests() {
return this.request<FriendRequest[]>('/friends/outgoing');
}
async getFriendshipStatus(userId: string) {
return this.request<FriendshipStatus>(`/friends/status/${userId}`);
}
async sendFriendRequest(friendId: string) {
return this.request<{ status: string }>('/friends/request', {
method: 'POST',
body: JSON.stringify({ friendId }),
});
}
async acceptFriendRequest(friendshipId: string) {
return this.request<{ id: string }>(`/friends/${friendshipId}/accept`, { method: 'POST' });
}
async declineFriendRequest(friendshipId: string) {
return this.request<{ success: boolean }>(`/friends/${friendshipId}/decline`, { method: 'POST' });
}
async removeFriend(friendshipId: string) {
return this.request<{ success: boolean }>(`/friends/${friendshipId}`, { method: 'DELETE' });
}
async getIceServers() {
return this.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
}
// Klipy
async getTrendingGifs() {
return this.request<any>('/klipy/trending');
}
async searchKlipyGifs(query: string) {
return this.request<any>(`/klipy/search?q=${encodeURIComponent(query)}`);
}
}
export const api = new ApiClient();
-247
View File
@@ -1,247 +0,0 @@
import { useState, FormEvent, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuthStore } from '../stores/authStore';
import { useLang } from '../lib/i18n';
import { Eye, EyeOff, ArrowRight, UserPlus, LogIn, MessageSquare } from 'lucide-react';
export default function AuthPage() {
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [bio, setBio] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { login, register } = useAuthStore();
const { t, lang, setLang } = useLang();
const [enableRegistration, setEnableRegistration] = useState(true);
useEffect(() => {
fetch('/api/config')
.then(res => res.json())
.then(data => {
if (data && typeof data.enableRegistration === 'boolean') {
setEnableRegistration(data.enableRegistration);
if (!data.enableRegistration) setIsLogin(true);
}
})
.catch(() => {});
}, []);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
if (isLogin) {
await login(username, password);
} else {
await register(username, displayName || username, password, bio);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка');
} finally {
setIsSubmitting(false);
}
};
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>
{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 {!isLogin && <span className="text-zinc-600 font-normal ml-1">({lang === 'ru' ? 'латиница, нельзя изменить' : 'latin, cannot change'})</span>}
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(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-hidden text-[15px]"
required
autoFocus
autoComplete="off"
/>
</div>
<AnimatePresence>
{!isLogin && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="space-y-4"
>
<div className="pt-2">
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'Отображаемое имя' : 'Display Name'}
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(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-hidden text-[15px]"
/>
</div>
</motion.div>
)}
</AnimatePresence>
<div className={!isLogin ? "pt-2" : ""}>
<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={password}
onChange={(e) => setPassword(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-hidden text-[15px]"
required
autoComplete={isLogin ? 'current-password' : '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>
{!isLogin && (
<p className="mt-2 text-[12px] text-zinc-500 flex items-center gap-1.5 font-medium">
<div className="w-1 h-1 rounded-full bg-[#9b66ff]" />
{lang === 'ru' ? 'Минимум 8 символов, буквы и цифры' : 'Minimum 8 characters, letters and numbers'}
</p>
)}
</div>
<AnimatePresence>
{!isLogin && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<div className="pt-2">
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'О себе' : 'About me'}
</label>
<input
type="text"
value={bio}
onChange={(e) => setBio(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-hidden text-[15px]"
/>
</div>
</motion.div>
)}
</AnimatePresence>
<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" />
) : (
<>
{isLogin ? (lang === 'ru' ? 'Войти' : 'Login') : (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">
{isLogin ? (lang === 'ru' ? 'Нет аккаунта?' : "Don't have an account?") : (lang === 'ru' ? 'Уже есть аккаунт?' : 'Already have an account?')}
</p>
<button
onClick={() => {
setIsLogin(!isLogin);
setError('');
setPassword('');
setDisplayName('');
setBio('');
}}
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors type-button"
type="button"
>
{isLogin ? (lang === 'ru' ? 'Зарегистрироваться' : 'Register') : (lang === 'ru' ? 'Войти' : 'Login')}
</button>
</div>
)}
</div>
</motion.div>
</motion.div>
);
}
+2 -2
View File
@@ -3,10 +3,10 @@ WORKDIR /app
# Copy everything and restore as distinct layers # Copy everything and restore as distinct layers
COPY . . COPY . .
RUN dotnet restore apps/server-net/Knot.sln RUN dotnet restore Knot.sln
# Build and publish a release # Build and publish a release
RUN dotnet publish apps/server-net/src/Host/Host.csproj -c Release -o out RUN dotnet publish src/Host/Host.csproj -c Release -o out
# Build runtime image # Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview
@@ -15,7 +15,7 @@ using Microsoft.AspNetCore.Authorization;
namespace Host.Controllers; namespace Host.Controllers;
[Authorize] [AllowAnonymous]
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class AdminController : ControllerBase public class AdminController : ControllerBase
@@ -53,6 +53,8 @@ public sealed class MessagesController : ControllerBase
} }
[HttpPost("upload")] [HttpPost("upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10 GB limit for form body
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct) public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct)
{ {
if (file == null || file.Length == 0) if (file == null || file.Length == 0)
@@ -174,11 +174,11 @@ using (var scope = app.Services.CreateScope())
} }
// Настройка конвейера запросов // Настройка конвейера запросов
app.UseCors();
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.ExceptionHandlingMiddleware>(); app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.ExceptionHandlingMiddleware>();
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.AdminAuthMiddleware>(); app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.AdminAuthMiddleware>();
app.UseCors();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();
@@ -31,28 +31,25 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
{ {
var userChats = await _chatRepository.GetUserChatsAsync(request.UserId, cancellationToken); var userChats = await _chatRepository.GetUserChatsAsync(request.UserId, cancellationToken);
var dtos = new List<ChatDto>(); var dtos = new List<ChatDto>();
bool hasFavorites = false;
foreach (var chat in userChats) foreach (var chat in userChats)
{ {
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken); var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
if (latestMessage == null)
{
continue;
}
var userIdsToFetch = new HashSet<Guid>(); var userIdsToFetch = new HashSet<Guid>();
foreach (var member in chat.Members) foreach (var member in chat.Members)
{ {
userIdsToFetch.Add(member.UserId); userIdsToFetch.Add(member.UserId);
} }
if (latestMessage != null)
{
userIdsToFetch.Add(latestMessage.SenderId); userIdsToFetch.Add(latestMessage.SenderId);
foreach (var r in latestMessage.Reactions) foreach (var r in latestMessage.Reactions)
{ {
userIdsToFetch.Add(r.UserId); userIdsToFetch.Add(r.UserId);
} }
}
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken); var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
@@ -76,6 +73,10 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
)); ));
} }
var messagesList = new List<ChatMessageDto>();
if (latestMessage != null)
{
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj); usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
var reactionsWithUser = new List<ReactionDto>(); var reactionsWithUser = new List<ReactionDto>();
@@ -92,9 +93,7 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
)); ));
} }
var messagesList = new List<ChatMessageDto> messagesList.Add(new ChatMessageDto(
{
new ChatMessageDto(
latestMessage.Id, latestMessage.Id,
latestMessage.ChatId, latestMessage.ChatId,
latestMessage.SenderId, latestMessage.SenderId,
@@ -117,8 +116,8 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null), ) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser, reactionsWithUser,
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList() latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
) ));
}; }
var unreadCount = await _messageRepository.GetUnreadCountAsync(chat.Id, request.UserId, cancellationToken); var unreadCount = await _messageRepository.GetUnreadCountAsync(chat.Id, request.UserId, cancellationToken);
@@ -135,12 +134,6 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
)); ));
} }
if (!hasFavorites)
{
var favs = new ChatDto(Guid.Empty, "favorites", "Избранное", null, null, DateTime.UtcNow, new List<ChatMemberDto>(), new List<ChatMessageDto>(), 0);
dtos.Add(favs);
}
var sorted = dtos.OrderByDescending(d => d.Messages.FirstOrDefault()?.CreatedAt ?? d.CreatedAt).ToList(); var sorted = dtos.OrderByDescending(d => d.Messages.FirstOrDefault()?.CreatedAt ?? d.CreatedAt).ToList();
return Result.Success(sorted); return Result.Success(sorted);
} }

Some files were not shown because too many files have changed in this diff Show More