All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- 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.
498 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|