mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement live last message update
This commit is contained in:
@@ -1,11 +1,21 @@
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { useAppState, type ChatTabs } from "@/pages/chat/state";
|
||||
import { UnifiedChatsList } from "./UnifiedChatsList";
|
||||
import type { FormEvent } from "react";
|
||||
import type { Tabs } from "mdui/components/tabs";
|
||||
|
||||
export function ChatTabs() {
|
||||
const { chat, setActiveTab, switchToPublicChat } = useAppState();
|
||||
const { chat, setActiveTab } = useAppState();
|
||||
|
||||
function handleChange(e: FormEvent<Tabs> & CustomEvent<{ value: string }>) {
|
||||
setActiveTab(e.detail.value as ChatTabs);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
<mdui-tabs value={chat.activeTab} full-width onChange={(e: any) => setActiveTab(e.detail.value)}>
|
||||
<mdui-tabs
|
||||
value={chat.activeTab}
|
||||
full-width
|
||||
onChange={handleChange}>
|
||||
<mdui-tab value="chats">
|
||||
Чаты
|
||||
</mdui-tab>
|
||||
@@ -15,37 +25,12 @@ export function ChatTabs() {
|
||||
<mdui-tab value="contacts">
|
||||
Контакты
|
||||
</mdui-tab>
|
||||
<mdui-tab value="dms">
|
||||
ЛС
|
||||
</mdui-tab>
|
||||
|
||||
<mdui-tab-panel slot="panel" value="chats">
|
||||
<mdui-list>
|
||||
<mdui-list-item
|
||||
headline="Общий чат"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-1"
|
||||
onClick={async () => await switchToPublicChat("Общий чат")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
headline="Общий чат 2"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-2"
|
||||
onClick={async () => await switchToPublicChat("Общий чат 2")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
<UnifiedChatsList />
|
||||
</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="dms">
|
||||
<mdui-list id="dm-users"></mdui-list>
|
||||
</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { fetchUserPublicKey } from "@/core/api/dmApi";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
|
||||
export function DMUsersList() {
|
||||
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
|
||||
const { chat, switchToDM } = useAppState();
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.activeTab === "chats") {
|
||||
loadUsers();
|
||||
}
|
||||
}, [chat.activeTab, loadUsers]);
|
||||
|
||||
if (isLoadingUsers) {
|
||||
return (
|
||||
<mdui-circular-progress />
|
||||
);
|
||||
}
|
||||
|
||||
async function handleUserClick(user: DMUser) {
|
||||
if (!user.publicKey) {
|
||||
// Get public key if not already loaded
|
||||
const authToken = useAppState.getState().user.authToken;
|
||||
if (!authToken) return;
|
||||
|
||||
const publicKey = await fetchUserPublicKey(user.id, authToken);
|
||||
if (publicKey) {
|
||||
user.publicKey = publicKey;
|
||||
} else {
|
||||
console.error("Failed to get public key for user:", user.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await switchToDM({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
publicKey: user.publicKey,
|
||||
profilePicture: user.profile_picture,
|
||||
online: user.online || false
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<mdui-list>
|
||||
{dmUsers.map((user: DMUser) => (
|
||||
<mdui-list-item
|
||||
key={user.id}
|
||||
headline={user.username}
|
||||
description={user.lastMessage || "Нет сообщений"}
|
||||
onClick={() => handleUserClick(user)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img
|
||||
src={user.profile_picture || defaultAvatar}
|
||||
alt={user.username}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
{user.unreadCount > 0 && (
|
||||
<mdui-badge slot="end-icon">
|
||||
{user.unreadCount}
|
||||
</mdui-badge>
|
||||
)}
|
||||
</mdui-list-item>
|
||||
))}
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import { PRODUCT_NAME } from "@/core/config";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ProfileDialog } from "./profile/ProfileDialog";
|
||||
import { useState } from "react";
|
||||
import { SettingsDialog } from "./settings/SettingsDialog";
|
||||
import { DMUsersList } from "./DMUsersList";
|
||||
import { UsernameSearch } from "./UsernameSearch";
|
||||
import type { Tabs } from "mdui";
|
||||
import type { ChatTabs } from "@/pages/chat/state";
|
||||
import { ChatTabs } from "./ChatTabs";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
|
||||
function BottomAppBar() {
|
||||
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
||||
@@ -36,75 +32,6 @@ function BottomAppBar() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ChatTabs() {
|
||||
const { chat, setActiveTab, switchToPublicChat } = useAppState();
|
||||
const { activeTab } = chat;
|
||||
|
||||
async function handleChatClick(chatName: string) {
|
||||
await switchToPublicChat(chatName);
|
||||
}
|
||||
|
||||
function handleTabChange(e: FormEvent<Tabs>) {
|
||||
setActiveTab((e.target as Tabs).value as ChatTabs);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
<mdui-tabs value={activeTab} full-width onChange={handleTabChange}>
|
||||
<mdui-tab value="chats">Чаты</mdui-tab>
|
||||
<mdui-tab value="channels">Каналы</mdui-tab>
|
||||
<mdui-tab value="contacts">Контакты</mdui-tab>
|
||||
|
||||
<mdui-tab-panel slot="panel" value="chats">
|
||||
<mdui-list>
|
||||
<mdui-list-item
|
||||
headline="Общий чат"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-1"
|
||||
onClick={() => handleChatClick("Общий чат")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
headline="Общий чат 2"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-2"
|
||||
onClick={() => handleChatClick("Общий чат 2")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
|
||||
{/* DM conversations will be loaded here */}
|
||||
<DMUsersList />
|
||||
</mdui-list>
|
||||
</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ChatHeader() {
|
||||
const [isProfileOpen, setProfileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="chat-header-left">
|
||||
<div className="product-name">{PRODUCT_NAME}</div>
|
||||
<div className="profile">
|
||||
<a href="#" id="profile-open" onClick={() => setProfileOpen(true)}>
|
||||
<img src={defaultAvatar} alt="" id="preview1" />
|
||||
</a>
|
||||
</div>
|
||||
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setProfileOpen} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeftPanel() {
|
||||
return (
|
||||
<div className="chat-list" id="chat-list">
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "@/core/api/authApi";
|
||||
import { fetchUserPublicKey } from "@/core/api/dmApi";
|
||||
import type { Message } from "@/core/types";
|
||||
import { websocket } from "@/core/websocket";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
|
||||
interface PublicChat {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "public";
|
||||
lastMessage?: Message;
|
||||
}
|
||||
|
||||
interface DMConversation {
|
||||
id: number;
|
||||
username: string;
|
||||
profile_picture?: string;
|
||||
online?: boolean;
|
||||
type: "dm";
|
||||
lastMessage?: string;
|
||||
unreadCount: number;
|
||||
publicKey?: string | null;
|
||||
}
|
||||
|
||||
type ChatItem = PublicChat | DMConversation;
|
||||
|
||||
export function UnifiedChatsList() {
|
||||
const { user, switchToPublicChat, switchToDM, chat } = useAppState();
|
||||
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
|
||||
|
||||
const [publicChats] = useState<PublicChat[]>([
|
||||
{ id: "general", name: "Общий чат", type: "public" },
|
||||
{ id: "general2", name: "Общий чат 2", type: "public" }
|
||||
]);
|
||||
const [lastMessages, setLastMessages] = useState<Record<string, Message | undefined>>({});
|
||||
const [allChats, setAllChats] = useState<ChatItem[]>([]);
|
||||
|
||||
// Load public chat last messages
|
||||
const loadLastMessages = useCallback(async () => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/get_messages`, {
|
||||
headers: getAuthHeaders(user.authToken)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
const lastMessage = data.messages[data.messages.length - 1];
|
||||
|
||||
setLastMessages({
|
||||
general: lastMessage,
|
||||
general2: lastMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading last messages:", error);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Load DM users when chats tab is active
|
||||
useEffect(() => {
|
||||
if (chat.activeTab === "chats") {
|
||||
loadUsers();
|
||||
loadLastMessages();
|
||||
}
|
||||
}, [chat.activeTab, loadUsers, loadLastMessages]);
|
||||
|
||||
// Combine public chats and DMs into one list
|
||||
useEffect(() => {
|
||||
const publicChatItems: ChatItem[] = publicChats.map(chat => ({
|
||||
...chat,
|
||||
lastMessage: lastMessages[chat.id]
|
||||
}));
|
||||
|
||||
const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
profile_picture: user.profile_picture,
|
||||
online: user.online,
|
||||
type: "dm" as const,
|
||||
lastMessage: user.lastMessage,
|
||||
unreadCount: user.unreadCount,
|
||||
publicKey: user.publicKey
|
||||
}));
|
||||
|
||||
// Combine and sort by last message timestamp (DMs first, then public chats)
|
||||
const combined = [...dmChatItems, ...publicChatItems];
|
||||
setAllChats(combined);
|
||||
}, [publicChats, lastMessages, dmUsers]);
|
||||
|
||||
// WebSocket listener for public chat message updates
|
||||
useEffect(() => {
|
||||
if (!websocket) return;
|
||||
|
||||
const handleWebSocketMessage = (e: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
if (msg.type === "newMessage") {
|
||||
const newMessage = msg.data as Message;
|
||||
// Update all public chats with the new message
|
||||
setLastMessages(prev => {
|
||||
const updated = { ...prev };
|
||||
publicChats.forEach(chat => {
|
||||
updated[chat.id] = newMessage;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
} else if (msg.type === "messageEdited") {
|
||||
const editedMessage = msg.data as Message;
|
||||
// Update only if the edited message is the current last message
|
||||
setLastMessages(prev => {
|
||||
const updated = { ...prev };
|
||||
publicChats.forEach(chat => {
|
||||
if (updated[chat.id]?.id === editedMessage.id) {
|
||||
updated[chat.id] = editedMessage;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
} else if (msg.type === "messageDeleted") {
|
||||
const deletedMessageId = msg.data?.message_id;
|
||||
let needsReload = false;
|
||||
|
||||
setLastMessages(prev => {
|
||||
const updated = { ...prev };
|
||||
publicChats.forEach(chat => {
|
||||
if (updated[chat.id]?.id === deletedMessageId) {
|
||||
updated[chat.id] = undefined;
|
||||
needsReload = true;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (needsReload) {
|
||||
loadLastMessages();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to handle WebSocket message in UnifiedChatsList:", error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener("message", handleWebSocketMessage);
|
||||
return () => websocket.removeEventListener("message", handleWebSocketMessage);
|
||||
}, [publicChats, loadLastMessages]);
|
||||
|
||||
const formatPublicChatMessage = (chatId: string): string => {
|
||||
const lastMessage = lastMessages[chatId];
|
||||
if (!lastMessage) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const isCurrentUser = lastMessage.username === user.currentUser?.username;
|
||||
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
|
||||
|
||||
const maxContentLength = 50 - prefix.length;
|
||||
const content = lastMessage.content.length > maxContentLength
|
||||
? lastMessage.content.substring(0, maxContentLength) + "..."
|
||||
: lastMessage.content;
|
||||
|
||||
return prefix + content;
|
||||
};
|
||||
|
||||
|
||||
const handlePublicChatClick = async (chatName: string) => {
|
||||
await switchToPublicChat(chatName);
|
||||
};
|
||||
|
||||
const handleDMClick = async (dmConversation: DMConversation) => {
|
||||
if (!dmConversation.publicKey) {
|
||||
const authToken = useAppState.getState().user.authToken;
|
||||
if (!authToken) return;
|
||||
|
||||
const publicKey = await fetchUserPublicKey(dmConversation.id, authToken);
|
||||
if (publicKey) {
|
||||
dmConversation.publicKey = publicKey;
|
||||
} else {
|
||||
console.error("Failed to get public key for user:", dmConversation.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await switchToDM({
|
||||
userId: dmConversation.id,
|
||||
username: dmConversation.username,
|
||||
publicKey: dmConversation.publicKey,
|
||||
profilePicture: dmConversation.profile_picture,
|
||||
online: dmConversation.online || false
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingUsers) {
|
||||
return (
|
||||
<mdui-circular-progress />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<mdui-list>
|
||||
{allChats.map((chat) => {
|
||||
if (chat.type === "public") {
|
||||
return (
|
||||
<mdui-list-item
|
||||
key={`public-${chat.id}`}
|
||||
headline={chat.name}
|
||||
onClick={() => handlePublicChatClick(chat.name)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{formatPublicChatMessage(chat.id) && (
|
||||
<span slot="description" className="list-description">
|
||||
{formatPublicChatMessage(chat.id)}
|
||||
</span>
|
||||
)}
|
||||
<img
|
||||
src={defaultAvatar}
|
||||
alt={chat.name}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
/>
|
||||
</mdui-list-item>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<mdui-list-item
|
||||
key={`dm-${chat.id}`}
|
||||
headline={chat.username}
|
||||
onClick={() => handleDMClick(chat)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<span slot="description" className="list-description">
|
||||
{chat.lastMessage || "Нет сообщений"}
|
||||
</span>
|
||||
<img
|
||||
src={chat.profile_picture || defaultAvatar}
|
||||
alt={chat.username}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
{chat.unreadCount > 0 && (
|
||||
<mdui-badge slot="end-icon">
|
||||
{chat.unreadCount}
|
||||
</mdui-badge>
|
||||
)}
|
||||
</mdui-list-item>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
@@ -130,10 +130,12 @@ export function UsernameSearch() {
|
||||
<mdui-list-item
|
||||
key={searchUser.id}
|
||||
headline={searchUser.username}
|
||||
description={searchUser.online ? "В сети" : "Не в сети"}
|
||||
onClick={() => handleUserClick(searchUser)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<span slot="description" className="list-description">
|
||||
{searchUser.online ? "В сети" : "Не в сети"}
|
||||
</span>
|
||||
<img
|
||||
src={searchUser.profile_picture || defaultAvatar}
|
||||
alt={searchUser.username}
|
||||
|
||||
@@ -154,7 +154,10 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
isAuthor={message.username === user.currentUser?.username}
|
||||
isAuthor={isDm ?
|
||||
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||
(message.username === user.currentUser?.username)
|
||||
}
|
||||
onProfileClick={handleProfileClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReactionClick={handleReactionClick}
|
||||
@@ -189,7 +192,10 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
{contextMenu.message && (
|
||||
<MessageContextMenu
|
||||
message={contextMenu.message}
|
||||
isAuthor={contextMenu.message.username === user.currentUser?.username}
|
||||
isAuthor={isDm ?
|
||||
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||
(contextMenu.message.username === user.currentUser?.username)
|
||||
}
|
||||
onEdit={handleEdit}
|
||||
onReply={handleReply}
|
||||
onDelete={handleDelete}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/core/api/dmApi";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||
import type { UserState } from "@/pages/chat/state";
|
||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||
|
||||
export interface DMPanelData {
|
||||
userId: number;
|
||||
@@ -48,8 +49,12 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||
const plaintext = await decryptDm(env, this.dmData!.publicKey);
|
||||
const isAuthor = env.senderId !== this.dmData!.userId;
|
||||
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username;
|
||||
const username = formatDMUsername(
|
||||
env.senderId,
|
||||
env.recipientId,
|
||||
this.currentUser.currentUser?.id!,
|
||||
this.dmData!.username
|
||||
);
|
||||
|
||||
// Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } }
|
||||
let content = plaintext;
|
||||
@@ -168,6 +173,7 @@ export class DMPanel extends MessagePanel {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Handle incoming WebSocket DM messages
|
||||
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
|
||||
if (response.type === "dmNew" && this.dmData) {
|
||||
|
||||
Reference in New Issue
Block a user