feat: realtime sync, settings UX and chat list improvements
Some checks failed
CI / test (push) Failing after 21s
Some checks failed
CI / test (push) Failing after 21s
- add chat_updated realtime event and dynamic chat subscriptions - auto-join invite links in web app - implement Telegram-like settings panel (general/notifications/privacy) - add browser notification preferences and keyboard send mode - improve chat list with last message preview/time and online badge - rework chat members UI with context actions and role crowns
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
|
||||
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
|
||||
import { globalSearch } from "../api/search";
|
||||
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
|
||||
import { updateMyProfile } from "../api/users";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { NewChatPanel } from "./NewChatPanel";
|
||||
import { SettingsPanel } from "./SettingsPanel";
|
||||
import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences";
|
||||
|
||||
export function ChatList() {
|
||||
const chats = useChatStore((s) => s.chats);
|
||||
const messagesByChat = useChatStore((s) => s.messagesByChat);
|
||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
||||
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
|
||||
@@ -28,6 +29,8 @@ export function ChatList() {
|
||||
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
|
||||
const [deleteForAll, setDeleteForAll] = useState(false);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [profileName, setProfileName] = useState("");
|
||||
const [profileUsername, setProfileUsername] = useState("");
|
||||
const [profileBio, setProfileBio] = useState("");
|
||||
@@ -119,6 +122,10 @@ export function ChatList() {
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyAppearancePreferences(getAppPreferences());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) {
|
||||
return;
|
||||
@@ -130,6 +137,12 @@ export function ChatList() {
|
||||
setProfileAllowPrivateMessages(me.allow_private_messages ?? true);
|
||||
}, [me]);
|
||||
|
||||
async function openSavedMessages() {
|
||||
const saved = await getSavedMessagesChat();
|
||||
const updatedChats = await getChats();
|
||||
useChatStore.setState({ chats: updatedChats, activeChatId: saved.id });
|
||||
}
|
||||
|
||||
const filteredChats = chats.filter((chat) => {
|
||||
if (chat.archived) {
|
||||
return false;
|
||||
@@ -157,10 +170,29 @@ export function ChatList() {
|
||||
const visibleChats = tab === "archived" ? archivedChats : filteredChats;
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-full w-full flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); }}>
|
||||
<aside className="relative flex h-full w-full flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); setMenuOpen(false); }}>
|
||||
<div className="border-b border-slate-700/50 px-3 py-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs">☰</button>
|
||||
<div className="relative mb-2 flex items-center gap-2">
|
||||
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs" onClick={() => setMenuOpen((v) => !v)}>☰</button>
|
||||
{menuOpen ? (
|
||||
<div className="absolute left-0 top-11 z-40 w-56 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl">
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setProfileOpen(true); setMenuOpen(false); }}>
|
||||
My Profile
|
||||
</button>
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { void openSavedMessages(); setMenuOpen(false); }}>
|
||||
Saved Messages
|
||||
</button>
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setTab("people"); setMenuOpen(false); }}>
|
||||
Contacts
|
||||
</button>
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setSettingsOpen(true); setMenuOpen(false); }}>
|
||||
Settings
|
||||
</button>
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => setMenuOpen(false)}>
|
||||
More
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="block flex-1">
|
||||
<input
|
||||
className="w-full rounded-full border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
||||
@@ -282,8 +314,13 @@ export function ChatList() {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
|
||||
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
|
||||
<div className="relative mt-0.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
|
||||
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
|
||||
</div>
|
||||
{chat.type === "private" && chat.counterpart_is_online ? (
|
||||
<span className="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-slate-900 bg-emerald-400" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -294,11 +331,11 @@ export function ChatList() {
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 text-[11px] text-slate-400">
|
||||
{messagesByChat[chat.id]?.length ? "now" : ""}
|
||||
{formatChatListTime(chat.last_message_created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-slate-400">{chatMetaLabel(chat)}</p>
|
||||
<p className="truncate text-xs text-slate-400">{chatPreviewLabel(chat)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -468,6 +505,7 @@ export function ChatList() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -520,6 +558,50 @@ function chatMetaLabel(chat: {
|
||||
return `${subscribers} subscribers`;
|
||||
}
|
||||
|
||||
function chatPreviewLabel(chat: {
|
||||
last_message_text?: string | null;
|
||||
last_message_type?: "text" | "image" | "video" | "audio" | "voice" | "file" | "circle_video" | null;
|
||||
type: "private" | "group" | "channel";
|
||||
is_saved?: boolean;
|
||||
counterpart_is_online?: boolean | null;
|
||||
counterpart_last_seen_at?: string | null;
|
||||
members_count?: number | null;
|
||||
online_count?: number | null;
|
||||
subscribers_count?: number | null;
|
||||
}): string {
|
||||
if (chat.last_message_text?.trim()) {
|
||||
return chat.last_message_text.trim();
|
||||
}
|
||||
if (chat.last_message_type) {
|
||||
if (chat.last_message_type === "image") return "Photo";
|
||||
if (chat.last_message_type === "video") return "Video";
|
||||
if (chat.last_message_type === "audio") return "Audio";
|
||||
if (chat.last_message_type === "voice") return "Voice message";
|
||||
if (chat.last_message_type === "file") return "File";
|
||||
if (chat.last_message_type === "circle_video") return "Video message";
|
||||
}
|
||||
return chatMetaLabel(chat);
|
||||
}
|
||||
|
||||
function formatChatListTime(value?: string | null): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
if (sameDay) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
|
||||
}
|
||||
|
||||
function formatLastSeen(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
|
||||
Reference in New Issue
Block a user