diff --git a/server/internal/platform/metrics/metrics.go b/server/internal/platform/metrics/metrics.go index 5e973cb..c8ac9df 100644 --- a/server/internal/platform/metrics/metrics.go +++ b/server/internal/platform/metrics/metrics.go @@ -3,6 +3,9 @@ package metrics import ( + "bufio" + "errors" + "net" "net/http" "strconv" "strings" @@ -82,6 +85,23 @@ func (r *recorder) WriteHeader(code int) { r.ResponseWriter.WriteHeader(code) } +// Hijack forwards the WebSocket upgrade to the underlying writer. Without +// this, wrapping the mux breaks every WebSocket handshake. +func (r *recorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hj, ok := r.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, errors.New("metrics: response writer does not support hijacking") + } + return hj.Hijack() +} + +// Flush forwards periodic flush calls for streaming responses. +func (r *recorder) Flush() { + if f, ok := r.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + // NormalizePath replaces numeric path segments with a placeholder so that // per-route cardinality stays bounded regardless of resource IDs. func NormalizePath(p string) string { diff --git a/server/internal/platform/metrics/metrics_test.go b/server/internal/platform/metrics/metrics_test.go new file mode 100644 index 0000000..84020a8 --- /dev/null +++ b/server/internal/platform/metrics/metrics_test.go @@ -0,0 +1,30 @@ +package metrics + +import ( + "bufio" + "net" + "net/http" + "net/http/httptest" + "testing" +) + +type hijackRecorder struct { + *httptest.ResponseRecorder +} + +func (h *hijackRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return nil, nil, nil +} + +func TestMiddlewarePreservesHijacker(t *testing.T) { + var seen http.ResponseWriter + handler := Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = w + })) + + handler.ServeHTTP(&hijackRecorder{httptest.NewRecorder()}, httptest.NewRequest(http.MethodGet, "/", nil)) + + if _, ok := seen.(http.Hijacker); !ok { + t.Fatal("middleware stripped http.Hijacker: WebSocket upgrades will fail") + } +} diff --git a/web/src/components/ChatWindow.tsx b/web/src/components/ChatWindow.tsx index cbfffd0..1596577 100644 --- a/web/src/components/ChatWindow.tsx +++ b/web/src/components/ChatWindow.tsx @@ -28,7 +28,7 @@ interface ChatWindowProps { onRetry: (message: ClientMessage) => void onReact: (message: ClientMessage, emoji: string) => void onMembersChanged: () => void - onMembersLoaded: (members: Member[]) => void + onMembersLoaded: (conversationId: number, members: Member[]) => void } function TypingDots() { @@ -251,21 +251,21 @@ export default function ChatWindow({ onMembersLoaded, }: ChatWindowProps) { const [members, setMembers] = useState([]) - const [membersLoading, setMembersLoading] = useState(conversation?.type === 'group') + const [membersLoading, setMembersLoading] = useState(conversation !== null) const [membersOpen, setMembersOpen] = useState(false) const conversationId = conversation?.id ?? null const isGroup = conversation?.type === 'group' useEffect(() => { - if (conversationId === null || !isGroup) return + if (conversationId === null) return let cancelled = false api .listMembers(conversationId) .then((list) => { if (cancelled) return setMembers(list) - onMembersLoaded(list) + onMembersLoaded(conversationId, list) }) .catch(() => { if (!cancelled) setMembers([]) @@ -276,7 +276,7 @@ export default function ChatWindow({ return () => { cancelled = true } - }, [conversationId, isGroup, membersVersion, onMembersLoaded]) + }, [conversationId, membersVersion, onMembersLoaded]) if (!conversation) { return ( diff --git a/web/src/components/MessageBubble.tsx b/web/src/components/MessageBubble.tsx index 4d1bec5..5fe0e37 100644 --- a/web/src/components/MessageBubble.tsx +++ b/web/src/components/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import Avatar from './Avatar' import { IconAlert, IconCheck, IconCheckCheck, IconFile, IconSmile } from './icons' import { formatBytes, formatTime, userTitle } from '../lib/format' @@ -78,6 +78,29 @@ export default function MessageBubble({ const isSending = message.status === 'sending' const isError = message.status === 'error' const accent = isOwn ? OWN_COLOR : PEER_COLOR + const [pickerOpen, setPickerOpen] = useState(false) + const panelRef = useRef(null) + const buttonRef = useRef(null) + + useEffect(() => { + if (!pickerOpen) return + const onPointerDown = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node + if (panelRef.current?.contains(target) || buttonRef.current?.contains(target)) return + setPickerOpen(false) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setPickerOpen(false) + } + document.addEventListener('mousedown', onPointerDown) + document.addEventListener('touchstart', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('mousedown', onPointerDown) + document.removeEventListener('touchstart', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [pickerOpen]) const grouped = useMemo(() => { const map = new Map() @@ -104,31 +127,7 @@ export default function MessageBubble({ ))}
-
- - -
+
{showName && ( @@ -156,6 +155,17 @@ export default function MessageBubble({ {!isSending && !isError && !message.read_by_peer && } )} +
@@ -174,7 +184,33 @@ export default function MessageBubble({ {message.edited_at && изменено}
-
+ + {pickerOpen && ( +
+ {QUICK_EMOJIS.map((emoji) => ( + + ))} +
+ )} {grouped.length > 0 && (
diff --git a/web/src/screens/ChatApp.tsx b/web/src/screens/ChatApp.tsx index 6ec93a9..e035402 100644 --- a/web/src/screens/ChatApp.tsx +++ b/web/src/screens/ChatApp.tsx @@ -46,6 +46,16 @@ export default function ChatApp({ user }: ChatAppProps) { const selectedIdRef = useRef(selectedId) const conversationsRef = useRef(conversations) const typingTimers = useRef(new Map()) + const membersLoadedRef = useRef(new Set()) + const membersInFlightRef = useRef(new Set()) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) useEffect(() => { selectedIdRef.current = selectedId @@ -81,6 +91,35 @@ export default function ChatApp({ user }: ChatAppProps) { void loadContacts() }, [loadContacts]) + useEffect(() => { + for (const conversation of conversations) { + if (membersLoadedRef.current.has(conversation.id) || membersInFlightRef.current.has(conversation.id)) { + continue + } + membersInFlightRef.current.add(conversation.id) + api + .listMembers(conversation.id) + .then((members) => { + membersLoadedRef.current.add(conversation.id) + if (!mountedRef.current) return + members.forEach((member) => rememberUser(member.user)) + if (conversation.type !== 'group') { + const others = members.filter((member) => member.user.id !== user.id) + if (others.length === 1) { + const peerId = others[0].user.id + setLearnedPeers((prev) => + prev[conversation.id] === peerId ? prev : { ...prev, [conversation.id]: peerId }, + ) + } + } + }) + .catch(() => {}) + .finally(() => { + membersInFlightRef.current.delete(conversation.id) + }) + } + }, [conversations, rememberUser, user.id]) + useEffect(() => { void ensurePushSubscription() }, []) @@ -323,10 +362,21 @@ export default function ChatApp({ user }: ChatAppProps) { ) const handleMembersLoaded = useCallback( - (members: Member[]) => { + (conversationId: number, members: Member[]) => { + membersLoadedRef.current.add(conversationId) members.forEach((member) => rememberUser(member.user)) + const conversation = conversationsRef.current.find((item) => item.id === conversationId) + if (conversation && conversation.type !== 'group') { + const others = members.filter((member) => member.user.id !== user.id) + if (others.length === 1) { + const peerId = others[0].user.id + setLearnedPeers((prev) => + prev[conversationId] === peerId ? prev : { ...prev, [conversationId]: peerId }, + ) + } + } }, - [rememberUser], + [rememberUser, user.id], ) const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null