import { ArrowLeft, CaretRight, Funnel, Hash, MagnifyingGlass, Phone, Plus, PushPin, SpeakerHigh, Users, VideoCamera, X, } from '@phosphor-icons/react'; import { Avatar, Tooltip } from '@discord-clone/ui'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery } from 'convex/react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useIsMobile } from '../../hooks/useIsMobile'; import { usePlatform } from '../../platform'; import { useOnlineUsers } from '../../contexts/PresenceContext'; import { api } from '../../../../../convex/_generated/api'; import { MemberProfileModal } from '../member/MemberProfileModal'; import { MobileMemberProfileSheet } from '../member/MobileMemberProfileSheet'; import { useKeybinds } from '../../contexts/KeybindContext'; import { ChannelHeaderPinsPopover } from './ChannelHeaderPinsPopover'; import styles from './ChannelHeader.module.css'; const SEARCH_FILTERS = [ { key: 'from:', desc: 'user' }, { key: 'mentions:', desc: 'user' }, { key: 'has:', desc: 'link, embed or file' }, { key: 'before:', desc: 'specific date' }, { key: 'during:', desc: 'specific date' }, { key: 'after:', desc: 'specific date' }, { key: 'pinned:', desc: 'true or false' }, ]; interface ChannelLike { _id?: string; name?: string; type?: string; topic?: string; } interface ChannelHeaderProps { channel?: ChannelLike | null; serverId?: string; onOpenChannelDetails?: () => void; onOpenSearchDrawer?: () => void; membersVisible?: boolean; onToggleMembers?: () => void; /** Hide the members button entirely. DMs use this since a 1:1 * conversation doesn't need a sidebar member list. */ hideMembersButton?: boolean; /** When true, the viewport is below the members-list breakpoint. The * Users button becomes a no-op and gets disabled styling. */ isNarrow?: boolean; /** Controlled search input. State lives in ChannelView so the sibling * SearchPanel can read the same query. */ searchQuery?: string; onSearchChange?: (next: string) => void; onSearchClear?: () => void; } export function ChannelHeader({ channel, onOpenChannelDetails, membersVisible = true, onToggleMembers, hideMembersButton = false, isNarrow = false, searchQuery = '', onSearchChange, onSearchClear, }: ChannelHeaderProps) { const isVoice = channel?.type === 'voice'; const isDM = channel?.type === 'dm'; const isMobile = useIsMobile(); const navigate = useNavigate(); const location = useLocation(); const { crypto } = usePlatform(); const { resolveStatus } = useOnlineUsers(); const keybinds = useKeybinds(); const pinsButtonRef = useRef(null); const dmHeaderButtonRef = useRef(null); const [pinsAnchor, setPinsAnchor] = useState(null); const [profileOpen, setProfileOpen] = useState(false); const [showFilters, setShowFilters] = useState(false); const searchInputRef = useRef(null); // ── DM participant lookup ────────────────────────────────────── // When this channel is a DM we pull the other participant out of // `api.dms.listDMs` and resolve their profile via getPublicKeys. const myUserId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null; const dmRows = useQuery( api.dms.listDMs, isDM && myUserId ? { userId: myUserId as any } : 'skip', ); const allUsers = useQuery( api.auth.getPublicKeys, isDM ? {} : 'skip', ) ?? []; const otherParticipant = useMemo(() => { if (!isDM || !dmRows || !channel?._id) return null; const row = (dmRows as any[]).find((r) => r.channel_id === channel._id); if (!row) return null; const profile = allUsers.find((u) => u.id === row.other_user_id); const storedStatus = (profile?.status as string | undefined) || (row.other_user_status as string | undefined) || 'offline'; const liveStatus = resolveStatus(storedStatus, row.other_user_id); // DM header uses the raw username (not the server display // name) so people always know who they're actually talking to. const username = (profile?.username as string | undefined) || (row.other_username as string | undefined) || 'user'; return { userId: row.other_user_id as string, displayName: username, username, avatarUrl: profile?.avatarUrl ?? row.other_user_avatar_url ?? null, status: liveStatus, }; }, [isDM, dmRows, allUsers, channel?._id, resolveStatus]); const rotateDMKey = useMutation(api.channelKeys.rotateDMKey); const handleRotateDMKey = async () => { if (!channel?._id || !otherParticipant || !myUserId) { throw new Error("Can't rotate — missing DM context."); } const privateKey = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('privateKey') : null; if (!privateKey) { throw new Error('No session key available.'); } const me = allUsers.find((u) => u.id === myUserId); const other = allUsers.find((u) => u.id === otherParticipant.userId); if (!me?.public_identity_key || !other?.public_identity_key) { throw new Error("One participant's public key is missing."); } const newKeyHex = await crypto.randomBytes(32); const plaintext = JSON.stringify({ [channel._id]: newKeyHex }); const [myBundle, otherBundle] = await Promise.all([ crypto.publicEncrypt(me.public_identity_key, plaintext), crypto.publicEncrypt(other.public_identity_key, plaintext), ]); await rotateDMKey({ channelId: channel._id as any, initiatorUserId: myUserId as any, entries: [ { userId: myUserId as any, encryptedKeyBundle: myBundle }, { userId: otherParticipant.userId as any, encryptedKeyBundle: otherBundle, }, ], }); }; const handleOpenProfile = () => { setProfileOpen(true); }; // Wire keybinds that affect this header: toggle pins popover, // toggle member list. The KeybindProvider dispatches // `brycord:keybind:` events on the window. useEffect(() => { const onTogglePins = () => handleTogglePins(); const onToggleMembers = () => { if (hideMembersButton) return; handleToggleMembers(); }; window.addEventListener('brycord:keybind:popouts.openPins', onTogglePins); window.addEventListener( 'brycord:keybind:popouts.toggleMembers', onToggleMembers, ); return () => { window.removeEventListener('brycord:keybind:popouts.openPins', onTogglePins); window.removeEventListener( 'brycord:keybind:popouts.toggleMembers', onToggleMembers, ); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [hideMembersButton]); const setSearchQuery = (next: string) => { onSearchChange?.(next); }; const clearSearchQuery = () => { if (onSearchClear) onSearchClear(); else onSearchChange?.(''); }; const onInputBlur = () => { // 200ms delay — long enough for the chip click to register setTimeout(() => setShowFilters(false), 200); }; const handleFilterClick = (key: string) => { setSearchQuery(searchQuery ? `${searchQuery} ${key}` : key); requestAnimationFrame(() => searchInputRef.current?.focus()); }; /** * On mobile, the back button strips the trailing `/channelId` segment * from the current path so we return to the channel list view. For * `/channels/home/:id` this yields `/channels/home`; for * `/channels/@me/:id` → `/channels/@me`. Falls back to `/channels/@me` * if the path doesn't look like a channel URL. */ const handleMobileBack = () => { const path = location.pathname; const match = path.match(/^(\/channels\/(?:home|@me|[^/]+))(?:\/[^/]+)?$/); navigate(match ? match[1] : '/channels/@me'); }; const handleTogglePins = () => { if (pinsAnchor) { setPinsAnchor(null); return; } const rect = pinsButtonRef.current?.getBoundingClientRect(); if (rect) setPinsAnchor(rect); }; const handleToggleMembers = () => { if (isNarrow) return; if (onToggleMembers) { onToggleMembers(); } else { window.dispatchEvent(new CustomEvent('brycord:toggle-members')); } }; if (isMobile) { return (
{isDM && otherParticipant ? ( ) : ( )}
{otherParticipant && ( setProfileOpen(false)} member={{ userId: otherParticipant.userId }} onRotateKey={handleRotateDMKey} /> )}
); } return (
{isDM && otherParticipant ? ( ) : (
{isVoice ? ( ) : ( )} {channel?.name || 'Channel'} {channel?.topic && ( <>
{channel.topic} )}
)}
{isDM && ( <> )} {!hideMembersButton && ( )}
setSearchQuery(e.target.value)} onFocus={() => setShowFilters(true)} onBlur={onInputBlur} /> {searchQuery && ( )}
{showFilters && !searchQuery && (
Search Filters
{SEARCH_FILTERS.map((f) => ( ))}
)}
{channel?._id && ( setPinsAnchor(null)} /> )} {otherParticipant && ( isMobile ? ( setProfileOpen(false)} member={{ userId: otherParticipant.userId }} onRotateKey={handleRotateDMKey} /> ) : ( setProfileOpen(false)} member={{ userId: otherParticipant.userId }} onRotateKey={handleRotateDMKey} /> ) )}
); }