feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
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.
This commit is contained in:
497
packages/shared/src/components/channel/ChannelHeader.tsx
Normal file
497
packages/shared/src/components/channel/ChannelHeader.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user