Files
DiscordClone/packages/shared/src/components/channel/ChannelHeader.tsx
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

498 lines
14 KiB
TypeScript

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<HTMLButtonElement>(null);
const dmHeaderButtonRef = useRef<HTMLButtonElement>(null);
const [pinsAnchor, setPinsAnchor] = useState<DOMRect | null>(null);
const [profileOpen, setProfileOpen] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(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:<id>` 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 (
<div className={styles.container}>
<div className={styles.channelInfoMobile}>
<button
type="button"
className={styles.backButton}
onClick={handleMobileBack}
aria-label="Back to channels"
>
<ArrowLeft size={20} weight="bold" />
</button>
{isDM && otherParticipant ? (
<button
ref={dmHeaderButtonRef}
type="button"
className={styles.channelInfoButton}
onClick={handleOpenProfile}
>
<Avatar
src={otherParticipant.avatarUrl}
size={26}
fallback={otherParticipant.displayName}
status={otherParticipant.status as any}
/>
<span className={styles.name}>
{otherParticipant.displayName}
</span>
<CaretRight size={14} weight="bold" className={styles.caretRight} />
</button>
) : (
<button
type="button"
className={styles.channelInfoButton}
onClick={onOpenChannelDetails}
>
{isVoice ? (
<SpeakerHigh size={22} className={styles.icon} />
) : (
<Hash size={22} weight="bold" className={styles.icon} />
)}
<span className={styles.name}>{channel?.name || 'Channel'}</span>
<CaretRight size={14} weight="bold" className={styles.caretRight} />
</button>
)}
</div>
{otherParticipant && (
<MemberProfileModal
isOpen={profileOpen}
onClose={() => setProfileOpen(false)}
member={{ userId: otherParticipant.userId }}
onRotateKey={handleRotateDMKey}
/>
)}
</div>
);
}
return (
<div className={styles.container}>
{isDM && otherParticipant ? (
<button
ref={dmHeaderButtonRef}
type="button"
className={styles.dmHeaderButton}
onClick={handleOpenProfile}
aria-label={`Open ${otherParticipant.displayName}'s profile`}
>
<Avatar
src={otherParticipant.avatarUrl}
size={28}
fallback={otherParticipant.displayName}
status={otherParticipant.status as any}
/>
<span className={styles.dmHeaderName}>
{otherParticipant.displayName}
</span>
</button>
) : (
<div className={styles.channelInfo}>
{isVoice ? (
<SpeakerHigh size={20} className={styles.icon} />
) : (
<Hash size={20} weight="bold" className={styles.icon} />
)}
<span className={styles.name}>{channel?.name || 'Channel'}</span>
{channel?.topic && (
<>
<div className={styles.divider} />
<span className={styles.topic}>{channel.topic}</span>
</>
)}
</div>
)}
<div className={styles.headerButtons}>
{isDM && (
<>
<Tooltip content="Start Voice Call" placement="bottom">
<button
type="button"
className={styles.headerButton}
aria-label="Start Voice Call"
disabled
title="Voice calls coming soon"
>
<Phone size={20} />
</button>
</Tooltip>
<Tooltip content="Start Video Call" placement="bottom">
<button
type="button"
className={styles.headerButton}
aria-label="Start Video Call"
disabled
title="Video calls coming soon"
>
<VideoCamera size={20} />
</button>
</Tooltip>
</>
)}
<Tooltip
content="Pinned Messages"
shortcut={keybinds.getCombo('popouts.openPins')}
placement="bottom"
>
<button
ref={pinsButtonRef}
type="button"
className={`${styles.headerButton} ${pinsAnchor ? styles.headerButtonActive : ''}`}
aria-label="Pinned"
onClick={handleTogglePins}
>
<PushPin size={20} />
</button>
</Tooltip>
{!hideMembersButton && (
<Tooltip
content={
isNarrow
? 'Window too narrow for member list'
: 'Member List'
}
shortcut={
isNarrow ? undefined : keybinds.getCombo('popouts.toggleMembers')
}
placement="bottom"
>
<button
type="button"
className={`${styles.headerButton} ${
membersVisible && !isNarrow ? styles.headerButtonActive : ''
} ${isNarrow ? styles.headerButtonDisabled : ''}`}
aria-label="Members"
aria-disabled={isNarrow || undefined}
onClick={handleToggleMembers}
>
<Users size={20} />
</button>
</Tooltip>
)}
<div className={styles.searchBarWrapper}>
<div className={styles.searchBar}>
<MagnifyingGlass
size={16}
weight="regular"
className={styles.searchBarIcon}
/>
<input
ref={searchInputRef}
className={styles.searchBarInput}
placeholder="Search messages"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setShowFilters(true)}
onBlur={onInputBlur}
/>
{searchQuery && (
<button
type="button"
className={styles.searchBarClear}
onClick={clearSearchQuery}
aria-label="Clear search"
>
<X size={14} />
</button>
)}
</div>
{showFilters && !searchQuery && (
<div className={styles.filterDropdown}>
<div className={styles.filterSection}>
<div className={styles.filterSectionHeader}>
<span className={styles.filterSectionIcon}>
<Funnel size={12} />
</span>
<span>Search Filters</span>
</div>
{SEARCH_FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={styles.filterOption}
onMouseDown={(e) => {
// Use mousedown not click so it fires before the input blur
e.preventDefault();
handleFilterClick(f.key);
}}
>
<span className={styles.filterBadge}>{f.key}</span>
<span className={styles.filterDesc}> {f.desc}</span>
<Plus size={14} className={styles.filterPlus} />
</button>
))}
</div>
</div>
)}
</div>
</div>
{channel?._id && (
<ChannelHeaderPinsPopover
isOpen={pinsAnchor !== null}
anchorRect={pinsAnchor}
channelId={channel._id}
onClose={() => setPinsAnchor(null)}
/>
)}
{otherParticipant && (
isMobile ? (
<MobileMemberProfileSheet
isOpen={profileOpen}
onClose={() => setProfileOpen(false)}
member={{ userId: otherParticipant.userId }}
onRotateKey={handleRotateDMKey}
/>
) : (
<MemberProfileModal
isOpen={profileOpen}
onClose={() => setProfileOpen(false)}
member={{ userId: otherParticipant.userId }}
onRotateKey={handleRotateDMKey}
/>
)
)}
</div>
);
}