внедрил систему статусов (BIO)

This commit is contained in:
max
2026-04-17 18:33:46 +03:00
parent ae710c3d43
commit d2e6eb578a
14 changed files with 280 additions and 17 deletions

View File

@@ -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");
}

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -11,7 +11,10 @@ public sealed record UpdateProfileCommand(
Guid UserId,
string? DisplayName,
string? Bio,
DateTime? Birthday) : ICommand<UserProfileDto>;
DateTime? Birthday,
string? StatusText,
string? StatusEmoji,
DateTime? StatusExpiresAt) : ICommand<UserProfileDto>;
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
{
@@ -28,9 +31,18 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
if ((request.Bio?.Length ?? 0) > 200)
return Result.Failure<UserProfileDto>(ProfilesErrors.BioTooLong);
if ((request.StatusText?.Length ?? 0) > 50)
return Result.Failure<UserProfileDto>(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)

View File

@@ -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;

View File

@@ -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());

View File

@@ -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
};
}

View File

@@ -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<IResult> 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);
});
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<string, { emoji?: string | null; text?: string | null }>();
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<HTMLDivElement>(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) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
{isPinned && <span className="material-symbols-outlined text-[14px] text-primary rotate-45">push_pin</span>}
{chat.type === 'personal' && otherUserStatus?.emoji && (
<span
className="text-sm leading-none"
title={otherUserStatus.text || undefined}
>
{otherUserStatus.emoji}
</span>
)}
<span className={`text-sm font-bold tracking-tight truncate ${isActive ? 'text-primary' : 'text-on-surface'}`}>{chatName}</span>
</div>
{timeStr && <span className="text-[10px] uppercase font-bold tracking-wider text-on-surface-variant/50 flex-shrink-0 ml-2">{timeStr}</span>}

View File

@@ -8,18 +8,25 @@ export class UserApi {
}
static async getUser(id: string) {
// Конечная точка в новом бэкенде: GET /api/profiles/{id}
return httpClient.request<User>(`/profiles/${id}`);
// Основной путь по ТЗ: GET /api/users/{id}; старый профильный путь оставлен на бэкенде.
return httpClient.request<User>(`/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<User>('/profiles/profile', {
method: 'PUT',
return httpClient.request<User>('/user/profile', {
method: 'PATCH',
body: JSON.stringify(payload),
});
}

View File

@@ -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 ? (
<textarea
value={formData.bio}
onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value }))}
onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value.slice(0, BIO_MAX_LENGTH) }))}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-sm font-medium text-white/80 focus:border-primary/50 transition-all outline-none resize-none h-32"
placeholder={t('tellAboutBio')}
/>
@@ -246,6 +272,11 @@ export default function SettingsPage() {
{user?.bio || t('noBio')}
</p>
)}
{editing && (
<p className="text-[10px] font-black uppercase tracking-widest text-white/35 text-right">
{formData.bio.length}/{BIO_MAX_LENGTH}
</p>
)}
</div>
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
@@ -266,6 +297,87 @@ export default function SettingsPage() {
</div>
</div>
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
<div className="flex items-center gap-3 text-primary mb-1">
<Smile size={16} />
<span className="text-[10px] font-black uppercase tracking-[0.2em]">
{lang === 'ru' ? 'Статус' : 'Status'}
</span>
</div>
{editing ? (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{STATUS_PRESETS.map((preset) => (
<button
key={`${preset.emoji}-${preset.textRu}`}
onClick={() => setFormData((p) => ({
...p,
statusEmoji: preset.emoji,
statusText: lang === 'ru' ? preset.textRu : preset.textEn,
}))}
className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-xs font-bold text-white/80 hover:border-primary/40"
>
{preset.emoji} {lang === 'ru' ? preset.textRu : preset.textEn}
</button>
))}
</div>
<div className="flex gap-3">
<button
onClick={() => setShowStatusPicker(true)}
className="px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-white text-sm font-bold"
>
{formData.statusEmoji}
</button>
<input
type="text"
value={formData.statusText}
onChange={(e) => setFormData((p) => ({ ...p, statusText: e.target.value.slice(0, STATUS_MAX_LENGTH) }))}
className="flex-1 bg-white/5 border border-white/10 rounded-2xl px-4 py-2.5 text-sm font-medium text-white/90 focus:border-primary/50 transition-all outline-none"
placeholder={lang === 'ru' ? 'Что у вас сейчас?' : 'What are you up to?'}
/>
</div>
<div className="flex gap-2">
{[
{ id: 'none', label: lang === 'ru' ? 'Без таймера' : 'No timer' },
{ id: '1h', label: lang === 'ru' ? '1 час' : '1 hour' },
{ id: '1d', label: lang === 'ru' ? '1 день' : '1 day' },
].map((opt) => (
<button
key={opt.id}
onClick={() => setFormData((p) => ({ ...p, statusDuration: opt.id as 'none' | '1h' | '1d' }))}
className={`px-3 py-2 rounded-xl text-xs font-black uppercase tracking-wider border ${
formData.statusDuration === opt.id
? 'bg-primary/20 border-primary/40 text-primary'
: 'bg-white/5 border-white/10 text-white/60'
}`}
>
{opt.label}
</button>
))}
</div>
<div className="flex items-center justify-between">
<p className="text-[10px] font-black uppercase tracking-widest text-white/35">
{formData.statusText.length}/{STATUS_MAX_LENGTH}
</p>
<button
onClick={() => setFormData((p) => ({ ...p, statusText: '', statusEmoji: '💬', statusDuration: 'none' }))}
className="text-[10px] font-black uppercase tracking-widest text-red-400/80 hover:text-red-400"
>
{lang === 'ru' ? 'Очистить статус' : 'Clear status'}
</button>
</div>
</div>
) : (
<div className="inline-flex px-4 py-2 rounded-full bg-white/5 border border-white/10 text-sm font-bold text-white">
{user?.statusEmoji || '💬'} {user?.statusText || (lang === 'ru' ? 'Статус не указан' : 'No status')}
</div>
)}
</div>
{/* App Settings */}
<div className="space-y-6 pt-6">
<div className="flex items-center gap-4 mb-4">
@@ -324,6 +436,17 @@ export default function SettingsPage() {
/>
)}
</AnimatePresence>
<AnimatePresence>
{showStatusPicker && (
<EmojiOnlyPicker
onSelect={(emoji) => {
setFormData((p) => ({ ...p, statusEmoji: emoji }));
setShowStatusPicker(false);
}}
onClose={() => setShowStatusPicker(false)}
/>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -11,7 +11,7 @@ import { useLang } from '../../../../core/infrastructure/i18n';
import { useAuthStore } from '../../../auth/application/authStore';
import { useFriendStore } from '../../../friends/application/friendStore';
import { ChatApi } from '../../../chats/infrastructure/chatApi';
import { httpClient } from '../../../../core/infrastructure/httpClient';
import { UserApi } from '../../infrastructure/userApi';
import { Loader2 } from 'lucide-react';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import type { FriendWithId, FriendRequest } from '../../../../core/domain/types';
@@ -51,7 +51,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
const fetchUser = useCallback(async () => {
try {
const res = await httpClient.request<any>(`/profiles/${userId}`);
const res = await UserApi.getUser(userId);
setUser(res);
} catch (e) {
console.error(e);
@@ -242,6 +242,13 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
<div className="px-4 py-1.5 rounded-full bg-primary/15 border border-primary/25">
<span className="text-xs font-black text-primary uppercase tracking-widest">@{user.username || 'user_' + userId.slice(0, 5)}</span>
</div>
{(user.statusEmoji || user.statusText) && (
<div className="px-4 py-1.5 rounded-full bg-white/5 border border-white/10">
<span className="text-xs font-black text-white tracking-wide">
{user.statusEmoji || '💬'} {user.statusText || (lang === 'ru' ? 'Статус' : 'Status')}
</span>
</div>
)}
<div className="flex items-center gap-2 text-xs text-white/40 font-black uppercase tracking-widest"><Calendar size={14} className="text-primary/50" /><span>{t('joined')} {formatRegistrationDate(user.createdAt)}</span></div>
</div>