diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx index b9c6274..d649e0e 100644 --- a/web/src/components/ChatInfoPanel.tsx +++ b/web/src/components/ChatInfoPanel.tsx @@ -35,6 +35,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { const [chat, setChat] = useState(null); const [members, setMembers] = useState([]); const [memberUsers, setMemberUsers] = useState>({}); + const [counterpartProfile, setCounterpartProfile] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [titleDraft, setTitleDraft] = useState(""); @@ -116,16 +117,22 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { } if (detail.type === "private" && !detail.is_saved && detail.counterpart_user_id) { try { + const counterpart = await getUserById(detail.counterpart_user_id); + if (!cancelled) { + setCounterpartProfile(counterpart); + } const blocked = await listBlockedUsers(); if (!cancelled) { setCounterpartBlocked(blocked.some((u) => u.id === detail.counterpart_user_id)); } } catch { if (!cancelled) { + setCounterpartProfile(null); setCounterpartBlocked(false); } } } else if (!cancelled) { + setCounterpartProfile(null); setCounterpartBlocked(false); } await refreshMembers(chatId); @@ -204,6 +211,32 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { {chat ? ( <> +
+
+ {chat.type === "private" && counterpartProfile?.avatar_url ? ( + avatar + ) : ( +
+ {initialsFromName(chat.display_title || chat.title || counterpartProfile?.name || chat.type)} +
+ )} +
+

{chatLabel(chat)}

+

+ {chat.type === "private" + ? privateChatStatusLabel(chat) + : chat.type === "group" + ? `${chat.members_count ?? members.length} members, ${chat.online_count ?? 0} online` + : `${chat.subscribers_count ?? chat.members_count ?? members.length} subscribers`} +

+
+
+ {chat.type === "private" && counterpartProfile?.username ?

@{counterpartProfile.username}

: null} + {chat.type !== "private" && chat.handle ?

@{chat.handle}

: null} + {chat.type === "private" && counterpartProfile?.bio ?

{counterpartProfile.bio}

: null} + {chat.type !== "private" && chat.description ?

{chat.description}

: null} +
+

Notifications

@@ -226,37 +259,34 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {

{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}

-

Type

-

{chat.type}

-

Title

- setTitleDraft(e.target.value)} - /> {isGroupLike ? ( - + <> +

Title

+ setTitleDraft(e.target.value)} + /> + + ) : null} - {chat.handle ?

@{chat.handle}

: null} - {chat.description ?

{chat.description}

: null} {isGroupLike && canManageMembers ? (
))} @@ -539,6 +539,17 @@ function chatLabel(chat: { display_title?: string | null; title: string | null; return "Channel"; } +function chatDisplayNameById( + chats: Array<{ id: number; display_title?: string | null; title: string | null; type: "private" | "group" | "channel"; is_saved?: boolean }>, + chatId: number +): string { + const chat = chats.find((item) => item.id === chatId); + if (!chat) { + return "Unknown chat"; + } + return chatLabel(chat); +} + function chatMetaLabel(chat: { type: "private" | "group" | "channel"; is_saved?: boolean; diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 057bd39..bbd5b8b 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -10,6 +10,7 @@ import { import type { Message, MessageReaction } from "../chat/types"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; +import { useAudioPlayerStore } from "../store/audioPlayerStore"; import { useUiStore } from "../store/uiStore"; import { formatTime } from "../utils/format"; import { formatMessageHtml } from "../utils/formatMessage"; @@ -796,7 +797,7 @@ function renderMessageContent( 🎀 Voice message
- +
); } @@ -814,7 +815,7 @@ function renderMessageContent(

Audio file

- + ); } @@ -915,8 +916,12 @@ async function downloadFileFromUrl(url: string): Promise { window.URL.revokeObjectURL(blobUrl); } -function AudioInlinePlayer({ src }: { src: string }) { +function AudioInlinePlayer({ src, title }: { src: string; title: string }) { const audioRef = useRef(null); + const activate = useAudioPlayerStore((s) => s.activate); + const detach = useAudioPlayerStore((s) => s.detach); + const setPlayingState = useAudioPlayerStore((s) => s.setPlaying); + const setVolumeState = useAudioPlayerStore((s) => s.setVolume); const [isPlaying, setIsPlaying] = useState(false); const [duration, setDuration] = useState(0); const [position, setPosition] = useState(0); @@ -934,17 +939,30 @@ function AudioInlinePlayer({ src }: { src: string }) { }; const onEnded = () => { setIsPlaying(false); + setPlayingState(audio, false); + }; + const onPlay = () => { + activate(audio, { src, title }); + setPlayingState(audio, true); + }; + const onPause = () => { + setPlayingState(audio, false); }; audio.addEventListener("loadedmetadata", onLoaded); audio.addEventListener("timeupdate", onTime); audio.addEventListener("ended", onEnded); + audio.addEventListener("play", onPlay); + audio.addEventListener("pause", onPause); return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("timeupdate", onTime); audio.removeEventListener("ended", onEnded); + audio.removeEventListener("play", onPlay); + audio.removeEventListener("pause", onPause); + detach(audio); }; - }, []); + }, [activate, detach, setPlayingState, src, title]); async function togglePlay() { const audio = audioRef.current; @@ -955,6 +973,7 @@ function AudioInlinePlayer({ src }: { src: string }) { return; } try { + activate(audio, { src, title }); await audio.play(); setIsPlaying(true); } catch { @@ -974,6 +993,7 @@ function AudioInlinePlayer({ src }: { src: string }) { if (!audio) return; audio.volume = nextValue; setVolume(nextValue); + setVolumeState(audio, nextValue); } return ( diff --git a/web/src/index.css b/web/src/index.css index 1e3d5f9..a34aa9a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -58,19 +58,19 @@ body { } html[data-theme="light"] { - --bm-bg-primary: #eef3fb; - --bm-bg-secondary: #f5f8fd; - --bm-bg-tertiary: #ffffff; + --bm-bg-primary: #eaf1fb; + --bm-bg-secondary: #f3f7fd; + --bm-bg-tertiary: #fbfdff; --bm-text-color: #0f172a; - --bm-panel-bg: rgba(255, 255, 255, 0.93); - --bm-panel-border: rgba(15, 23, 42, 0.12); + --bm-panel-bg: rgba(255, 255, 255, 0.96); + --bm-panel-border: rgba(15, 23, 42, 0.14); } html[data-theme="light"] .tg-chat-wallpaper { background: - radial-gradient(circle at 14% 18%, rgba(59, 130, 246, 0.08), transparent 30%), - radial-gradient(circle at 86% 74%, rgba(34, 197, 94, 0.06), transparent 33%), - linear-gradient(160deg, rgba(15, 23, 42, 0.01) 0%, rgba(15, 23, 42, 0.02) 100%); + radial-gradient(circle at 14% 18%, rgba(59, 130, 246, 0.09), transparent 32%), + radial-gradient(circle at 86% 74%, rgba(14, 165, 233, 0.06), transparent 35%), + linear-gradient(160deg, rgba(148, 163, 184, 0.08) 0%, rgba(148, 163, 184, 0.03) 100%); } html[data-theme="light"] .bg-slate-900\/95, @@ -78,12 +78,22 @@ html[data-theme="light"] .bg-slate-900\/90, html[data-theme="light"] .bg-slate-900\/80, html[data-theme="light"] .bg-slate-900\/70, html[data-theme="light"] .bg-slate-900\/60, -html[data-theme="light"] .bg-slate-900, +html[data-theme="light"] .bg-slate-900 { + background-color: rgba(255, 255, 255, 0.97) !important; +} + html[data-theme="light"] .bg-slate-800\/80, html[data-theme="light"] .bg-slate-800\/70, html[data-theme="light"] .bg-slate-800\/60, html[data-theme="light"] .bg-slate-800 { - background-color: rgba(255, 255, 255, 0.92) !important; + background-color: rgba(241, 245, 249, 0.95) !important; +} + +html[data-theme="light"] .bg-slate-700\/80, +html[data-theme="light"] .bg-slate-700\/70, +html[data-theme="light"] .bg-slate-700\/60, +html[data-theme="light"] .bg-slate-700 { + background-color: rgba(226, 232, 240, 0.95) !important; } html[data-theme="light"] .border-slate-700\/80, @@ -91,12 +101,147 @@ html[data-theme="light"] .border-slate-700\/70, html[data-theme="light"] .border-slate-700\/60, html[data-theme="light"] .border-slate-700\/50, html[data-theme="light"] .border-slate-700 { - border-color: rgba(15, 23, 42, 0.14) !important; + border-color: rgba(71, 85, 105, 0.28) !important; +} + +html[data-theme="light"] .text-slate-100 { + color: #0f172a !important; +} + +html[data-theme="light"] .text-slate-200 { + color: #1e293b !important; +} + +html[data-theme="light"] .text-slate-300 { + color: #334155 !important; +} + +html[data-theme="light"] .text-slate-400 { + color: #64748b !important; +} + +html[data-theme="light"] .text-slate-500 { + color: #94a3b8 !important; +} + +html[data-theme="light"] .hover\:bg-slate-800\/70:hover, +html[data-theme="light"] .hover\:bg-slate-800\/65:hover, +html[data-theme="light"] .hover\:bg-slate-800\/60:hover, +html[data-theme="light"] .hover\:bg-slate-800:hover { + background-color: rgba(226, 232, 240, 0.95) !important; +} + +html[data-theme="light"] .hover\:bg-slate-700\/80:hover, +html[data-theme="light"] .hover\:bg-slate-700:hover { + background-color: rgba(203, 213, 225, 0.9) !important; +} + +html[data-theme="light"] .tg-panel { + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08); +} + +html[data-theme="light"] .bg-slate-950\/55, +html[data-theme="light"] .bg-slate-950\/60, +html[data-theme="light"] .bg-slate-950\/90 { + background-color: rgba(15, 23, 42, 0.28) !important; +} + +html[data-theme="light"] input, +html[data-theme="light"] textarea, +html[data-theme="light"] select { + color: #0f172a; +} + +html[data-theme="light"] input::placeholder, +html[data-theme="light"] textarea::placeholder { + color: #64748b; +} + +html[data-theme="light"] .text-slate-950, +html[data-theme="light"] .text-black { + color: #0f172a !important; +} + +html[data-theme="light"] .bg-sky-500\/30 { + background-color: rgba(14, 165, 233, 0.2) !important; +} + +html[data-theme="light"] .text-sky-100 { + color: #0369a1 !important; +} + +html[data-theme="light"] .text-sky-300 { + color: #075985 !important; +} + +html[data-theme="light"] .text-red-400, +html[data-theme="light"] .text-red-300 { + color: #b91c1c !important; +} + +html[data-theme="light"] .text-amber-300 { + color: #a16207 !important; +} + +html[data-theme="light"] .text-emerald-400 { + color: #047857 !important; +} + +html[data-theme="light"] .border-slate-800\/60, +html[data-theme="light"] .border-slate-800, +html[data-theme="light"] .border-slate-900 { + border-color: rgba(71, 85, 105, 0.2) !important; +} + +html[data-theme="light"] .bg-slate-900\/50 { + background-color: rgba(248, 250, 252, 0.95) !important; +} + +html[data-theme="light"] .bg-slate-800\/50 { + background-color: rgba(241, 245, 249, 0.88) !important; +} + +html[data-theme="light"] .text-slate-600 { + color: #475569 !important; +} + +html[data-theme="light"] .text-slate-700 { + color: #334155 !important; +} + +html[data-theme="light"] .text-slate-800 { + color: #1e293b !important; +} + +html[data-theme="light"] .text-slate-900 { + color: #0f172a !important; +} + +html[data-theme="light"] .bg-white { + background-color: #ffffff !important; +} + +html[data-theme="light"] .bg-slate-100 { + background-color: #f1f5f9 !important; +} + +html[data-theme="light"] .bg-slate-200 { + background-color: #e2e8f0 !important; +} + +html[data-theme="light"] .border-slate-600, +html[data-theme="light"] .border-slate-500 { + border-color: rgba(100, 116, 139, 0.35) !important; +} + +html[data-theme="light"] .ring-slate-700, +html[data-theme="light"] .ring-slate-600 { + --tw-ring-color: rgba(100, 116, 139, 0.35) !important; } html[data-theme="light"] .text-slate-100, html[data-theme="light"] .text-slate-200, html[data-theme="light"] .text-slate-300, html[data-theme="light"] .text-slate-400 { - color: #334155 !important; + text-shadow: none; } diff --git a/web/src/pages/ChatsPage.tsx b/web/src/pages/ChatsPage.tsx index 4a9b960..dac0971 100644 --- a/web/src/pages/ChatsPage.tsx +++ b/web/src/pages/ChatsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { ChatList } from "../components/ChatList"; import { ChatInfoPanel } from "../components/ChatInfoPanel"; import { MessageComposer } from "../components/MessageComposer"; @@ -8,6 +8,7 @@ import { searchMessages } from "../api/chats"; import type { Message } from "../chat/types"; import { useRealtime } from "../hooks/useRealtime"; import { useAuthStore } from "../store/authStore"; +import { useAudioPlayerStore } from "../store/audioPlayerStore"; import { useChatStore } from "../store/chatStore"; import { useState } from "react"; @@ -27,9 +28,17 @@ export function ChatsPage() { const [searchQuery, setSearchQuery] = useState(""); const [searchLoading, setSearchLoading] = useState(false); const [searchResults, setSearchResults] = useState([]); + const [searchActiveIndex, setSearchActiveIndex] = useState(0); const [notificationsOpen, setNotificationsOpen] = useState(false); const [notifications, setNotifications] = useState([]); const [loadingNotifications, setLoadingNotifications] = useState(false); + const searchInputRef = useRef(null); + const activeTrack = useAudioPlayerStore((s) => s.track); + const isAudioPlaying = useAudioPlayerStore((s) => s.isPlaying); + const audioVolume = useAudioPlayerStore((s) => s.volume); + const toggleAudioPlay = useAudioPlayerStore((s) => s.togglePlay); + const stopAudio = useAudioPlayerStore((s) => s.stop); + const audioEl = useAudioPlayerStore((s) => s.audioEl); useRealtime(); @@ -76,6 +85,36 @@ export function ChatsPage() { }; }, [searchOpen, searchQuery, activeChatId]); + useEffect(() => { + if (!searchOpen) { + return; + } + const timer = window.setTimeout(() => searchInputRef.current?.focus(), 30); + return () => window.clearTimeout(timer); + }, [searchOpen]); + + useEffect(() => { + if (!searchOpen) { + return; + } + if (searchResults.length === 0) { + setSearchActiveIndex(0); + return; + } + setSearchActiveIndex((prev) => Math.min(prev, searchResults.length - 1)); + }, [searchResults, searchOpen]); + + useEffect(() => { + if (!searchOpen || searchResults.length === 0) { + return; + } + const current = searchResults[searchActiveIndex]; + if (!current) { + return; + } + setFocusedMessage(current.chat_id, current.id); + }, [searchOpen, searchActiveIndex, searchResults, setFocusedMessage]); + useEffect(() => { if (!notificationsOpen) { return; @@ -112,38 +151,125 @@ export function ChatsPage() {
-
- - {activeChatId ? ( - + {activeChatId ? ( + + ) : null} +
+

{activeChat?.display_title || activeChat?.title || me?.name || `@${me?.username}`}

+

{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}

+
+
+
+ + + +
+ + ) : ( +
+ setSearchQuery(e.target.value)} + /> + + {searchLoading + ? "..." + : searchResults.length > 0 + ? `${searchActiveIndex + 1}/${searchResults.length}` + : "0/0"} + + - ) : null} + + +
+ )} +
+ {activeTrack ? ( +
-

{activeChat?.display_title || activeChat?.title || me?.name || `@${me?.username}`}

-

{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}

+

{activeTrack.title}

+
+
+ + + + πŸ”Š {Math.round(audioVolume * 100)}% +
-
- - - -
- + ) : null}
@@ -161,46 +287,6 @@ export function ChatsPage() {
setInfoOpen(false)} /> - {searchOpen ? ( -
setSearchOpen(false)}> -
e.stopPropagation()}> -
-

Search messages

- -
- setSearchQuery(e.target.value)} - /> - {searchLoading ?

Searching...

: null} - {!searchLoading && searchQuery.trim().length >= 2 && searchResults.length === 0 ? ( -

Nothing found

- ) : null} -
- {searchResults.map((message) => { - const chatMeta = chats.find((chat) => chat.id === message.chat_id); - const chatLabel = chatMeta?.public_id ?? String(message.chat_id); - return ( - - ); - })} -
-
-
- ) : null} {notificationsOpen ? (
setNotificationsOpen(false)}>
e.stopPropagation()}> diff --git a/web/src/store/audioPlayerStore.ts b/web/src/store/audioPlayerStore.ts new file mode 100644 index 0000000..54c97f0 --- /dev/null +++ b/web/src/store/audioPlayerStore.ts @@ -0,0 +1,74 @@ +import { create } from "zustand"; + +interface ActiveTrack { + src: string; + title: string; +} + +interface AudioPlayerState { + track: ActiveTrack | null; + audioEl: HTMLAudioElement | null; + isPlaying: boolean; + volume: number; + activate: (audioEl: HTMLAudioElement, track: ActiveTrack) => void; + detach: (audioEl: HTMLAudioElement) => void; + setPlaying: (audioEl: HTMLAudioElement, isPlaying: boolean) => void; + setVolume: (audioEl: HTMLAudioElement, volume: number) => void; + togglePlay: () => Promise; + stop: () => void; +} + +export const useAudioPlayerStore = create((set, get) => ({ + track: null, + audioEl: null, + isPlaying: false, + volume: 1, + activate: (audioEl, track) => { + set({ + audioEl, + track, + isPlaying: !audioEl.paused, + volume: audioEl.volume ?? 1, + }); + }, + detach: (audioEl) => { + const current = get().audioEl; + if (current !== audioEl) { + return; + } + set({ audioEl: null, track: null, isPlaying: false }); + }, + setPlaying: (audioEl, isPlaying) => { + if (get().audioEl !== audioEl) { + return; + } + set({ isPlaying }); + }, + setVolume: (audioEl, volume) => { + if (get().audioEl !== audioEl) { + return; + } + set({ volume }); + }, + togglePlay: async () => { + const audio = get().audioEl; + if (!audio) { + return; + } + if (audio.paused) { + await audio.play(); + set({ isPlaying: true }); + return; + } + audio.pause(); + set({ isPlaying: false }); + }, + stop: () => { + const audio = get().audioEl; + if (audio) { + audio.pause(); + } + set({ audioEl: null, track: null, isPlaying: false }); + }, +})); + diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index a1ce7c9..4fab97e 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/ws.ts"],"version":"5.9.2"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/ws.ts"],"version":"5.9.2"} \ No newline at end of file