Compare commits
18
Commits
android_v2
...
600e43eec5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
600e43eec5 | ||
|
|
7b251686b2 | ||
|
|
00682b2977 | ||
|
|
e05572fc3f | ||
|
|
c264b7df27 | ||
|
|
56f75ae32b | ||
|
|
ca9cf27716 | ||
|
|
7225e3272e | ||
|
|
86ae06beb6 | ||
|
|
454f70f716 | ||
|
|
e700609d30 | ||
|
|
88f39aa51f | ||
|
|
c3dbbaa7b8 | ||
|
|
2eb4f48ca0 | ||
|
|
0c1adaab6c | ||
|
|
a52726d0e6 | ||
|
|
d812a7a40c | ||
|
|
c8b4fed25a |
@@ -17,7 +17,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:py-8 lg:gap-4 z-50 transition-all safe-area-bottom"
|
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:pt-8 lg:pb-14 lg:gap-4 z-50 transition-all safe-area-bottom"
|
||||||
>
|
>
|
||||||
<div className="hidden lg:flex mb-10 flex-col items-center">
|
<div className="hidden lg:flex mb-10 flex-col items-center">
|
||||||
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
||||||
@@ -46,7 +46,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="lg:mt-auto group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
className="lg:mt-auto lg:mb-4 group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
||||||
onClick={() => onTabChange('settings')}
|
onClick={() => onTabChange('settings')}
|
||||||
>
|
>
|
||||||
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export function normalizeUser(user: any): any {
|
|||||||
result.avatar = result.avatarUrl;
|
result.avatar = result.avatarUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Приведение bio (Settings -> bio, Profiles -> about)
|
||||||
|
if (!result.bio && result.about) {
|
||||||
|
result.bio = result.about;
|
||||||
|
}
|
||||||
|
if (!result.about && result.bio) {
|
||||||
|
result.about = result.bio;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { User } from '../../../core/domain/types';
|
|||||||
|
|
||||||
export class AuthApi {
|
export class AuthApi {
|
||||||
static async login(username: string, password: string) {
|
static async login(username: string, password: string) {
|
||||||
const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/login', {
|
const response = await httpClient.request<any>('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
@@ -11,16 +11,17 @@ export class AuthApi {
|
|||||||
return {
|
return {
|
||||||
token: response.accessToken,
|
token: response.accessToken,
|
||||||
user: {
|
user: {
|
||||||
id: response.userId,
|
...response,
|
||||||
username: response.username,
|
id: response.userId || (response as any).id,
|
||||||
|
username: response.username || (response as any).userName,
|
||||||
displayName: response.displayName,
|
displayName: response.displayName,
|
||||||
avatar: null
|
avatar: response.avatar || null
|
||||||
} as User
|
} as unknown as User
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async register(username: string, displayName: string, password: string, bio?: string) {
|
static async register(username: string, displayName: string, password: string, bio?: string) {
|
||||||
const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/register', {
|
const response = await httpClient.request<any>('/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, displayName, password, bio }),
|
body: JSON.stringify({ username, displayName, password, bio }),
|
||||||
});
|
});
|
||||||
@@ -28,24 +29,32 @@ export class AuthApi {
|
|||||||
return {
|
return {
|
||||||
token: response.accessToken,
|
token: response.accessToken,
|
||||||
user: {
|
user: {
|
||||||
id: response.userId,
|
...response,
|
||||||
username: response.username,
|
id: response.userId || (response as any).id,
|
||||||
|
username: response.username || (response as any).userName,
|
||||||
displayName: response.displayName,
|
displayName: response.displayName,
|
||||||
avatar: null
|
avatar: response.avatar || null
|
||||||
} as User
|
} as unknown as User
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getMe() {
|
static async getMe() {
|
||||||
const response = await httpClient.request<{ userId: string; username: string; displayName: string; avatar: string | null; accessToken?: string }>('/auth/me');
|
const authRes = await httpClient.request<any>('/auth/me');
|
||||||
|
const userId = authRes.userId || authRes.id;
|
||||||
|
|
||||||
|
// Пытаемся получить расширенный профиль (био, дата рождения)
|
||||||
|
const profileRes = await httpClient.request<any>(`/profiles/${userId}`).catch(() => ({}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: response.userId,
|
...authRes,
|
||||||
username: response.username,
|
...profileRes,
|
||||||
displayName: response.displayName,
|
id: userId,
|
||||||
avatar: response.avatar
|
username: authRes.username || authRes.userName || profileRes.userName,
|
||||||
} as User,
|
displayName: authRes.displayName || profileRes.displayName,
|
||||||
token: response.accessToken
|
avatar: authRes.avatar || authRes.avatarUrl || profileRes.avatarUrl
|
||||||
|
} as unknown as User,
|
||||||
|
token: authRes.accessToken
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1377,11 +1377,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{typingInChat.length > 0 && (
|
{/* Typing indicator is already shown in the header, removed from here to prevent layout jumping */}
|
||||||
<div className="px-4 pb-1">
|
|
||||||
<TypingIndicator />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -76,9 +76,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const getSearchedUserId = (u: UserPresence & { userId?: string }) => u.id || u.userId || '';
|
||||||
|
|
||||||
// Keep local state in sync with chat prop
|
// Keep local state in sync with chat prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGroupName(chat.name || '');
|
setGroupName(chat.name || '');
|
||||||
setGroupDesc(chat.description || '');
|
setGroupDesc(chat.description || '');
|
||||||
}, [chat.name, chat.description]);
|
}, [chat.name, chat.description]);
|
||||||
@@ -95,7 +96,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
const results = await UserApi.searchUsers(searchQuery);
|
const results = await UserApi.searchUsers(searchQuery);
|
||||||
// Filter out users already in the group
|
// Filter out users already in the group
|
||||||
const memberIds = new Set(chat.members.map((m) => m.user.id));
|
const memberIds = new Set(chat.members.map((m) => m.user.id));
|
||||||
setSearchResults(results.filter((u) => !memberIds.has(u.id)));
|
setSearchResults(results.filter((u) => {
|
||||||
|
const candidateId = getSearchedUserId(u as UserPresence & { userId?: string });
|
||||||
|
return !!candidateId && !memberIds.has(candidateId);
|
||||||
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -565,10 +569,13 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
<Loader2 size={16} className="text-zinc-500 animate-spin" />
|
<Loader2 size={16} className="text-zinc-500 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{searchResults.map((u) => (
|
{searchResults.map((u) => {
|
||||||
|
const candidateId = getSearchedUserId(u as UserPresence & { userId?: string });
|
||||||
|
if (!candidateId) return null;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={u.id}
|
key={candidateId}
|
||||||
onClick={() => handleAddMember(u.id)}
|
onClick={() => handleAddMember(candidateId)}
|
||||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
|
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -583,7 +590,8 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
</div>
|
</div>
|
||||||
<UserPlus size={14} className="text-knot-400 flex-shrink-0" />
|
<UserPlus size={14} className="text-knot-400 flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{searchQuery.trim() && !isSearching && searchResults.length === 0 && (
|
{searchQuery.trim() && !isSearching && searchResults.length === 0 && (
|
||||||
<p className="text-xs text-zinc-500 text-center py-2">{t('usersNotFound')}</p>
|
<p className="text-xs text-zinc-500 text-center py-2">{t('usersNotFound')}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMine && (
|
{!isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||||
<div className="w-8 flex-shrink-0 mr-2 self-end">
|
<div className="w-8 flex-shrink-0 mr-2 self-end">
|
||||||
{showAvatar ? (
|
{showAvatar ? (
|
||||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||||
@@ -472,7 +472,7 @@ function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<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`}>
|
<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 && (
|
{!isMine && showAvatar && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||||
<button
|
<button
|
||||||
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
|
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
|
||||||
onClick={() => onViewProfile?.(message.senderId)}
|
onClick={() => onViewProfile?.(message.senderId)}
|
||||||
@@ -613,7 +613,7 @@ function MessageBubble({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`
|
<div className={`
|
||||||
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content ? 'mb-2' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
|
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content || hasVoice || hasAudio || hasFile || message.type === 'poll' ? 'mb-3' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
|
||||||
${isSingleGif ? 'max-w-[260px]' : ''}
|
${isSingleGif ? 'max-w-[260px]' : ''}
|
||||||
overflow-hidden relative rounded-[1.25rem]
|
overflow-hidden relative rounded-[1.25rem]
|
||||||
`}>
|
`}>
|
||||||
@@ -709,7 +709,7 @@ function MessageBubble({
|
|||||||
|
|
||||||
{/* Голосовое - Optimized Kinetic Layout */}
|
{/* Голосовое - Optimized Kinetic Layout */}
|
||||||
{hasVoice && (
|
{hasVoice && (
|
||||||
<div className="flex items-center gap-3 min-w-[200px] py-0.5">
|
<div className={`flex items-center gap-3 min-w-[200px] py-0.5 ${hasImage || hasVideo || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
|
||||||
<audio
|
<audio
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
src={media.find((m) => m.type === 'voice')?.url}
|
src={media.find((m) => m.type === 'voice')?.url}
|
||||||
@@ -778,7 +778,7 @@ function MessageBubble({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[220px]">
|
<div className={`min-w-[220px] ${hasImage || hasVideo || hasVoice || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
|
||||||
{audioMedia?.filename && (
|
{audioMedia?.filename && (
|
||||||
<div className="flex items-center gap-2 mb-2 min-w-0">
|
<div className="flex items-center gap-2 mb-2 min-w-0">
|
||||||
<Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
|
<Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
|
||||||
@@ -875,7 +875,7 @@ function MessageBubble({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`flex items-center gap-3 p-3 rounded-2xl ${isMine ? 'bg-[#0a0a0a]/5 hover:bg-[#0a0a0a]/10' : 'bg-zinc-900/50 hover:bg-zinc-800/80 border border-white/5'
|
className={`flex items-center gap-3 p-3 rounded-2xl ${isMine ? 'bg-[#0a0a0a]/5 hover:bg-[#0a0a0a]/10' : 'bg-zinc-900/50 hover:bg-zinc-800/80 border border-white/5'
|
||||||
} transition-all mb-1 group/file`}
|
} transition-all mb-1 group/file ${hasImage || hasVideo || hasVoice || hasAudio || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}
|
||||||
>
|
>
|
||||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20'
|
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20'
|
||||||
} group-hover/file:scale-110 transition-transform`}>
|
} group-hover/file:scale-110 transition-transform`}>
|
||||||
@@ -1034,7 +1034,7 @@ function MessageBubble({
|
|||||||
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
|
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
|
||||||
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
|
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-end gap-2 text-sm w-full">
|
<div className={`flex items-end gap-2 text-sm w-full ${hasImage || hasVideo || hasVoice || hasAudio || hasFile || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
|
||||||
<div className="flex-1 min-w-0 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' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}>
|
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}>
|
||||||
{renderFormattedText(message.content)}
|
{renderFormattedText(message.content)}
|
||||||
@@ -1088,7 +1088,7 @@ function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMine && (
|
{isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||||
<div className="w-8 flex-shrink-0 ml-2 self-end">
|
<div className="w-8 flex-shrink-0 ml-2 self-end">
|
||||||
{showAvatar ? (
|
{showAvatar ? (
|
||||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||||
|
|||||||
@@ -331,65 +331,70 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const processFiles = useCallback((files: File[]) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const { addNotification } = useNotificationStore.getState();
|
||||||
if (files.length > 0) {
|
const newAttachments: Attachment[] = [];
|
||||||
const { addNotification } = useNotificationStore.getState();
|
|
||||||
const newAttachments: Attachment[] = [];
|
let tooLarge = false;
|
||||||
|
let limitExceeded = false;
|
||||||
let tooLarge = false;
|
|
||||||
let limitExceeded = false;
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (attachments.length + newAttachments.length >= 20) {
|
if (attachments.length + newAttachments.length >= 20) {
|
||||||
limitExceeded = true;
|
limitExceeded = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
tooLarge = true;
|
tooLarge = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
|
||||||
newAttachments.push({ file, type: isAudio ? 'audio' : 'file' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
const isVideo = file.type.startsWith('video/');
|
||||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
const isImage = file.type.startsWith('image/');
|
||||||
|
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||||
setAttachments(prev => [...prev, ...newAttachments]);
|
|
||||||
inputRef.current?.focus();
|
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
|
||||||
|
const preview = isImage ? URL.createObjectURL(file) : undefined;
|
||||||
|
|
||||||
|
newAttachments.push({ file, type, preview });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
||||||
|
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||||
|
|
||||||
|
setAttachments(prev => [...prev, ...newAttachments]);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [attachments, t]);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (files.length > 0) processFiles(files);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
setShowAttachMenu(false);
|
setShowAttachMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) processFiles(files);
|
||||||
const { addNotification } = useNotificationStore.getState();
|
|
||||||
const newAttachments: Attachment[] = [];
|
|
||||||
|
|
||||||
let limitExceeded = false;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (attachments.length + newAttachments.length >= 20) {
|
|
||||||
limitExceeded = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const isVideo = file.type.startsWith('video/');
|
|
||||||
const preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined;
|
|
||||||
newAttachments.push({ file, preview, type: isVideo ? 'video' : 'image' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
|
||||||
|
|
||||||
setAttachments(prev => [...prev, ...newAttachments]);
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
setShowAttachMenu(false);
|
setShowAttachMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent) => {
|
||||||
|
const items = Array.from(e.clipboardData.items);
|
||||||
|
const files = items
|
||||||
|
.filter(item => item.kind === 'file')
|
||||||
|
.map(item => item.getAsFile())
|
||||||
|
.filter((f): f is File => f !== null);
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
processFiles(files);
|
||||||
|
// If we only pasted files, don't paste the filename/text representation in the textarea
|
||||||
|
if (items.every(item => item.kind === 'file')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Запись голосового
|
// Запись голосового
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -569,41 +574,11 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
const files = Array.from(e.dataTransfer.files);
|
const files = Array.from(e.dataTransfer.files);
|
||||||
const { addNotification } = useNotificationStore.getState();
|
processFiles(files);
|
||||||
const newAttachments: Attachment[] = [];
|
|
||||||
|
|
||||||
let tooLarge = false;
|
|
||||||
let limitExceeded = false;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (attachments.length + newAttachments.length >= 20) {
|
|
||||||
limitExceeded = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
tooLarge = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVideo = file.type.startsWith('video/');
|
|
||||||
const isImage = file.type.startsWith('image/');
|
|
||||||
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma', '.opus'];
|
|
||||||
const isAudio = file.type.startsWith('audio/') || audioExts.some(ext => file.name.toLowerCase().endsWith(ext));
|
|
||||||
|
|
||||||
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
|
|
||||||
const preview = isImage ? URL.createObjectURL(file) : undefined;
|
|
||||||
|
|
||||||
newAttachments.push({ file, type, preview });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
|
||||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
|
||||||
|
|
||||||
setAttachments(prev => [...prev, ...newAttachments]);
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const hasContent = text.trim() || attachments.length > 0;
|
const hasContent = text.trim() || attachments.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -856,6 +831,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onContextMenu={handleInputContextMenu}
|
onContextMenu={handleInputContextMenu}
|
||||||
|
onPaste={handlePaste}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="w-full bg-transparent border-none focus:ring-0 text-[#efeff3] placeholder-on-surface-variant/30 text-[16px] leading-[1.3] resize-none max-h-[140px] custom-scrollbar outline-none py-1 px-0"
|
className="w-full bg-transparent border-none focus:ring-0 text-[#efeff3] placeholder-on-surface-variant/30 text-[16px] leading-[1.3] resize-none max-h-[140px] custom-scrollbar outline-none py-1 px-0"
|
||||||
placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'}
|
placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'}
|
||||||
|
|||||||
@@ -14,9 +14,13 @@ export class UserApi {
|
|||||||
|
|
||||||
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string | null }) {
|
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string | null }) {
|
||||||
// Конечная точка в новом бэкенде: PUT /api/profiles/profile
|
// Конечная точка в новом бэкенде: PUT /api/profiles/profile
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
about: data.bio // Дублируем для совместимости
|
||||||
|
};
|
||||||
return httpClient.request<User>('/profiles/profile', {
|
return httpClient.request<User>('/profiles/profile', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
|||||||
{/* About Section */}
|
{/* About Section */}
|
||||||
<div className="p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 mb-10 group relative transition-colors hover:bg-white/[0.04]">
|
<div className="p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 mb-10 group relative transition-colors hover:bg-white/[0.04]">
|
||||||
<div className="flex items-center gap-3 mb-4 text-primary"><Info size={16} /><span className="text-xs font-black uppercase tracking-[0.2em]">{t('aboutMe')}</span></div>
|
<div className="flex items-center gap-3 mb-4 text-primary"><Info size={16} /><span className="text-xs font-black uppercase tracking-[0.2em]">{t('aboutMe')}</span></div>
|
||||||
<p className="text-base text-white/80 leading-relaxed font-medium">{user.about || t('noBio')}</p>
|
<p className="text-base text-white/80 leading-relaxed font-medium">{user.bio || t('noBio')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs Nav */}
|
{/* Tabs Nav */}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: knot-db
|
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER
|
- POSTGRES_USER
|
||||||
@@ -10,20 +9,19 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ../backend
|
context: ../backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: knot-server
|
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- minio
|
- minio
|
||||||
- mongo
|
- mongo
|
||||||
ports:
|
ports:
|
||||||
- "5059:8080"
|
- "${SERVER_PORT:-5059}:8080"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL
|
- DATABASE_URL
|
||||||
- MONGO_CONNECTION
|
- MONGO_CONNECTION
|
||||||
@@ -41,23 +39,21 @@ services:
|
|||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio
|
image: minio/minio
|
||||||
container_name: knot-minio
|
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- MINIO_ROOT_USER
|
- MINIO_ROOT_USER
|
||||||
- MINIO_ROOT_PASSWORD
|
- MINIO_ROOT_PASSWORD
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "${MINIO_API_PORT:-9000}:9000"
|
||||||
- "9001:9001"
|
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
mongo:
|
mongo:
|
||||||
image: mongo:6-jammy
|
image: mongo:6-jammy
|
||||||
container_name: knot-mongo
|
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "27017:27017"
|
- "${MONGO_PORT:-27017}:27017"
|
||||||
volumes:
|
volumes:
|
||||||
- mongo_data:/data/db
|
- mongo_data:/data/db
|
||||||
|
|
||||||
@@ -65,10 +61,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: client-web/Dockerfile
|
dockerfile: client-web/Dockerfile
|
||||||
container_name: knot-web
|
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "9090:80"
|
- "${WEB_PORT:-9090}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user