Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d0e4fb1f0 | |||
| 36ab7eca8a | |||
| ddd696a790 | |||
| 53b3b5e1fc | |||
| a4573b315d | |||
| c271e95100 |
@@ -11,6 +11,11 @@ interface Props {
|
|||||||
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
|
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
|
||||||
fallbackClass?: string;
|
fallbackClass?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
// Browser loading hint. Use 'eager' for above-the-fold avatars (e.g. the
|
||||||
|
// active conversation header, call tiles). Defaults to 'lazy' so off-screen
|
||||||
|
// avatars (chat list rows, friends list, popovers) don't hammer Supabase
|
||||||
|
// Storage on initial render.
|
||||||
|
loading?: 'eager' | 'lazy';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Avatar({
|
export function Avatar({
|
||||||
@@ -19,6 +24,7 @@ export function Avatar({
|
|||||||
className = 'h-10 w-10',
|
className = 'h-10 w-10',
|
||||||
fallbackClass = 'bg-accent/20 text-accent',
|
fallbackClass = 'bg-accent/20 text-accent',
|
||||||
alt,
|
alt,
|
||||||
|
loading = 'lazy',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const effectiveUrl = useCachedAvatarUrl(url);
|
const effectiveUrl = useCachedAvatarUrl(url);
|
||||||
if (effectiveUrl) {
|
if (effectiveUrl) {
|
||||||
@@ -28,6 +34,7 @@ export function Avatar({
|
|||||||
alt={alt ?? displayName ?? ''}
|
alt={alt ?? displayName ?? ''}
|
||||||
className={'shrink-0 rounded-full object-cover ' + className}
|
className={'shrink-0 rounded-full object-cover ' + className}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ function AudioContent({
|
|||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="relative h-full w-full rounded-full object-cover"
|
className="relative h-full w-full rounded-full object-cover"
|
||||||
|
loading="eager"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
@@ -366,7 +367,7 @@ function VideoStub({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
|
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" loading="eager" />
|
||||||
) : (
|
) : (
|
||||||
letter
|
letter
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ function HeaderBar({
|
|||||||
className="relative shrink-0 rounded-full text-left transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default"
|
className="relative shrink-0 rounded-full text-left transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default"
|
||||||
>
|
>
|
||||||
{isDm ? (
|
{isDm ? (
|
||||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
|
||||||
) : peerAvatar ? (
|
) : peerAvatar ? (
|
||||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||||
<UsersIcon className="h-5 w-5" />
|
<UsersIcon className="h-5 w-5" />
|
||||||
@@ -197,7 +197,7 @@ function HeaderBar({
|
|||||||
|
|
||||||
interface HeaderActionButtonProps {
|
interface HeaderActionButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
tone?: 'default' | 'accent';
|
tone?: 'default' | 'accent';
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export function GameModal({ gameId, onClose }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (finished && winnerIdx !== null && winnerIdx === myPlayerIdx) {
|
if (finished && winnerIdx !== null && winnerIdx === myPlayerIdx) {
|
||||||
|
// Respect the OS-level reduced-motion preference.
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia &&
|
||||||
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Two bursts from the lower corners for a celebratory feel.
|
// Two bursts from the lower corners for a celebratory feel.
|
||||||
void confetti({
|
void confetti({
|
||||||
particleCount: 80,
|
particleCount: 80,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
softDeleteMessage,
|
softDeleteMessage,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -60,8 +60,17 @@ interface Props {
|
|||||||
senderAvatarUrl?: string | null | undefined;
|
senderAvatarUrl?: string | null | undefined;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
reactions: AggregatedReaction[];
|
reactions: AggregatedReaction[];
|
||||||
onToggleReaction: (emoji: string) => Promise<void>;
|
/**
|
||||||
onVotePoll?: (emoji: string, optionEmojis: string[]) => Promise<void>;
|
* Toggle a reaction on this message. Receives the message id so the parent
|
||||||
|
* can pass a stable handler reference across every row (lets `React.memo`
|
||||||
|
* actually skip re-renders triggered by composer keystrokes / typing pings).
|
||||||
|
*/
|
||||||
|
onToggleReaction: (messageId: string, emoji: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Cast/clear an exclusive poll vote. Receives the message id for the same
|
||||||
|
* reason as `onToggleReaction`.
|
||||||
|
*/
|
||||||
|
onVotePoll?: (messageId: string, emoji: string, optionEmojis: string[]) => Promise<void>;
|
||||||
showSeen?: boolean;
|
showSeen?: boolean;
|
||||||
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
||||||
deliveryState?: 'sent' | 'delivered' | 'read';
|
deliveryState?: 'sent' | 'delivered' | 'read';
|
||||||
@@ -83,7 +92,7 @@ interface Props {
|
|||||||
onTogglePin?: (messageId: string) => void;
|
onTogglePin?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({
|
function MessageBubbleInner({
|
||||||
message,
|
message,
|
||||||
mine,
|
mine,
|
||||||
groupedWithPrev,
|
groupedWithPrev,
|
||||||
@@ -257,12 +266,12 @@ export function MessageBubble({
|
|||||||
async (emoji: string) => {
|
async (emoji: string) => {
|
||||||
setPickerOpen(false);
|
setPickerOpen(false);
|
||||||
try {
|
try {
|
||||||
await onToggleReaction(emoji);
|
await onToggleReaction(message.id, emoji);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('toggleReaction failed', err);
|
console.error('toggleReaction failed', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onToggleReaction],
|
[onToggleReaction, message.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const copyableText =
|
const copyableText =
|
||||||
@@ -527,7 +536,9 @@ export function MessageBubble({
|
|||||||
reactions={reactions}
|
reactions={reactions}
|
||||||
mine={mine}
|
mine={mine}
|
||||||
onVote={(emoji) =>
|
onVote={(emoji) =>
|
||||||
onVotePoll ? onVotePoll(emoji, pollOptionEmojis) : onToggleReaction(emoji)
|
onVotePoll
|
||||||
|
? onVotePoll(message.id, emoji, pollOptionEmojis)
|
||||||
|
: onToggleReaction(message.id, emoji)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -593,7 +604,7 @@ export function MessageBubble({
|
|||||||
<button
|
<button
|
||||||
key={r.emoji + ':' + r.count}
|
key={r.emoji + ':' + r.count}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void onToggleReaction(r.emoji)}
|
onClick={() => void onToggleReaction(message.id, r.emoji)}
|
||||||
className={
|
className={
|
||||||
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(r.mine
|
(r.mine
|
||||||
@@ -743,6 +754,14 @@ export function MessageBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoized export. Skips re-rendering when none of its props' shallow
|
||||||
|
* references change — i.e. when the parent re-renders due to composer
|
||||||
|
* keystrokes, typing-indicator updates, presence pings, etc. Relies on
|
||||||
|
* the parent passing stable callback refs (see `ConversationPage`).
|
||||||
|
*/
|
||||||
|
export const MessageBubble = memo(MessageBubbleInner);
|
||||||
|
|
||||||
function PollCard({
|
function PollCard({
|
||||||
question,
|
question,
|
||||||
options,
|
options,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||||
badge?: 'friends' | 'chats';
|
badge?: 'friends' | 'chats';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export function Sidebar() {
|
|||||||
interface RailNavLinkProps {
|
interface RailNavLinkProps {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||||
badge?: number;
|
badge?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
|
|||||||
interface RailIconButtonProps {
|
interface RailIconButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||||
tone?: 'default' | 'danger';
|
tone?: 'default' | 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75.
|
// Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75.
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
type IconProps = React.SVGProps<SVGSVGElement>;
|
type IconProps = React.ComponentPropsWithoutRef<'svg'>;
|
||||||
|
|
||||||
function Base({ children, ...props }: IconProps & { children: React.ReactNode }) {
|
function Base({ children, ...props }: IconProps & { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +21,7 @@ function Base({ children, ...props }: IconProps & { children: React.ReactNode })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MailIcon(props: IconProps) {
|
function MailIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
@@ -28,8 +29,9 @@ export function MailIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MailIcon = memo(MailIconInner);
|
||||||
|
|
||||||
export function AtIcon(props: IconProps) {
|
function AtIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="4" />
|
<circle cx="12" cy="12" r="4" />
|
||||||
@@ -37,8 +39,9 @@ export function AtIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const AtIcon = memo(AtIconInner);
|
||||||
|
|
||||||
export function TicketIcon(props: IconProps) {
|
function TicketIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2a2 2 0 1 0 0 4v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 1 0 0-4V8Z" />
|
<path d="M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2a2 2 0 1 0 0 4v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 1 0 0-4V8Z" />
|
||||||
@@ -46,8 +49,9 @@ export function TicketIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const TicketIcon = memo(TicketIconInner);
|
||||||
|
|
||||||
export function ArrowRightIcon(props: IconProps) {
|
function ArrowRightIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M5 12h14" />
|
<path d="M5 12h14" />
|
||||||
@@ -55,8 +59,9 @@ export function ArrowRightIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ArrowRightIcon = memo(ArrowRightIconInner);
|
||||||
|
|
||||||
export function CheckCircleIcon(props: IconProps) {
|
function CheckCircleIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
@@ -64,8 +69,9 @@ export function CheckCircleIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const CheckCircleIcon = memo(CheckCircleIconInner);
|
||||||
|
|
||||||
export function AlertIcon(props: IconProps) {
|
function AlertIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M12 9v4" />
|
<path d="M12 9v4" />
|
||||||
@@ -74,8 +80,9 @@ export function AlertIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const AlertIcon = memo(AlertIconInner);
|
||||||
|
|
||||||
export function ShieldIcon(props: IconProps) {
|
function ShieldIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />
|
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />
|
||||||
@@ -83,8 +90,9 @@ export function ShieldIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ShieldIcon = memo(ShieldIconInner);
|
||||||
|
|
||||||
export function LockIcon(props: IconProps) {
|
function LockIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="4" y="11" width="16" height="10" rx="2" />
|
<rect x="4" y="11" width="16" height="10" rx="2" />
|
||||||
@@ -92,8 +100,9 @@ export function LockIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const LockIcon = memo(LockIconInner);
|
||||||
|
|
||||||
export function WifiLowIcon(props: IconProps) {
|
function WifiLowIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" />
|
<path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" />
|
||||||
@@ -102,8 +111,9 @@ export function WifiLowIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const WifiLowIcon = memo(WifiLowIconInner);
|
||||||
|
|
||||||
export function WifiOffIcon(props: IconProps) {
|
function WifiOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="m2 2 20 20" />
|
<path d="m2 2 20 20" />
|
||||||
@@ -116,8 +126,9 @@ export function WifiOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const WifiOffIcon = memo(WifiOffIconInner);
|
||||||
|
|
||||||
export function PinIcon(props: IconProps) {
|
function PinIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M12 17v5" />
|
<path d="M12 17v5" />
|
||||||
@@ -125,8 +136,9 @@ export function PinIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PinIcon = memo(PinIconInner);
|
||||||
|
|
||||||
export function EyeOffIcon(props: IconProps) {
|
function EyeOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="m2 2 20 20" />
|
<path d="m2 2 20 20" />
|
||||||
@@ -136,8 +148,9 @@ export function EyeOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const EyeOffIcon = memo(EyeOffIconInner);
|
||||||
|
|
||||||
export function CaptionsIcon(props: IconProps) {
|
function CaptionsIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="6" width="18" height="12" rx="2" />
|
<rect x="3" y="6" width="18" height="12" rx="2" />
|
||||||
@@ -146,8 +159,9 @@ export function CaptionsIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const CaptionsIcon = memo(CaptionsIconInner);
|
||||||
|
|
||||||
export function PinOffIcon(props: IconProps) {
|
function PinOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="m2 2 20 20" />
|
<path d="m2 2 20 20" />
|
||||||
@@ -157,8 +171,9 @@ export function PinOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PinOffIcon = memo(PinOffIconInner);
|
||||||
|
|
||||||
export function SparklesIcon(props: IconProps) {
|
function SparklesIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M12 3v4" />
|
<path d="M12 3v4" />
|
||||||
@@ -172,8 +187,9 @@ export function SparklesIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SparklesIcon = memo(SparklesIconInner);
|
||||||
|
|
||||||
export function SpinnerIcon(props: IconProps) {
|
function SpinnerIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -208,16 +224,18 @@ export function SpinnerIcon(props: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SpinnerIcon = memo(SpinnerIconInner);
|
||||||
|
|
||||||
export function ChatBubbleIcon(props: IconProps) {
|
function ChatBubbleIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
|
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ChatBubbleIcon = memo(ChatBubbleIconInner);
|
||||||
|
|
||||||
export function UsersIcon(props: IconProps) {
|
function UsersIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||||
@@ -227,8 +245,9 @@ export function UsersIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const UsersIcon = memo(UsersIconInner);
|
||||||
|
|
||||||
export function GearIcon(props: IconProps) {
|
function GearIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
@@ -236,8 +255,9 @@ export function GearIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const GearIcon = memo(GearIconInner);
|
||||||
|
|
||||||
export function SearchIcon(props: IconProps) {
|
function SearchIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="11" cy="11" r="7" />
|
<circle cx="11" cy="11" r="7" />
|
||||||
@@ -245,16 +265,18 @@ export function SearchIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SearchIcon = memo(SearchIconInner);
|
||||||
|
|
||||||
export function PlusIcon(props: IconProps) {
|
function PlusIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M12 5v14M5 12h14" />
|
<path d="M12 5v14M5 12h14" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PlusIcon = memo(PlusIconInner);
|
||||||
|
|
||||||
export function SignOutIcon(props: IconProps) {
|
function SignOutIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
@@ -263,32 +285,36 @@ export function SignOutIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SignOutIcon = memo(SignOutIconInner);
|
||||||
|
|
||||||
export function MenuIcon(props: IconProps) {
|
function MenuIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MenuIcon = memo(MenuIconInner);
|
||||||
|
|
||||||
export function ChevronDownIcon(props: IconProps) {
|
function ChevronDownIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="m6 9 6 6 6-6" />
|
<path d="m6 9 6 6 6-6" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ChevronDownIcon = memo(ChevronDownIconInner);
|
||||||
|
|
||||||
export function PencilIcon(props: IconProps) {
|
function PencilIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PencilIcon = memo(PencilIconInner);
|
||||||
|
|
||||||
export function TrashIcon(props: IconProps) {
|
function TrashIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M3 6h18" />
|
<path d="M3 6h18" />
|
||||||
@@ -299,8 +325,9 @@ export function TrashIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const TrashIcon = memo(TrashIconInner);
|
||||||
|
|
||||||
export function SmileIcon(props: IconProps) {
|
function SmileIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
@@ -310,8 +337,9 @@ export function SmileIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SmileIcon = memo(SmileIconInner);
|
||||||
|
|
||||||
export function CopyIcon(props: IconProps) {
|
function CopyIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||||
@@ -319,16 +347,18 @@ export function CopyIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const CopyIcon = memo(CopyIconInner);
|
||||||
|
|
||||||
export function PhoneIcon(props: IconProps) {
|
function PhoneIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" />
|
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PhoneIcon = memo(PhoneIconInner);
|
||||||
|
|
||||||
export function PhoneOffIcon(props: IconProps) {
|
function PhoneOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.48-3.05" />
|
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.48-3.05" />
|
||||||
@@ -337,8 +367,9 @@ export function PhoneOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PhoneOffIcon = memo(PhoneOffIconInner);
|
||||||
|
|
||||||
export function MicIcon(props: IconProps) {
|
function MicIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="9" y="2" width="6" height="12" rx="3" />
|
<rect x="9" y="2" width="6" height="12" rx="3" />
|
||||||
@@ -348,8 +379,9 @@ export function MicIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MicIcon = memo(MicIconInner);
|
||||||
|
|
||||||
export function MicOffIcon(props: IconProps) {
|
function MicOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M1 1l22 22" />
|
<path d="M1 1l22 22" />
|
||||||
@@ -362,8 +394,9 @@ export function MicOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MicOffIcon = memo(MicOffIconInner);
|
||||||
|
|
||||||
export function InfoIcon(props: IconProps) {
|
function InfoIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
@@ -372,16 +405,18 @@ export function InfoIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const InfoIcon = memo(InfoIconInner);
|
||||||
|
|
||||||
export function XIcon(props: IconProps) {
|
function XIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M18 6 6 18M6 6l12 12" />
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const XIcon = memo(XIconInner);
|
||||||
|
|
||||||
export function MonitorShareIcon(props: IconProps) {
|
function MonitorShareIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||||
@@ -390,8 +425,9 @@ export function MonitorShareIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MonitorShareIcon = memo(MonitorShareIconInner);
|
||||||
|
|
||||||
export function MonitorStopIcon(props: IconProps) {
|
function MonitorStopIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||||
@@ -400,8 +436,9 @@ export function MonitorStopIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MonitorStopIcon = memo(MonitorStopIconInner);
|
||||||
|
|
||||||
export function SunIcon(props: IconProps) {
|
function SunIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="4" />
|
<circle cx="12" cy="12" r="4" />
|
||||||
@@ -409,16 +446,18 @@ export function SunIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SunIcon = memo(SunIconInner);
|
||||||
|
|
||||||
export function MoonIcon(props: IconProps) {
|
function MoonIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
|
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MoonIcon = memo(MoonIconInner);
|
||||||
|
|
||||||
export function GridIcon(props: IconProps) {
|
function GridIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||||
@@ -428,8 +467,9 @@ export function GridIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const GridIcon = memo(GridIconInner);
|
||||||
|
|
||||||
export function ImageIcon(props: IconProps) {
|
function ImageIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
@@ -438,8 +478,9 @@ export function ImageIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ImageIcon = memo(ImageIconInner);
|
||||||
|
|
||||||
export function FileIcon(props: IconProps) {
|
function FileIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z" />
|
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z" />
|
||||||
@@ -447,8 +488,9 @@ export function FileIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const FileIcon = memo(FileIconInner);
|
||||||
|
|
||||||
export function PollIcon(props: IconProps) {
|
function PollIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M5 19V9" />
|
<path d="M5 19V9" />
|
||||||
@@ -458,8 +500,9 @@ export function PollIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PollIcon = memo(PollIconInner);
|
||||||
|
|
||||||
export function FocusIcon(props: IconProps) {
|
function FocusIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
@@ -467,8 +510,9 @@ export function FocusIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const FocusIcon = memo(FocusIconInner);
|
||||||
|
|
||||||
export function MaximizeIcon(props: IconProps) {
|
function MaximizeIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M4 9V5a1 1 0 0 1 1-1h4" />
|
<path d="M4 9V5a1 1 0 0 1 1-1h4" />
|
||||||
@@ -478,8 +522,9 @@ export function MaximizeIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MaximizeIcon = memo(MaximizeIconInner);
|
||||||
|
|
||||||
export function VideoIcon(props: IconProps) {
|
function VideoIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="2" y="6" width="15" height="12" rx="2" />
|
<rect x="2" y="6" width="15" height="12" rx="2" />
|
||||||
@@ -487,16 +532,18 @@ export function VideoIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const VideoIcon = memo(VideoIconInner);
|
||||||
|
|
||||||
export function CrownIcon(props: IconProps) {
|
function CrownIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
<path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const CrownIcon = memo(CrownIconInner);
|
||||||
|
|
||||||
export function MusicIcon(props: IconProps) {
|
function MusicIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M9 18V5l12-2v13" />
|
<path d="M9 18V5l12-2v13" />
|
||||||
@@ -505,16 +552,18 @@ export function MusicIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MusicIcon = memo(MusicIconInner);
|
||||||
|
|
||||||
export function SendIcon(props: IconProps) {
|
function SendIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="m3 11 18-8-8 18-2-8-8-2Z" />
|
<path d="m3 11 18-8-8 18-2-8-8-2Z" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SendIcon = memo(SendIconInner);
|
||||||
|
|
||||||
export function ArchiveIcon(props: IconProps) {
|
function ArchiveIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="4" width="18" height="5" rx="1" />
|
<rect x="3" y="4" width="18" height="5" rx="1" />
|
||||||
@@ -523,8 +572,9 @@ export function ArchiveIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ArchiveIcon = memo(ArchiveIconInner);
|
||||||
|
|
||||||
export function BellIcon(props: IconProps) {
|
function BellIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" />
|
<path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" />
|
||||||
@@ -532,8 +582,9 @@ export function BellIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const BellIcon = memo(BellIconInner);
|
||||||
|
|
||||||
export function BellOffIcon(props: IconProps) {
|
function BellOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
||||||
@@ -545,8 +596,9 @@ export function BellOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const BellOffIcon = memo(BellOffIconInner);
|
||||||
|
|
||||||
export function MoreVerticalIcon(props: IconProps) {
|
function MoreVerticalIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="5" r="1.5" />
|
<circle cx="12" cy="5" r="1.5" />
|
||||||
@@ -555,8 +607,9 @@ export function MoreVerticalIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MoreVerticalIcon = memo(MoreVerticalIconInner);
|
||||||
|
|
||||||
export function HeadphonesIcon(props: IconProps) {
|
function HeadphonesIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
|
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
|
||||||
@@ -565,8 +618,9 @@ export function HeadphonesIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const HeadphonesIcon = memo(HeadphonesIconInner);
|
||||||
|
|
||||||
export function HeadphonesOffIcon(props: IconProps) {
|
function HeadphonesOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
|
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
|
||||||
@@ -577,8 +631,9 @@ export function HeadphonesOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const HeadphonesOffIcon = memo(HeadphonesOffIconInner);
|
||||||
|
|
||||||
export function ReplyIcon(props: IconProps) {
|
function ReplyIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<polyline points="9 17 4 12 9 7" />
|
<polyline points="9 17 4 12 9 7" />
|
||||||
@@ -586,8 +641,9 @@ export function ReplyIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ReplyIcon = memo(ReplyIconInner);
|
||||||
|
|
||||||
export function ForwardIcon(props: IconProps) {
|
function ForwardIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<polyline points="15 17 20 12 15 7" />
|
<polyline points="15 17 20 12 15 7" />
|
||||||
@@ -595,16 +651,18 @@ export function ForwardIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ForwardIcon = memo(ForwardIconInner);
|
||||||
|
|
||||||
export function ChevronUpIcon(props: IconProps) {
|
function ChevronUpIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<polyline points="18 15 12 9 6 15" />
|
<polyline points="18 15 12 9 6 15" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ChevronUpIcon = memo(ChevronUpIconInner);
|
||||||
|
|
||||||
export function AddUserIcon(props: IconProps) {
|
function AddUserIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||||
@@ -613,12 +671,13 @@ export function AddUserIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const AddUserIcon = memo(AddUserIconInner);
|
||||||
|
|
||||||
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
|
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
|
||||||
// wherever the app needs a standalone icon (sidebar rail, auth screen,
|
// wherever the app needs a standalone icon (sidebar rail, auth screen,
|
||||||
// favicon). Colour decisions sit inside the SVG so consumers just size the
|
// favicon). Colour decisions sit inside the SVG so consumers just size the
|
||||||
// element via `className`.
|
// element via `className`.
|
||||||
export function LogoMark(props: IconProps) {
|
function LogoMarkInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -638,10 +697,11 @@ export function LogoMark(props: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const LogoMark = memo(LogoMarkInner);
|
||||||
|
|
||||||
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
|
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
|
||||||
// "dark" = white text (use on dark background), "light" = near-black.
|
// "dark" = white text (use on dark background), "light" = near-black.
|
||||||
export function LogoLockup({
|
function LogoLockupInner({
|
||||||
tone = 'dark',
|
tone = 'dark',
|
||||||
...props
|
...props
|
||||||
}: IconProps & { tone?: 'dark' | 'light' }) {
|
}: IconProps & { tone?: 'dark' | 'light' }) {
|
||||||
@@ -676,3 +736,4 @@ export function LogoLockup({
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const LogoLockup = memo(LogoLockupInner);
|
||||||
|
|||||||
@@ -204,6 +204,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
void registerWebPush(installId);
|
void registerWebPush(installId);
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
|
// Pre-warm Supabase: fires the first round-trip in the background so the
|
||||||
|
// first user-triggered query (e.g. loading conversations) doesn't pay
|
||||||
|
// the cold-connection latency.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) return;
|
||||||
|
void supabase.from('profiles').select('id').limit(1).then(() => undefined);
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
// Phase 3: ensure this install owns exactly one devices row. The row is
|
// Phase 3: ensure this install owns exactly one devices row. The row is
|
||||||
// pure session-list telemetry — it does not carry any cryptographic
|
// pure session-list telemetry — it does not carry any cryptographic
|
||||||
// material since the per-user-key refactor. We re-use the row across
|
// material since the per-user-key refactor. We re-use the row across
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
|
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
|
||||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -26,13 +26,16 @@ import {
|
|||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
import { ImageAnnotator } from '../components/ImageAnnotator';
|
const ImageAnnotator = lazy(() =>
|
||||||
|
import('../components/ImageAnnotator').then((m) => ({ default: m.ImageAnnotator })),
|
||||||
|
);
|
||||||
import { InCallPanel } from '../components/InCallPanel';
|
import { InCallPanel } from '../components/InCallPanel';
|
||||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||||
import { CallPreviewPanel } from '../components/CallPreviewPanel';
|
import { CallPreviewPanel } from '../components/CallPreviewPanel';
|
||||||
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
|
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
|
||||||
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
||||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||||
|
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||||
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
|
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
|
||||||
import { PollComposerDialog } from '../components/PollComposerDialog';
|
import { PollComposerDialog } from '../components/PollComposerDialog';
|
||||||
import { UserProfilePopover } from '../components/UserProfilePopover';
|
import { UserProfilePopover } from '../components/UserProfilePopover';
|
||||||
@@ -49,9 +52,15 @@ import {
|
|||||||
createWatchTogetherPayload,
|
createWatchTogetherPayload,
|
||||||
createGamePayload,
|
createGamePayload,
|
||||||
} from '../lib/conversationFeatures';
|
} from '../lib/conversationFeatures';
|
||||||
import { WhiteboardModal } from '../components/WhiteboardModal';
|
const WhiteboardModal = lazy(() =>
|
||||||
import { WatchTogetherModal } from '../components/WatchTogetherModal';
|
import('../components/WhiteboardModal').then((m) => ({ default: m.WhiteboardModal })),
|
||||||
import { GameModal } from '../components/GameModal';
|
);
|
||||||
|
const WatchTogetherModal = lazy(() =>
|
||||||
|
import('../components/WatchTogetherModal').then((m) => ({ default: m.WatchTogetherModal })),
|
||||||
|
);
|
||||||
|
const GameModal = lazy(() =>
|
||||||
|
import('../components/GameModal').then((m) => ({ default: m.GameModal })),
|
||||||
|
);
|
||||||
import { createWhiteboard, createWatchSession, parseYouTubeUrl, createGame, type GameType } from '@chat-app/shared/chat';
|
import { createWhiteboard, createWatchSession, parseYouTubeUrl, createGame, type GameType } from '@chat-app/shared/chat';
|
||||||
import { compressImages } from '../lib/imageCompress';
|
import { compressImages } from '../lib/imageCompress';
|
||||||
import { ensureInstallId } from '../lib/installId';
|
import { ensureInstallId } from '../lib/installId';
|
||||||
@@ -70,6 +79,12 @@ import { useTypingChannel } from '../lib/useTypingChannel';
|
|||||||
|
|
||||||
const STICK_THRESHOLD = 80;
|
const STICK_THRESHOLD = 80;
|
||||||
|
|
||||||
|
// Stable empty-reactions sentinel. We pass this when a message has no
|
||||||
|
// reactions instead of `[]` literal — a fresh array per render would defeat
|
||||||
|
// `React.memo` on `MessageBubble` since the `reactions` prop reference would
|
||||||
|
// change on every parent render.
|
||||||
|
const EMPTY_REACTIONS: AggregatedReaction[] = [];
|
||||||
|
|
||||||
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
|
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
|
||||||
// of ConversationPage when the route param (`id`) changes — switching
|
// of ConversationPage when the route param (`id`) changes — switching
|
||||||
// chats unmounts/remounts the page in our router setup. Session-only
|
// chats unmounts/remounts the page in our router setup. Session-only
|
||||||
@@ -361,6 +376,20 @@ export function ConversationPage() {
|
|||||||
[messageById, senderNameFor, t],
|
[messageById, senderNameFor, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Pre-compute quoted refs per message into a stable map. Calling
|
||||||
|
// `buildQuoted(m.replyToId)` inline inside the `.map` returned a fresh
|
||||||
|
// object on every parent render, defeating `React.memo` on MessageBubble.
|
||||||
|
// With the map memoized on the same deps as `buildQuoted`, each bubble
|
||||||
|
// gets a stable `quoted` reference until the underlying data actually
|
||||||
|
// changes (new messages, sender renames, language switch).
|
||||||
|
const quotedByMessage = useMemo(() => {
|
||||||
|
const out = new Map<string, QuotedRef | null>();
|
||||||
|
for (const m of messages) {
|
||||||
|
out.set(m.id, buildQuoted(m.replyToId));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [messages, buildQuoted]);
|
||||||
|
|
||||||
const jumpToMessage = useCallback((targetId: string) => {
|
const jumpToMessage = useCallback((targetId: string) => {
|
||||||
const el = scrollRef.current?.querySelector<HTMLElement>(
|
const el = scrollRef.current?.querySelector<HTMLElement>(
|
||||||
'[data-message-id="' + CSS.escape(targetId) + '"]',
|
'[data-message-id="' + CSS.escape(targetId) + '"]',
|
||||||
@@ -380,6 +409,19 @@ export function ConversationPage() {
|
|||||||
setForwardTarget(m);
|
setForwardTarget(m);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Stable handler for MessageBubble's `onAvatarClick`. Previously this was
|
||||||
|
// an inline arrow in the `.map`, which gave every row a fresh callback ref
|
||||||
|
// and defeated `React.memo` on the bubble (every parent re-render — every
|
||||||
|
// keystroke in the composer — re-rendered all 200 bubbles).
|
||||||
|
const handleAvatarClick = useCallback((uid: string, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setProfilePopover({
|
||||||
|
userId: uid,
|
||||||
|
x: ev.clientX,
|
||||||
|
y: ev.clientY,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const searchActive = useMemo(
|
const searchActive = useMemo(
|
||||||
() =>
|
() =>
|
||||||
searchQuery.trim().length > 0 ||
|
searchQuery.trim().length > 0 ||
|
||||||
@@ -904,9 +946,9 @@ export function ConversationPage() {
|
|||||||
senderDisplayName={senderProfile?.displayName}
|
senderDisplayName={senderProfile?.displayName}
|
||||||
senderAvatarUrl={senderProfile?.avatarUrl}
|
senderAvatarUrl={senderProfile?.avatarUrl}
|
||||||
conversationId={id ?? ''}
|
conversationId={id ?? ''}
|
||||||
reactions={reactionsByMessage.get(m.id) ?? []}
|
reactions={reactionsByMessage.get(m.id) ?? EMPTY_REACTIONS}
|
||||||
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
onToggleReaction={toggleReaction}
|
||||||
onVotePoll={(emoji, optionEmojis) => votePoll(m.id, emoji, optionEmojis)}
|
onVotePoll={votePoll}
|
||||||
showSeen={m.id === lastSeenMessageId}
|
showSeen={m.id === lastSeenMessageId}
|
||||||
{...(m.senderId === myId
|
{...(m.senderId === myId
|
||||||
? {
|
? {
|
||||||
@@ -921,18 +963,11 @@ export function ConversationPage() {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
quoted={buildQuoted(m.replyToId)}
|
quoted={quotedByMessage.get(m.id) ?? null}
|
||||||
onJumpToMessage={jumpToMessage}
|
onJumpToMessage={jumpToMessage}
|
||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
onAvatarClick={(uid, ev) => {
|
onAvatarClick={handleAvatarClick}
|
||||||
ev.stopPropagation();
|
|
||||||
setProfilePopover({
|
|
||||||
userId: uid,
|
|
||||||
x: ev.clientX,
|
|
||||||
y: ev.clientY,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
highlighted={highlightedId === m.id}
|
highlighted={highlightedId === m.id}
|
||||||
isPinned={pinnedIds.has(m.id)}
|
isPinned={pinnedIds.has(m.id)}
|
||||||
onTogglePin={handleTogglePin}
|
onTogglePin={handleTogglePin}
|
||||||
@@ -1321,6 +1356,7 @@ export function ConversationPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<ImageAnnotator
|
<ImageAnnotator
|
||||||
file={attachments[annotatingIndex]!}
|
file={attachments[annotatingIndex]!}
|
||||||
onCancel={() => setAnnotatingIndex(null)}
|
onCancel={() => setAnnotatingIndex(null)}
|
||||||
@@ -1329,27 +1365,34 @@ export function ConversationPage() {
|
|||||||
setAnnotatingIndex(null);
|
setAnnotatingIndex(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{openWhiteboardId && (
|
{openWhiteboardId && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<WhiteboardModal
|
<WhiteboardModal
|
||||||
whiteboardId={openWhiteboardId}
|
whiteboardId={openWhiteboardId}
|
||||||
onClose={() => setOpenWhiteboardId(null)}
|
onClose={() => setOpenWhiteboardId(null)}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{openWatchSessionId && (
|
{openWatchSessionId && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<WatchTogetherModal
|
<WatchTogetherModal
|
||||||
sessionId={openWatchSessionId}
|
sessionId={openWatchSessionId}
|
||||||
onClose={() => setOpenWatchSessionId(null)}
|
onClose={() => setOpenWatchSessionId(null)}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{openGameId && (
|
{openGameId && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<GameModal
|
<GameModal
|
||||||
gameId={openGameId}
|
gameId={openGameId}
|
||||||
onClose={() => setOpenGameId(null)}
|
onClose={() => setOpenGameId(null)}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gameDialogOpen && (
|
{gameDialogOpen && (
|
||||||
|
|||||||
@@ -85,9 +85,10 @@
|
|||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.001ms !important;
|
||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.001ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# Phase 6 — Performance Pack
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. This plan is grouped into 3 risk tiers — Group A is parallel-safe quick wins, Group B is medium-scope, Group C is audits.
|
||||||
|
|
||||||
|
**Goal:** A focused performance pass after the fifteen-features initiative shipped. Faster startup, smoother long chats, smaller bundle, less main-thread blocking on PIN-unlock, no UX regressions.
|
||||||
|
|
||||||
|
**Rollback anchor:** tag `pre-phase6-perf` → `888ed1b` (already pushed to origin).
|
||||||
|
|
||||||
|
**Strategy:** ship Group A first (5 tiny safe wins ≈ 5h), pause + smoke-test, then B (medium ≈ 2-3 days), then C (audits ≈ 1 day). No release between groups; one combined release at the very end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group A — Safe quick wins (~5h, low risk)
|
||||||
|
|
||||||
|
### T1: Lazy-load four fat modals
|
||||||
|
|
||||||
|
**What:** Convert eager imports of `WhiteboardModal`, `WatchTogetherModal`, `ImageAnnotator`, `GameModal` to `React.lazy(() => import(...))` inside `ConversationPage.tsx`. Wrap each conditional render in `<Suspense fallback={null}>`.
|
||||||
|
|
||||||
|
**Why:** These modals total ~200-300 KB (canvas-confetti dep, IFrame player loader, ImageAnnotator's full op-stack, etc.) and render in <1 % of sessions. Initial bundle drops by that amount → faster cold load.
|
||||||
|
|
||||||
|
**Files:** `apps/desktop/src/pages/ConversationPage.tsx` only.
|
||||||
|
|
||||||
|
**Risk:** trivial. `Suspense` with `fallback={null}` means a few ms blank flicker the first time each modal opens (chunk download). Acceptable.
|
||||||
|
|
||||||
|
**Effort:** ~30 min.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T2: `prefers-reduced-motion` global rule
|
||||||
|
|
||||||
|
**What:**
|
||||||
|
- Global CSS rule in `apps/desktop/src/index.css` (or wherever global styles live): `@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } }`.
|
||||||
|
- Gate the confetti burst in `GameModal.tsx` behind `window.matchMedia('(prefers-reduced-motion: reduce)').matches`.
|
||||||
|
|
||||||
|
**Why:** Accessibility + CPU savings for users who set the OS preference. Confetti is the most visible offender.
|
||||||
|
|
||||||
|
**Risk:** very low. Tailwind already respects motion-reduce variants in some classes; this is the global default.
|
||||||
|
|
||||||
|
**Effort:** ~30 min.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T3: Memoize `MessageBubble` + audit callback stability
|
||||||
|
|
||||||
|
**What:**
|
||||||
|
- Wrap `MessageBubble` export in `React.memo` with shallow equality (default).
|
||||||
|
- Audit the message-list render site (ConversationPage or a MessagesList component) — every callback prop passed into the row (`onReply`, `onPin`, `onDelete`, …) must be `useCallback`-stable with no per-render closures. Replace anonymous `() => doX(message.id)` patterns with stable handlers that receive the id at call time.
|
||||||
|
|
||||||
|
**Why:** Typing in the composer currently re-runs the entire `messages.map(...)` and re-renders every bubble. With memoization + stable callbacks, only the new bubble appears; existing rows stay mounted. Big win on long chats.
|
||||||
|
|
||||||
|
**Risk:** medium-low. Possible bugs if a callback captures stale state (e.g. closure over `pinnedSet` that doesn't update). Mitigation: pass volatile state as props on the bubble and let `React.memo` handle the diff.
|
||||||
|
|
||||||
|
**Effort:** ~2h.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T4: Memoize icon components (pragmatic "sprite-sheet" alternative)
|
||||||
|
|
||||||
|
**What:** Original idea was a real SVG sprite-sheet (single `<svg>` with `<symbol>` defs + `<use href="#name">`). Pragmatic alternative: wrap every icon component in `React.memo`. They're pure functions of `className`/`...props` so memoization is free, and 90 % of the perf win (avoiding React reconciliation on identical icon trees) comes from this without the sprite refactor risk.
|
||||||
|
|
||||||
|
**Files:** `apps/desktop/src/components/icons.tsx` (or `icons/` folder — whichever the codebase uses).
|
||||||
|
|
||||||
|
**Why:** Real sprite-sheet is invasive (refactor 60+ icon usages, change className/fill inheritance). Memoizing achieves the bulk of the win at <30 min effort. Real sprite-sheet stays available as a follow-up if bundle-analyzer (T11) shows icons are a top-3 bundle hog.
|
||||||
|
|
||||||
|
**Risk:** none — `React.memo` is purely a perf hint.
|
||||||
|
|
||||||
|
**Effort:** ~30 min.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T5: Pre-warm Supabase + avatar loading hints
|
||||||
|
|
||||||
|
**What:**
|
||||||
|
- In `AuthContext.tsx`, fire one trivial query early (e.g. `supabase.from('profiles').select('id').limit(1)`) so the connection is warm by the time the user does anything.
|
||||||
|
- Audit `<img>` tags for avatars: add `loading="lazy"` to off-screen ones (chat list rows below the fold, deep history) and keep `loading="eager"` only for above-the-fold (current conv header, top of chat list).
|
||||||
|
|
||||||
|
**Why:** First real query after login currently pays cold-connection latency (~100-200 ms). Pre-warm hides it. Lazy avatars stop the browser from hammering Supabase Storage on initial render.
|
||||||
|
|
||||||
|
**Risk:** none.
|
||||||
|
|
||||||
|
**Effort:** ~1h.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Group A final gate
|
||||||
|
|
||||||
|
- [ ] `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
|
||||||
|
- [ ] `pnpm --filter @chat-app/shared test -- --run` (still 71/71)
|
||||||
|
- [ ] User smoke-test: cold-start the app, send a few messages, type in composer, open one of the 4 modals — verify nothing broke + the visible improvements (faster initial render, smoother typing in long chats).
|
||||||
|
- [ ] Tag `phase6a-done` for incremental rollback granularity if Group B introduces issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group B — Medium scope (~2-3 days, moderate risk)
|
||||||
|
|
||||||
|
### T6: Web-Worker for Argon2 + crypto_box_open (PIN-unlock path)
|
||||||
|
|
||||||
|
**What:**
|
||||||
|
- Create `apps/desktop/src/lib/workers/crypto.worker.ts` that imports libsodium-wrappers and exposes a postMessage RPC: `{ op: 'unsealUserKey', sealedKey, pin, salt, kdfParams }` → `{ privateKey: Uint8Array }` (transferred).
|
||||||
|
- Build with Vite's worker syntax: `new Worker(new URL('./workers/crypto.worker.ts', import.meta.url), { type: 'module' })`.
|
||||||
|
- Refactor `apps/desktop/src/lib/userIdentity.ts`'s `unlockUserKey` (and any other hot Argon2 callers) to call the worker instead of the inline crypto backend.
|
||||||
|
|
||||||
|
**Why:** PIN-unlock currently runs Argon2id (~1-2 sec on mid hardware) on the main thread → UI freeze during login. Worker offloads it, login screen stays responsive.
|
||||||
|
|
||||||
|
**Risk:** medium. libsodium-wrappers needs to be initialized in both contexts. structured-clone transfers `Uint8Array` cleanly. The risk is that libsodium-wrappers might ship a bigger worker bundle than expected (we accept the trade-off because the main bundle gets smaller too).
|
||||||
|
|
||||||
|
**Effort:** ~1 day. Includes typing the postMessage RPC + ensuring the existing PIN-unlock flow keeps its error semantics (wrong PIN, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T7: WebP thumbnails for image attachments
|
||||||
|
|
||||||
|
**What:**
|
||||||
|
- When sending an image attachment: in addition to encrypting+uploading the full image (`<convId>/<attachmentId>.bin`), generate a 320×320 max-dim WebP thumb via `<canvas>.toBlob({ type: 'image/webp', quality: 0.7 })`, encrypt with the SAME per-attachment key, upload to `<convId>/<attachmentId>-thumb.bin`.
|
||||||
|
- `MessageBubble` image render: try downloading the thumb first; fall back to full image on 404 (graceful for pre-Phase-6 attachments).
|
||||||
|
- Click-to-expand: fetch the full image.
|
||||||
|
|
||||||
|
**Why:** A 5 MB image in the chat scroll loads 5 MB even off-screen. Thumb is ~10-30 KB. Scroll is silky, bandwidth drops 99 %.
|
||||||
|
|
||||||
|
**Files:** `packages/shared/src/chat/attachments.ts` (extend `encryptAndUploadAttachment` to optionally generate+upload thumb), `apps/desktop/src/components/MessageBubble.tsx` (try-thumb-first logic), maybe `Lightbox.tsx` (full image on click).
|
||||||
|
|
||||||
|
**Schema:** none — naming-convention based, 404-fallback preserves backward compat.
|
||||||
|
|
||||||
|
**Risk:** low-medium. Edge cases: very small images (thumb is bigger than full → skip thumb gen), animated GIFs (don't generate static-frame thumb, just use full).
|
||||||
|
|
||||||
|
**Effort:** ~½ day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T8: Virtual-scroll for message list
|
||||||
|
|
||||||
|
**What:**
|
||||||
|
- `pnpm --filter @chat-app/desktop add react-virtuoso`
|
||||||
|
- Replace the message-list `.map(...)` in (likely) `ConversationPage.tsx` / `MessagesList.tsx` with `<Virtuoso>`.
|
||||||
|
- Configure: `data={messages}`, `itemContent={(_, msg) => <MessageBubble ... />}`, `followOutput="smooth"` for auto-scroll on new messages, `initialTopMostItemIndex={messages.length - 1}` to start at bottom.
|
||||||
|
- If date-day headers exist: switch to `<GroupedVirtuoso>` with `groupCounts` + `groupContent`.
|
||||||
|
|
||||||
|
**Why:** Long conversations (1000+ messages) currently render all rows → scroll jank, layout thrashing. Virtuoso renders only visible rows + a small overscan buffer.
|
||||||
|
|
||||||
|
**Risk:** medium-high. Things that can go wrong:
|
||||||
|
- Scroll-anchor preservation when Pinned-Messages panel opens.
|
||||||
|
- Auto-scroll-to-bottom on send.
|
||||||
|
- Smooth-scroll-to-message when clicking a pin or a reply.
|
||||||
|
- Image-load reflow (Virtuoso handles this but needs proper height detection).
|
||||||
|
|
||||||
|
Mitigation: thorough manual smoke-test before commit. Keep the old render behind a feature flag for one release if jitters appear.
|
||||||
|
|
||||||
|
**Effort:** ~½ day to 1 day depending on edge cases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T9: PIN-Idle-Auto-Lock
|
||||||
|
|
||||||
|
**What:**
|
||||||
|
- Settings → Sicherheit: new toggle "Auto-Lock nach Inaktivität" + dropdown (5 / 15 / 30 / 60 Minuten). Default OFF.
|
||||||
|
- localStorage key `chatapp.autoLockMinutes` (or similar) — added to `PRESERVE_LOCAL_STORAGE` so memory-wipe doesn't disable the setting silently (same pattern as wipe-on-close toggle).
|
||||||
|
- In `AuthContext` (or a new top-level hook): listen on `keydown` / `mousedown` / `pointermove`, reset a timer on each event. When the timer fires: `wipeLocalState(uid)` + navigate to `/device` (the PIN-unlock screen).
|
||||||
|
|
||||||
|
**Why:** Spec mentioned this as polish + a Security win — laptop left unattended, auto-locks after X min, attacker can't read messages without PIN.
|
||||||
|
|
||||||
|
**Risk:** low. The wipe-on-close infrastructure (P1.T12-T13) already handles all the local-state clearing — same call site.
|
||||||
|
|
||||||
|
**Effort:** ~½ day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10: i18next tree-shake audit
|
||||||
|
|
||||||
|
**What:**
|
||||||
|
- `pnpm --filter @chat-app/desktop add -D i18next-parser`
|
||||||
|
- Configure it to scan `apps/desktop/src/**/*.{ts,tsx}` for `t('app:...')` calls + extract used keys.
|
||||||
|
- Diff against `apps/desktop/locales/de/app.json` (or wherever the resource files live). List dead keys.
|
||||||
|
- Prune them. Verify nothing visible regresses.
|
||||||
|
|
||||||
|
**Why:** Resource files accumulate keys from removed/redesigned features. Smaller resource bundle = faster app start (in-memory JSON parse).
|
||||||
|
|
||||||
|
**Risk:** low — `t()` always falls back to `defaultValue` if a key is missing, so even an accidental over-prune doesn't crash the UI; it just shows the German default.
|
||||||
|
|
||||||
|
**Effort:** ~2h (mostly looking at the diff + judgment calls).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Group B final gate
|
||||||
|
|
||||||
|
- [ ] Both typechecks green
|
||||||
|
- [ ] All shared tests green
|
||||||
|
- [ ] User smoke-test: cold start (Argon2 worker), open a long chat (virtual scroll), send an image (thumb generation), idle 5+min (auto-lock if enabled), check console for noise.
|
||||||
|
- [ ] Tag `phase6b-done`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group C — Audits + judgment calls (~1 day)
|
||||||
|
|
||||||
|
### T11: Bundle-analyzer audit + targeted dep swaps
|
||||||
|
|
||||||
|
**What:**
|
||||||
|
- `pnpm dlx vite-bundle-visualizer` against the desktop build → outputs HTML report.
|
||||||
|
- Review the treemap. Common offenders to check:
|
||||||
|
- Full lodash vs lodash-es (or no lodash at all if only a few utils)
|
||||||
|
- Moment.js vs date-fns / native `Intl.DateTimeFormat`
|
||||||
|
- Multiple realtime/socket clients
|
||||||
|
- Icon libs pulling all icons
|
||||||
|
- Dev-only deps accidentally in prod bundle
|
||||||
|
- Apply targeted swaps (max ~3-5) based on the worst findings.
|
||||||
|
|
||||||
|
**Why:** Shrinks bundle further beyond T1's lazy-load. Each ~50 KB shaved is a real cold-start win.
|
||||||
|
|
||||||
|
**Files:** `apps/desktop/package.json`, the consumer files that import from swapped deps.
|
||||||
|
|
||||||
|
**Risk:** variable per swap. A Moment-to-date-fns swap touches many call sites. Cap at the 3 biggest offenders to keep risk bounded.
|
||||||
|
|
||||||
|
**Effort:** ~2h audit + variable fixes (estimate 2-3h additional).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T12: Optimistic-UI audit + targeted gap fills
|
||||||
|
|
||||||
|
**What:**
|
||||||
|
- Audit each user-write action across the app:
|
||||||
|
- `send` (message) → likely already optimistic; verify
|
||||||
|
- `editEncryptedMessage` → likely already optimistic
|
||||||
|
- Pin / unpin
|
||||||
|
- Add / remove reaction (doesn't exist yet — skip)
|
||||||
|
- Vote on poll
|
||||||
|
- Revoke device
|
||||||
|
- Toggle mentions-only
|
||||||
|
- Toggle mute
|
||||||
|
- For each action that currently waits for the server roundtrip before updating local state: add optimistic-update with rollback on error.
|
||||||
|
|
||||||
|
**Why:** Perceived latency drops to ~0 ms for most clicks. Server roundtrip happens silently.
|
||||||
|
|
||||||
|
**Risk:** medium. Each optimistic-update is its own potential rollback bug. Mitigation: only touch actions where rollback is straightforward (e.g. a toggle's previous state is trivially recoverable). Skip if rollback is hairy.
|
||||||
|
|
||||||
|
**Effort:** ~1 day total (each action is ~30-60 min including verification).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Group C final gate
|
||||||
|
|
||||||
|
- [ ] Both typechecks green, all shared tests green
|
||||||
|
- [ ] Bundle size measured before/after (note in report)
|
||||||
|
- [ ] User smoke-test of any actions that gained optimistic UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred / skipped (with reasoning)
|
||||||
|
|
||||||
|
### Realtime-Channel-Pooling
|
||||||
|
|
||||||
|
**Skipped for now.** The current UI keeps only one conversation actively open at a time. Concurrent channels at steady state are typically 5-8 (auth-self, conversations-list, current conv messages, current conv typing, mentions, maybe whiteboard / game / watch). Pooling into a single multiplexed channel would require a manager singleton + per-call-site refactor (~15-20 sites), with a meaningful risk of subtle realtime bugs during the transition.
|
||||||
|
|
||||||
|
**Reconsider when:** sustained active channel count exceeds 15, or Supabase invoices a noticeable channel-quota line item. Then a focused 1-day refactor with thorough realtime smoke testing makes sense.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release strategy
|
||||||
|
|
||||||
|
- No `pnpm release` between Group A/B/C — single combined release after Group C (or earlier if Group B+C get deferred).
|
||||||
|
- Suggested version when releasing: `0.20.0` (combines unreleased Phase 5 + 5C + Phase 6).
|
||||||
|
- Rollback at any commit boundary via `git reset --hard pre-phase6-perf` (Group A) or `git reset --hard phase6a-done` / `phase6b-done` (per-group).
|
||||||
Reference in New Issue
Block a user