global fix and add tauri app
This commit is contained in:
36
apps/frontend/components/chat/chat-pinned-message-banner.tsx
Normal file
36
apps/frontend/components/chat/chat-pinned-message-banner.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { Pin } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChatPinnedMessageBannerProps {
|
||||
senderName: string;
|
||||
preview: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function ChatPinnedMessageBanner({ senderName, preview, className, onClick }: ChatPinnedMessageBannerProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 border-b border-[#dce3ec] bg-white/95 px-4 py-2 text-left shadow-[0_8px_20px_rgba(31,36,48,0.06)] backdrop-blur transition hover:bg-[#f7f9fc]',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="h-10 w-1 shrink-0 rounded-full bg-[#3390ec]" aria-hidden />
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-[#3390ec]/10 text-[#3390ec]">
|
||||
<Pin className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-[#3390ec]">Закреплённое сообщение</span>
|
||||
<span className="block truncate text-sm text-[#1f2430]">
|
||||
<span className="font-medium">{senderName}: </span>
|
||||
{preview || 'Сообщение'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { LayoutGrid, Loader2, Pin, Send, X } from 'lucide-react';
|
||||
import { LayoutGrid, Loader2, Send, X } from 'lucide-react';
|
||||
import { BotMessageKeyboard } from '@/components/chat/bot-message-keyboard';
|
||||
import { BotChatMessageContextMenu } from '@/components/chat/bot-chat-message-context-menu';
|
||||
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
|
||||
import { useRealtime } from '@/components/notifications/realtime-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -69,6 +70,18 @@ export function FamilyBotChat({
|
||||
const [botMeta, setBotMeta] = useState<BotChatMeta | null>(null);
|
||||
const { subscribe } = useRealtime();
|
||||
const { showToast } = useToast();
|
||||
const pinnedMessage = messages
|
||||
.filter((message) => message.isPinned)
|
||||
.sort((left, right) => new Date(right.pinnedAt ?? right.createdAt).getTime() - new Date(left.pinnedAt ?? left.createdAt).getTime())[0] ?? null;
|
||||
|
||||
function scrollToBotMessage(messageId: string) {
|
||||
const element = document.getElementById(`bot-msg-${messageId}`);
|
||||
if (!element) return false;
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
element.classList.add('blink-highlight');
|
||||
window.setTimeout(() => element.classList.remove('blink-highlight'), 2000);
|
||||
return true;
|
||||
}
|
||||
|
||||
const openMiniApp = useCallback(
|
||||
(url: string) => {
|
||||
@@ -236,6 +249,17 @@ export function FamilyBotChat({
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col', className)}>
|
||||
{pinnedMessage ? (
|
||||
<ChatPinnedMessageBanner
|
||||
senderName={pinnedMessage.direction === 'out' ? botName : 'Вы'}
|
||||
preview={pinnedMessage.text}
|
||||
onClick={() => {
|
||||
if (!scrollToBotMessage(pinnedMessage.id)) {
|
||||
showToast('Закреплённое сообщение пока не загружено в ленте');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-[#667085]">
|
||||
@@ -247,15 +271,9 @@ export function FamilyBotChat({
|
||||
const mine = message.direction === 'in';
|
||||
const hasKeyboard = Boolean(message.replyMarkupJson?.trim());
|
||||
return (
|
||||
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
|
||||
<div key={message.id} id={`bot-msg-${message.id}`} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
|
||||
<BotChatMessageContextMenu text={message.text} onCopy={() => showToast('Текст скопирован')}>
|
||||
<div className={cn('max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm', mine ? 'rounded-br-md bg-[#effdde]' : 'rounded-bl-md bg-white')}>
|
||||
{message.isPinned ? (
|
||||
<div className={cn('mb-1 flex items-center gap-1 text-[11px] font-medium', mine ? 'text-[#6c8f56]' : 'text-[#3390ec]')}>
|
||||
<Pin className="h-3 w-3" />
|
||||
Закреплено
|
||||
</div>
|
||||
) : null}
|
||||
{!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{botName}</p> : null}
|
||||
<p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p>
|
||||
{!mine && hasKeyboard && message.messageId > 0 ? (
|
||||
|
||||
@@ -39,6 +39,7 @@ import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools
|
||||
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
|
||||
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
|
||||
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
|
||||
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
|
||||
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
|
||||
import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar';
|
||||
import { EmojiPicker } from '@/components/chat/emoji-picker';
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
reportChatTyping,
|
||||
sendChatMessage,
|
||||
setChatMessagePinned,
|
||||
setChatRoomPinned,
|
||||
setChatRoomMuted,
|
||||
toggleChatMessageReaction,
|
||||
updateFamilyGroup,
|
||||
@@ -247,6 +249,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const [miniAppTitle, setMiniAppTitle] = useState('Mini App');
|
||||
const [botChatMeta, setBotChatMeta] = useState<BotChatMeta | null>(null);
|
||||
const [deletingRoomId, setDeletingRoomId] = useState<string | null>(null);
|
||||
const [pinningRoomId, setPinningRoomId] = useState<string | null>(null);
|
||||
const [familyAvatarUploading, setFamilyAvatarUploading] = useState(false);
|
||||
const [roomAvatarUploading, setRoomAvatarUploading] = useState(false);
|
||||
const [forwardDialogOpen, setForwardDialogOpen] = useState(false);
|
||||
@@ -257,6 +260,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
|
||||
const readReceiptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const typingEmitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastTypingSentAtRef = useRef(0);
|
||||
@@ -279,6 +283,13 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
() => new Map(visibleMessages.map((message) => [message.id, message])),
|
||||
[visibleMessages]
|
||||
);
|
||||
const pinnedMessage = useMemo(
|
||||
() =>
|
||||
visibleMessages
|
||||
.filter((message) => message.isPinned)
|
||||
.sort((left, right) => new Date(right.pinnedAt ?? right.createdAt).getTime() - new Date(left.pinnedAt ?? left.createdAt).getTime())[0] ?? null,
|
||||
[visibleMessages]
|
||||
);
|
||||
|
||||
const { typers, registerTyper, clearTypers } = useChatTyping(user?.id);
|
||||
const {
|
||||
@@ -311,6 +322,17 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
userId: user?.id,
|
||||
token
|
||||
});
|
||||
const pinnedVisibleText = pinnedMessage
|
||||
? getE2EMessageText({
|
||||
message: pinnedMessage,
|
||||
isE2E: activeRoomIsE2E,
|
||||
peerE2eKey,
|
||||
peerKeyLoading,
|
||||
e2ePayload: pinnedMessage.isEncrypted ? e2ePayloads[pinnedMessage.id] : null,
|
||||
e2eError: e2eErrors[pinnedMessage.id]
|
||||
})
|
||||
: '';
|
||||
const pinnedPreview = pinnedMessage ? getMessagePreviewText(pinnedMessage, pinnedVisibleText) : '';
|
||||
const activeRoomTitle = useMemo(
|
||||
() => (activeRoom && user ? roomDisplayLabel(activeRoom, user.id) : ''),
|
||||
[activeRoom, user]
|
||||
@@ -431,8 +453,21 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}, [activeRoomId, messages, token]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
autoScrollStateRef.current = { id: null, count: 0 };
|
||||
}, [activeRoomId]);
|
||||
|
||||
useEffect(() => {
|
||||
const lastMessage = visibleMessages[visibleMessages.length - 1];
|
||||
const previous = autoScrollStateRef.current;
|
||||
const next = { id: lastMessage?.id ?? null, count: visibleMessages.length };
|
||||
autoScrollStateRef.current = next;
|
||||
if (!next.id) return;
|
||||
const lastMessageChanged = previous.id !== next.id;
|
||||
const messageWasAppended = next.count >= previous.count;
|
||||
if (lastMessageChanged && messageWasAppended) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: previous.id ? 'smooth' : 'auto' });
|
||||
}
|
||||
}, [visibleMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRoomId || !token || activeRoomIsBot) return;
|
||||
@@ -683,6 +718,36 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
setRooms((current) => current.map((item) => (item.id === room.id ? room : item)));
|
||||
}
|
||||
|
||||
function sortRoomsForViewer(roomList: ChatRoom[]) {
|
||||
return [...roomList].sort((a, b) => {
|
||||
const pinDiff = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned));
|
||||
if (pinDiff !== 0) return pinDiff;
|
||||
if (a.isPinned && b.isPinned) {
|
||||
return (b.pinnedAt ?? '').localeCompare(a.pinnedAt ?? '');
|
||||
}
|
||||
const rank = (type: string) => (type === 'GENERAL' ? 0 : 1);
|
||||
const rankDiff = rank(a.type) - rank(b.type);
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
const aTime = a.lastMessage?.createdAt ?? a.updatedAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.updatedAt;
|
||||
return bTime.localeCompare(aTime);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleToggleRoomPin(room: ChatRoom) {
|
||||
if (!token) return;
|
||||
setPinningRoomId(room.id);
|
||||
try {
|
||||
const updated = await setChatRoomPinned(room.id, !room.isPinned, token);
|
||||
setRooms((current) => sortRoomsForViewer(current.map((item) => (item.id === updated.id ? updated : item))));
|
||||
showToast(updated.isPinned ? 'Чат закреплён' : 'Чат откреплён');
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось закрепить чат') ?? 'Ошибка');
|
||||
} finally {
|
||||
setPinningRoomId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddRoomMember(memberUserId: string) {
|
||||
if (!token || !activeRoomId) return;
|
||||
try {
|
||||
@@ -1281,7 +1346,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
<ChatRoomAvatarDisplay room={room} viewerUserId={user?.id ?? ''} token={token} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate font-medium">{user ? roomDisplayLabel(room, user.id) : room.name}</p>
|
||||
<p className="flex min-w-0 items-center gap-1 truncate font-medium">
|
||||
<span className="truncate">{user ? roomDisplayLabel(room, user.id) : room.name}</span>
|
||||
{room.isPinned ? <Pin className="h-3.5 w-3.5 shrink-0 fill-[#8a8f9e] text-[#8a8f9e]" /> : null}
|
||||
</p>
|
||||
{room.lastMessage ? (
|
||||
<span className="shrink-0 text-[11px] text-[#a8adbc]">{formatMessageTime(room.lastMessage.createdAt)}</span>
|
||||
) : null}
|
||||
@@ -1291,17 +1359,23 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{room.type !== 'GENERAL' ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Удалить чат"
|
||||
disabled={deletingRoomId === room.id}
|
||||
className="mr-2 flex h-8 w-8 shrink-0 self-center items-center justify-center rounded-lg text-[#667085] opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 disabled:opacity-50"
|
||||
onClick={() => void handleDeleteRoom(room.id)}
|
||||
>
|
||||
{deletingRoomId === room.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={room.isPinned ? 'Открепить чат' : 'Закрепить чат'}
|
||||
disabled={pinningRoomId === room.id}
|
||||
className={cn(
|
||||
'flex h-8 w-8 shrink-0 self-center items-center justify-center rounded-lg text-[#8a8f9e] transition hover:bg-[#eceef4] hover:text-[#667085]',
|
||||
room.isPinned ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||
pinningRoomId === room.id && 'opacity-100'
|
||||
)}
|
||||
onClick={() => void handleToggleRoomPin(room)}
|
||||
>
|
||||
{pinningRoomId === room.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Pin className={cn('h-4 w-4', room.isPinned && 'fill-[#8a8f9e]')} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1452,6 +1526,17 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{pinnedMessage ? (
|
||||
<ChatPinnedMessageBanner
|
||||
senderName={pinnedMessage.senderName}
|
||||
preview={pinnedPreview}
|
||||
onClick={() => {
|
||||
if (!scrollToChatMessage(pinnedMessage.id)) {
|
||||
showToast('Закреплённое сообщение пока не загружено в ленте');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', isDragging && 'select-none')}>
|
||||
{visibleMessages.map((message, messageIndex) => {
|
||||
const previousMessage = visibleMessages[messageIndex - 1];
|
||||
@@ -1518,12 +1603,6 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
)
|
||||
)}
|
||||
>
|
||||
{message.isPinned && !isLargeEmoji ? (
|
||||
<div className={cn('mb-1 flex items-center gap-1 text-[11px] font-medium', mine ? 'text-[#6c8f56]' : 'text-[#3390ec]')}>
|
||||
<Pin className="h-3 w-3" />
|
||||
Закреплено
|
||||
</div>
|
||||
) : null}
|
||||
{!mine ? <p className="mb-1 text-xs font-semibold text-[#3390ec]">{message.senderName}</p> : null}
|
||||
{forwardedFrom ? (
|
||||
<p className="mb-1 text-xs font-medium text-[#3390ec]">↪️ Переслано от {forwardedFrom.senderName}</p>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Search, UsersRound } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Loader2, Pin, Search, UsersRound } from 'lucide-react';
|
||||
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
|
||||
import { FamilyGroupSelector } from '@/components/family/family-group-selector';
|
||||
import { FamilyInviteDialog } from '@/components/family/family-invite-dialog';
|
||||
@@ -11,6 +11,8 @@ import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useSelectedFamily } from '@/hooks/use-primary-family';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChatRoom, fetchChatRooms, getApiErrorMessage, setChatRoomPinned } from '@/lib/api';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
|
||||
export function FamilySidebarPanel() {
|
||||
const { user, token } = useAuth();
|
||||
@@ -25,7 +27,64 @@ export function FamilySidebarPanel() {
|
||||
refresh
|
||||
} = useSelectedFamily(Boolean(user && token));
|
||||
const { openChatWithMember } = useFamilyOverlay();
|
||||
const { showToast } = useToast();
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [rooms, setRooms] = useState<ChatRoom[]>([]);
|
||||
const [pinningRoomId, setPinningRoomId] = useState<string | null>(null);
|
||||
const currentUserId = user?.id ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!group?.id || !token) {
|
||||
setRooms([]);
|
||||
return;
|
||||
}
|
||||
fetchChatRooms(group.id, token)
|
||||
.then((response) => setRooms(response.rooms ?? []))
|
||||
.catch(() => setRooms([]));
|
||||
}, [group?.id, token]);
|
||||
|
||||
const roomByPeerUserId = useMemo(() => {
|
||||
const map = new Map<string, ChatRoom>();
|
||||
for (const room of rooms) {
|
||||
if (room.type !== 'DIRECT' && room.type !== 'BOT' && room.type !== 'E2E') continue;
|
||||
const peer = room.members.find((member) => member.userId !== currentUserId);
|
||||
if (peer) map.set(peer.userId, room);
|
||||
}
|
||||
return map;
|
||||
}, [currentUserId, rooms]);
|
||||
|
||||
const sortedMembers = useMemo(() => {
|
||||
return [...(group?.members ?? [])].sort((left, right) => {
|
||||
const leftRoom = roomByPeerUserId.get(left.userId);
|
||||
const rightRoom = roomByPeerUserId.get(right.userId);
|
||||
const pinDiff = Number(Boolean(rightRoom?.isPinned)) - Number(Boolean(leftRoom?.isPinned));
|
||||
if (pinDiff !== 0) return pinDiff;
|
||||
if (leftRoom?.isPinned && rightRoom?.isPinned) {
|
||||
return (rightRoom.pinnedAt ?? '').localeCompare(leftRoom.pinnedAt ?? '');
|
||||
}
|
||||
if (left.userId === currentUserId) return -1;
|
||||
if (right.userId === currentUserId) return 1;
|
||||
return left.displayName.localeCompare(right.displayName, 'ru');
|
||||
});
|
||||
}, [currentUserId, group?.members, roomByPeerUserId]);
|
||||
|
||||
async function toggleMemberPinned(memberUserId: string) {
|
||||
const room = roomByPeerUserId.get(memberUserId);
|
||||
if (!room || !token) {
|
||||
showToast('Откройте чат с участником, чтобы его можно было закрепить');
|
||||
return;
|
||||
}
|
||||
setPinningRoomId(room.id);
|
||||
try {
|
||||
const updated = await setChatRoomPinned(room.id, !room.isPinned, token);
|
||||
setRooms((current) => current.map((item) => (item.id === updated.id ? updated : item)));
|
||||
showToast(updated.isPinned ? 'Чат закреплён' : 'Чат откреплён');
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось закрепить чат') ?? 'Ошибка');
|
||||
} finally {
|
||||
setPinningRoomId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user || !token) return null;
|
||||
|
||||
@@ -69,49 +128,68 @@ export function FamilySidebarPanel() {
|
||||
Загрузка...
|
||||
</div>
|
||||
) : hasFamily ? (
|
||||
<div className="max-h-[220px] space-y-1 overflow-y-auto pr-1">
|
||||
{(group?.members ?? []).map((member) => {
|
||||
<div className="space-y-1 overflow-y-auto pr-1">
|
||||
{sortedMembers.map((member) => {
|
||||
const presence = presenceByUserId.get(member.userId);
|
||||
const isSelf = member.userId === user.id;
|
||||
const isSelf = member.userId === currentUserId;
|
||||
const memberRoom = roomByPeerUserId.get(member.userId);
|
||||
const memberPinned = Boolean(memberRoom?.isPinned);
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={member.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-xl px-2 py-1.5 text-left transition hover:bg-[#f4f5f8]',
|
||||
'flex w-full items-center gap-1 rounded-xl transition hover:bg-[#f4f5f8]',
|
||||
isSelf && 'bg-[#fafbfd]'
|
||||
)}
|
||||
onClick={() =>
|
||||
openChatWithMember(member.userId, member.displayName, {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left"
|
||||
onClick={() => openChatWithMember(member.userId, member.displayName, {
|
||||
isBot: member.isBot,
|
||||
botUsername: member.botUsername
|
||||
})
|
||||
}
|
||||
title={isSelf ? 'Вы' : `Написать ${member.displayName}`}
|
||||
>
|
||||
<AvatarWithPresence
|
||||
userId={member.userId}
|
||||
displayName={member.displayName}
|
||||
hasAvatar={member.hasAvatar}
|
||||
token={token}
|
||||
isVerified={member.isVerified}
|
||||
verificationIcon={member.verificationIcon}
|
||||
online={isSelf ? undefined : presence?.online}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[12px] font-medium leading-tight">
|
||||
{member.displayName}
|
||||
{member.isBot ? ' 🤖' : ''}
|
||||
{isSelf ? ' (Вы)' : ''}
|
||||
</p>
|
||||
{!isSelf ? (
|
||||
<p className={cn('truncate text-[10px]', member.isBot ? 'text-[#3390ec]' : presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}>
|
||||
{member.isBot ? 'Бот' : presence?.online ? 'В сети' : 'Не в сети'}
|
||||
})}
|
||||
title={isSelf ? 'Вы' : `Написать ${member.displayName}`}
|
||||
>
|
||||
<AvatarWithPresence
|
||||
userId={member.userId}
|
||||
displayName={member.displayName}
|
||||
hasAvatar={member.hasAvatar}
|
||||
token={token}
|
||||
isVerified={member.isVerified}
|
||||
verificationIcon={member.verificationIcon}
|
||||
online={isSelf ? undefined : presence?.online}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex min-w-0 items-center gap-1 truncate text-[12px] font-medium leading-tight">
|
||||
<span className="truncate">{member.displayName}</span>
|
||||
{member.isBot ? <span>🤖</span> : null}
|
||||
{isSelf ? <span>(Вы)</span> : null}
|
||||
{memberPinned ? <Pin className="h-3 w-3 shrink-0 fill-[#8a8f9e] text-[#8a8f9e]" /> : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
{!isSelf ? (
|
||||
<p className={cn('truncate text-[10px]', member.isBot ? 'text-[#3390ec]' : presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}>
|
||||
{member.isBot ? 'Бот' : presence?.online ? 'В сети' : 'Не в сети'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
{!isSelf ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={memberPinned ? 'Открепить чат' : 'Закрепить чат'}
|
||||
className="mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[#8a8f9e] transition hover:bg-[#eceef4] hover:text-[#667085]"
|
||||
onClick={() => void toggleMemberPinned(member.userId)}
|
||||
>
|
||||
{pinningRoomId === memberRoom?.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Pin className={cn('h-3.5 w-3.5', memberPinned && 'fill-[#8a8f9e]')} />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, BarChart3, CalendarDays, ImageIcon, Loader2, MessageCircle, Paperclip, Pin, Search, Send, Settings, Smile, Trash2, X } from 'lucide-react';
|
||||
import { ArrowLeft, BarChart3, CalendarDays, ImageIcon, Loader2, MessageCircle, Paperclip, Search, Send, Settings, Smile, Trash2, X } from 'lucide-react';
|
||||
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
|
||||
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
|
||||
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
||||
import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
|
||||
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
||||
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
|
||||
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
|
||||
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
|
||||
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
|
||||
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
|
||||
@@ -122,6 +123,7 @@ export function MiniFamilyChat() {
|
||||
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
||||
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const previousGroupIdRef = useRef<string | null>(null);
|
||||
|
||||
@@ -129,6 +131,13 @@ export function MiniFamilyChat() {
|
||||
() => messages.filter((message) => !message.isDeleted),
|
||||
[messages]
|
||||
);
|
||||
const pinnedMessage = useMemo(
|
||||
() =>
|
||||
visibleMessages
|
||||
.filter((message) => message.isPinned)
|
||||
.sort((left, right) => new Date(right.pinnedAt ?? right.createdAt).getTime() - new Date(left.pinnedAt ?? left.createdAt).getTime())[0] ?? null,
|
||||
[visibleMessages]
|
||||
);
|
||||
|
||||
const {
|
||||
selectionMode,
|
||||
@@ -152,6 +161,17 @@ export function MiniFamilyChat() {
|
||||
userId: user?.id,
|
||||
token
|
||||
});
|
||||
const pinnedVisibleText = pinnedMessage
|
||||
? getE2EMessageText({
|
||||
message: pinnedMessage,
|
||||
isE2E: activeRoomIsE2E,
|
||||
peerE2eKey,
|
||||
peerKeyLoading,
|
||||
e2ePayload: pinnedMessage.isEncrypted ? e2ePayloads[pinnedMessage.id] : null,
|
||||
e2eError: e2eErrors[pinnedMessage.id]
|
||||
})
|
||||
: '';
|
||||
const pinnedPreview = pinnedMessage ? getMessagePreviewText(pinnedMessage, pinnedVisibleText) : '';
|
||||
|
||||
const loadRooms = useCallback(async () => {
|
||||
if (!group || !token) return;
|
||||
@@ -357,8 +377,21 @@ export function MiniFamilyChat() {
|
||||
}, [activeRoom, subscribe]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
autoScrollStateRef.current = { id: null, count: 0 };
|
||||
}, [activeRoom?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const lastMessage = visibleMessages[visibleMessages.length - 1];
|
||||
const previous = autoScrollStateRef.current;
|
||||
const next = { id: lastMessage?.id ?? null, count: visibleMessages.length };
|
||||
autoScrollStateRef.current = next;
|
||||
if (!next.id) return;
|
||||
const lastMessageChanged = previous.id !== next.id;
|
||||
const messageWasAppended = next.count >= previous.count;
|
||||
if (lastMessageChanged && messageWasAppended) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: previous.id ? 'smooth' : 'auto' });
|
||||
}
|
||||
}, [visibleMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRoom || !token || !messages.length) return;
|
||||
@@ -914,6 +947,18 @@ export function MiniFamilyChat() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{pinnedMessage ? (
|
||||
<ChatPinnedMessageBanner
|
||||
senderName={pinnedMessage.senderName}
|
||||
preview={pinnedPreview}
|
||||
className="px-3 py-2"
|
||||
onClick={() => {
|
||||
if (!scrollToChatMessage(pinnedMessage.id)) {
|
||||
showToast('Закреплённое сообщение пока не загружено в ленте');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn('flex-1 space-y-2 overflow-y-auto bg-[#eef1f6] p-3', isDragging && 'select-none')}
|
||||
style={{ maxHeight: 'min(50vh,380px)' }}
|
||||
@@ -978,12 +1023,6 @@ export function MiniFamilyChat() {
|
||||
)
|
||||
)}
|
||||
>
|
||||
{message.isPinned && !isLargeEmoji ? (
|
||||
<div className={cn('mb-1 flex items-center gap-1 text-[10px] font-medium', mine ? 'text-[#6c8f56]' : 'text-[#3390ec]')}>
|
||||
<Pin className="h-3 w-3" />
|
||||
Закреплено
|
||||
</div>
|
||||
) : null}
|
||||
{!mine && !isLargeEmoji ? (
|
||||
<p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{message.senderName}</p>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user