From d2e6eb578a1a08b33cc4435068e422f51c253f24 Mon Sep 17 00:00:00 2001 From: max Date: Fri, 17 Apr 2026 18:33:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=BD=D0=B5=D0=B4=D1=80=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=83=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D0=BE=D0=B2=20(BIO)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Application/DTOs/ProfilesErrors.cs | 2 + .../Application/DTOs/UserProfileDto.cs | 3 + .../Profiles/DTOs/ProfilesRequests.cs | 8 +- .../Profiles/UpdateProfile/UpdateProfile.cs | 14 +- .../Profiles/Domain/ProfileDocument.cs | 13 ++ .../Database/ProfileRepository.cs | 1 + .../Mappings/ProfileMappings.cs | 6 + .../Endpoints/ProfilesEndpoints.cs | 31 ++++- client-web/src/core/domain/types.ts | 3 + client-web/src/core/utils/normalize.ts | 11 ++ .../presentation/components/ChatListItem.tsx | 44 ++++++ .../modules/users/infrastructure/userApi.ts | 19 ++- .../presentation/components/SettingsPage.tsx | 131 +++++++++++++++++- .../presentation/components/UserProfile.tsx | 11 +- 14 files changed, 280 insertions(+), 17 deletions(-) diff --git a/backend/src/Contracts/Profiles/Application/DTOs/ProfilesErrors.cs b/backend/src/Contracts/Profiles/Application/DTOs/ProfilesErrors.cs index e690b5e..79483ea 100644 --- a/backend/src/Contracts/Profiles/Application/DTOs/ProfilesErrors.cs +++ b/backend/src/Contracts/Profiles/Application/DTOs/ProfilesErrors.cs @@ -9,4 +9,6 @@ public static class ProfilesErrors public static Error AvatarNotFound => new("Profiles.AvatarNotFound", "Avatar not found"); public static Error InvalidAvatarFormat => new("Profiles.InvalidAvatarFormat", "Invalid avatar format"); public static Error AvatarUploadFailed => new("Profiles.AvatarUploadFailed", "Avatar upload failed"); + public static Error BioTooLong => new("Profiles.BioTooLong", "Bio must be 200 characters or less"); + public static Error StatusTextTooLong => new("Profiles.StatusTextTooLong", "Status text must be 50 characters or less"); } diff --git a/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs b/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs index 1dbb0fa..cc8587e 100644 --- a/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs +++ b/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs @@ -12,5 +12,8 @@ public class UserProfileDto public DateTime? LastSeen { get; set; } public DateTime? Birthday { get; set; } public bool IsPremium { get; set; } + public string? StatusText { get; set; } + public string? StatusEmoji { get; set; } + public DateTime? StatusExpiresAt { get; set; } public DateTime CreatedAt { get; set; } } diff --git a/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs b/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs index ebf67a7..0643a52 100644 --- a/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs +++ b/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs @@ -2,5 +2,11 @@ using System; namespace Knot.Modules.Profiles.Application.Profiles.DTOs; -public record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday); +public record UpdateProfileRequest( + string? DisplayName, + string? Bio, + DateTime? Birthday, + string? StatusText, + string? StatusEmoji, + DateTime? StatusExpiresAt); public record UpdateSettingsRequest(bool? HideStoryViews); diff --git a/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs b/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs index 80184cf..e2c0ad1 100644 --- a/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs +++ b/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs @@ -11,7 +11,10 @@ public sealed record UpdateProfileCommand( Guid UserId, string? DisplayName, string? Bio, - DateTime? Birthday) : ICommand; + DateTime? Birthday, + string? StatusText, + string? StatusEmoji, + DateTime? StatusExpiresAt) : ICommand; internal sealed class UpdateProfileCommandHandler : ICommandHandler { @@ -28,9 +31,18 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler(ProfilesErrors.ProfileNotFound); + if ((request.Bio?.Length ?? 0) > 200) + return Result.Failure(ProfilesErrors.BioTooLong); + + if ((request.StatusText?.Length ?? 0) > 50) + return Result.Failure(ProfilesErrors.StatusTextTooLong); + profile.DisplayName = request.DisplayName ?? profile.DisplayName; profile.About = request.Bio; profile.Birthday = request.Birthday; + profile.StatusText = request.StatusText; + profile.StatusEmoji = request.StatusEmoji; + profile.StatusExpiresAt = request.StatusExpiresAt; var result = await _repository.UpdateAsync(profile, cancellationToken); if (result.IsFailure) diff --git a/backend/src/Modules/Profiles/Domain/ProfileDocument.cs b/backend/src/Modules/Profiles/Domain/ProfileDocument.cs index 17c2c55..9e7c037 100644 --- a/backend/src/Modules/Profiles/Domain/ProfileDocument.cs +++ b/backend/src/Modules/Profiles/Domain/ProfileDocument.cs @@ -19,6 +19,12 @@ public sealed class ProfileDocument public string? Bio { get; private set; } + public string? StatusText { get; private set; } + + public string? StatusEmoji { get; private set; } + + public DateTime? StatusExpiresAt { get; private set; } + public string? AvatarUrl { get; private set; } public DateTime? Birthday { get; private set; } @@ -65,6 +71,13 @@ public sealed class ProfileDocument public void UpdateSettings(bool hideStoryViews) => HideStoryViews = hideStoryViews; + public void UpdateStatus(string? statusText, string? statusEmoji, DateTime? statusExpiresAt) + { + StatusText = statusText; + StatusEmoji = statusEmoji; + StatusExpiresAt = statusExpiresAt; + } + public void UpdateStatus(bool isBanned, bool isDeleted) { IsBanned = isBanned; diff --git a/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs index 35ca780..e36fa2e 100644 --- a/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs +++ b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs @@ -80,6 +80,7 @@ internal class ProfileRepository : IProfileRepository dto.Birthday); document.UpdateAvatar(dto.Avatar); + document.UpdateStatus(dto.StatusText, dto.StatusEmoji, dto.StatusExpiresAt); await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct); return Result.Success(document.ToDto()); diff --git a/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs b/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs index 01d8be8..ae50d6c 100644 --- a/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs +++ b/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs @@ -1,5 +1,6 @@ using Knot.Contracts.Profiles.Application.DTOs; using Knot.Modules.Profiles.Domain; +using System; namespace Knot.Modules.Profiles.Infrastructure.Mappings; @@ -7,6 +8,8 @@ public static class ProfileMappings { public static UserProfileDto ToDto(this ProfileDocument document) { + var hasActiveStatus = !document.StatusExpiresAt.HasValue || document.StatusExpiresAt.Value > DateTime.UtcNow; + return new UserProfileDto { UserId = document.Id, @@ -18,6 +21,9 @@ public static class ProfileMappings LastSeen = null, Birthday = document.Birthday, IsPremium = false, + StatusText = hasActiveStatus ? document.StatusText : null, + StatusEmoji = hasActiveStatus ? document.StatusEmoji : null, + StatusExpiresAt = hasActiveStatus ? document.StatusExpiresAt : null, CreatedAt = document.CreatedAt }; } diff --git a/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs b/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs index 815f191..680d428 100644 --- a/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs +++ b/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs @@ -17,6 +17,8 @@ public static class ProfilesEndpoints public static void MapProfilesEndpoints(this WebApplication app) { var group = app.MapGroup("api/profiles").RequireAuthorization(); + var userGroup = app.MapGroup("api/user").RequireAuthorization(); + var usersGroup = app.MapGroup("api/users").RequireAuthorization(); group.MapGet("search", async ([FromQuery] string q, ISender sender, IUserContext userContext, CancellationToken ct) => { @@ -67,16 +69,39 @@ public static class ProfilesEndpoints return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); }); - group.MapPut("profile", async ([FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request, ISender sender, IUserContext userContext, CancellationToken ct) => + async Task UpdateProfileHandler( + [FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request, + ISender sender, + IUserContext userContext, + CancellationToken ct) { - var result = await sender.Send(new UpdateProfileCommand(userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct); + var result = await sender.Send( + new UpdateProfileCommand( + userContext.UserId, + request.DisplayName, + request.Bio, + request.Birthday, + request.StatusText, + request.StatusEmoji, + request.StatusExpiresAt), + ct); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); - }); + } + + group.MapPut("profile", UpdateProfileHandler); + group.MapPatch("profile", UpdateProfileHandler); + userGroup.MapPatch("profile", UpdateProfileHandler); group.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => { var result = await sender.Send(new GetProfileQuery(id), ct); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); }); + + usersGroup.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetProfileQuery(id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); + }); } } diff --git a/client-web/src/core/domain/types.ts b/client-web/src/core/domain/types.ts index 69722ba..d07e698 100644 --- a/client-web/src/core/domain/types.ts +++ b/client-web/src/core/domain/types.ts @@ -18,6 +18,9 @@ export interface UserPresence extends UserBasic { export interface User extends UserPresence { bio: string | null; birthday: string | null; + statusText?: string | null; + statusEmoji?: string | null; + statusExpiresAt?: string | null; createdAt: string; hideStoryViews?: boolean; } diff --git a/client-web/src/core/utils/normalize.ts b/client-web/src/core/utils/normalize.ts index 2519155..30059bc 100644 --- a/client-web/src/core/utils/normalize.ts +++ b/client-web/src/core/utils/normalize.ts @@ -33,6 +33,17 @@ export function normalizeUser(user: any): any { result.about = result.bio; } + // 4. Приведение status (snake_case -> camelCase) + if (!result.statusText && result.status_text) { + result.statusText = result.status_text; + } + if (!result.statusEmoji && result.status_emoji) { + result.statusEmoji = result.status_emoji; + } + if (!result.statusExpiresAt && result.status_expires_at) { + result.statusExpiresAt = result.status_expires_at; + } + return result; } diff --git a/client-web/src/modules/chats/presentation/components/ChatListItem.tsx b/client-web/src/modules/chats/presentation/components/ChatListItem.tsx index b88f039..3d858ea 100644 --- a/client-web/src/modules/chats/presentation/components/ChatListItem.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatListItem.tsx @@ -11,6 +11,9 @@ 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'; +import { UserApi } from '../../../users/infrastructure/userApi'; + +const userStatusCache = new Map(); interface ChatListItemProps { chat: Chat; @@ -24,6 +27,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) { const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [otherUserStatus, setOtherUserStatus] = useState<{ emoji?: string | null; text?: string | null } | null>(null); const ctxRef = useRef(null); const myMember = chat.members.find((m) => m.user.id === user?.id); @@ -87,6 +91,38 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) { const [showAttachmentConfirm, setShowAttachmentConfirm] = useState(false); const [importStatus, setImportStatus] = useState<{ processed: number, total: number } | null>(null); + useEffect(() => { + if (chat.type !== 'personal' || !otherMember?.user?.id) { + setOtherUserStatus(null); + return; + } + + const otherId = otherMember.user.id; + const cached = userStatusCache.get(otherId); + if (cached) { + setOtherUserStatus(cached); + return; + } + + let cancelled = false; + UserApi.getUser(otherId) + .then((profile) => { + if (cancelled) return; + const status = { emoji: profile.statusEmoji, text: profile.statusText }; + userStatusCache.set(otherId, status); + setOtherUserStatus(status); + }) + .catch(() => { + if (!cancelled) { + setOtherUserStatus(null); + } + }); + + return () => { + cancelled = true; + }; + }, [chat.type, otherMember?.user?.id]); + useEffect(() => { if (!chat.isImporting || !chat.importJobId) { setImportStatus(null); @@ -210,6 +246,14 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
{isPinned && push_pin} + {chat.type === 'personal' && otherUserStatus?.emoji && ( + + {otherUserStatus.emoji} + + )} {chatName}
{timeStr && {timeStr}} diff --git a/client-web/src/modules/users/infrastructure/userApi.ts b/client-web/src/modules/users/infrastructure/userApi.ts index 7facf4b..344b512 100644 --- a/client-web/src/modules/users/infrastructure/userApi.ts +++ b/client-web/src/modules/users/infrastructure/userApi.ts @@ -8,18 +8,25 @@ export class UserApi { } static async getUser(id: string) { - // Конечная точка в новом бэкенде: GET /api/profiles/{id} - return httpClient.request(`/profiles/${id}`); + // Основной путь по ТЗ: GET /api/users/{id}; старый профильный путь оставлен на бэкенде. + return httpClient.request(`/users/${id}`); } - static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string | null }) { - // Конечная точка в новом бэкенде: PUT /api/profiles/profile + static async updateProfile(data: { + displayName?: string; + bio?: string; + birthday?: string | null; + statusText?: string | null; + statusEmoji?: string | null; + statusExpiresAt?: string | null; + }) { + // Основной путь по ТЗ: PATCH /api/user/profile const payload = { ...data, about: data.bio // Дублируем для совместимости }; - return httpClient.request('/profiles/profile', { - method: 'PUT', + return httpClient.request('/user/profile', { + method: 'PATCH', body: JSON.stringify(payload), }); } diff --git a/client-web/src/modules/users/presentation/components/SettingsPage.tsx b/client-web/src/modules/users/presentation/components/SettingsPage.tsx index 8ee241a..ee95b42 100644 --- a/client-web/src/modules/users/presentation/components/SettingsPage.tsx +++ b/client-web/src/modules/users/presentation/components/SettingsPage.tsx @@ -4,7 +4,7 @@ import { User, Camera, Edit3, Calendar, Info, MapPin, AtSign, Check, Loader2, LogOut, ChevronRight, ArrowLeft, Languages, Palette, Trash2, - Bell, Shield, Eye + Bell, Shield, Eye, Smile } from 'lucide-react'; import { useAuthStore } from '../../../auth/application/authStore'; import { useLang } from '../../../../core/infrastructure/i18n'; @@ -14,6 +14,16 @@ import DatePicker from '../../../../core/presentation/components/ui/DatePicker'; import AvatarCropModal from './AvatarCropModal'; import { Area } from 'react-easy-crop'; import { getCroppedImg } from '../../../../core/utils/cropImage'; +import EmojiOnlyPicker from '../../../stories/presentation/components/EmojiOnlyPicker'; + +const BIO_MAX_LENGTH = 200; +const STATUS_MAX_LENGTH = 50; +const STATUS_PRESETS = [ + { emoji: '📞', textRu: 'На созвоне', textEn: 'On a call' }, + { emoji: '🍔', textRu: 'Обед', textEn: 'Lunch' }, + { emoji: '💻', textRu: 'Работаю', textEn: 'Working' }, + { emoji: '💤', textRu: 'Сплю', textEn: 'Sleeping' }, +]; export default function SettingsPage() { const { user, updateUser, logout } = useAuthStore(); @@ -23,9 +33,13 @@ export default function SettingsPage() { const [formData, setFormData] = useState({ displayName: user?.displayName || '', bio: user?.bio || '', - birthday: user?.birthday || '' + birthday: user?.birthday || '', + statusText: user?.statusText || '', + statusEmoji: user?.statusEmoji || '💬', + statusDuration: 'none' as 'none' | '1h' | '1d' }); const [saving, setSaving] = useState(false); + const [showStatusPicker, setShowStatusPicker] = useState(false); // Avatar cropping state const [cropModal, setCropModal] = useState<{ @@ -41,7 +55,10 @@ export default function SettingsPage() { setFormData({ displayName: user.displayName || '', bio: user.bio || '', - birthday: user.birthday || '' + birthday: user.birthday || '', + statusText: user.statusText || '', + statusEmoji: user.statusEmoji || '💬', + statusDuration: 'none' }); } }, [user]); @@ -51,6 +68,15 @@ export default function SettingsPage() { try { const payload = { ...formData, + bio: formData.bio.slice(0, BIO_MAX_LENGTH), + statusText: formData.statusText.trim() ? formData.statusText.slice(0, STATUS_MAX_LENGTH) : null, + statusEmoji: formData.statusText.trim() ? formData.statusEmoji : null, + statusExpiresAt: + formData.statusText.trim() && formData.statusDuration !== 'none' + ? new Date( + Date.now() + (formData.statusDuration === '1h' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000) + ).toISOString() + : null, birthday: formData.birthday || null }; const updatedUser = await UserApi.updateProfile(payload); @@ -237,7 +263,7 @@ export default function SettingsPage() { {editing ? (