Merge branch 'dev' into msc4133

This commit is contained in:
Ginger 2025-09-22 11:46:32 -04:00 committed by GitHub
commit 458b1c0172
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1084 additions and 852 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,34 @@
import { as, Box, Text } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import * as css from './styles.css';
export const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`;
export const EmojiGroup = as<
'div',
{
id: string;
label: string;
children: ReactNode;
}
>(({ className, id, label, children, ...props }, ref) => (
<Box
id={getDOMGroupId(id)}
data-group-id={id}
className={classNames(css.EmojiGroup, className)}
direction="Column"
gap="200"
{...props}
ref={ref}
>
<Text id={`EmojiGroup-${id}-label`} as="label" className={css.EmojiGroupLabel} size="O400">
{label}
</Text>
<div aria-labelledby={`EmojiGroup-${id}-label`} className={css.EmojiGroupContent}>
<Box wrap="Wrap" justifyContent="Center">
{children}
</Box>
</div>
</Box>
));

View file

@ -0,0 +1,105 @@
import React from 'react';
import { Box } from 'folds';
import { MatrixClient } from 'matrix-js-sdk';
import { EmojiItemInfo, EmojiType } from '../types';
import * as css from './styles.css';
import { PackImageReader } from '../../../plugins/custom-emoji';
import { IEmoji } from '../../../plugins/emoji';
import { mxcUrlToHttp } from '../../../utils/matrix';
export const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => {
const label = element.getAttribute('title');
const type = element.getAttribute('data-emoji-type') as EmojiType | undefined;
const data = element.getAttribute('data-emoji-data');
const shortcode = element.getAttribute('data-emoji-shortcode');
if (type && data && shortcode && label)
return {
type,
data,
shortcode,
label,
};
return undefined;
};
type EmojiItemProps = {
emoji: IEmoji;
};
export function EmojiItem({ emoji }: EmojiItemProps) {
return (
<Box
as="button"
type="button"
alignItems="Center"
justifyContent="Center"
className={css.EmojiItem}
title={emoji.label}
aria-label={`${emoji.label} emoji`}
data-emoji-type={EmojiType.Emoji}
data-emoji-data={emoji.unicode}
data-emoji-shortcode={emoji.shortcode}
>
{emoji.unicode}
</Box>
);
}
type CustomEmojiItemProps = {
mx: MatrixClient;
useAuthentication?: boolean;
image: PackImageReader;
};
export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiItemProps) {
return (
<Box
as="button"
type="button"
alignItems="Center"
justifyContent="Center"
className={css.EmojiItem}
title={image.body || image.shortcode}
aria-label={`${image.body || image.shortcode} emoji`}
data-emoji-type={EmojiType.CustomEmoji}
data-emoji-data={image.url}
data-emoji-shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.CustomEmojiImg}
alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
/>
</Box>
);
}
type StickerItemProps = {
mx: MatrixClient;
useAuthentication?: boolean;
image: PackImageReader;
};
export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) {
return (
<Box
as="button"
type="button"
alignItems="Center"
justifyContent="Center"
className={css.StickerItem}
title={image.body || image.shortcode}
aria-label={`${image.body || image.shortcode} emoji`}
data-emoji-type={EmojiType.Sticker}
data-emoji-data={image.url}
data-emoji-shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.StickerImg}
alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
/>
</Box>
);
}

View file

@ -0,0 +1,30 @@
import { as, Box, Line } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import * as css from './styles.css';
export const EmojiBoardLayout = as<
'div',
{
header: ReactNode;
sidebar?: ReactNode;
children: ReactNode;
}
>(({ className, header, sidebar, children, ...props }, ref) => (
<Box
display="InlineFlex"
className={classNames(css.Base, className)}
direction="Row"
{...props}
ref={ref}
>
<Box direction="Column" grow="Yes">
<Box className={css.Header} direction="Column" shrink="No">
{header}
</Box>
{children}
</Box>
<Line size="300" direction="Vertical" />
{sidebar}
</Box>
));

View file

@ -0,0 +1,22 @@
import React from 'react';
import { Box, toRem, config, Icons, Icon, Text } from 'folds';
export function NoStickerPacks() {
return (
<Box
style={{ padding: `${toRem(60)} ${config.space.S500}` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="300"
>
<Icon size="600" src={Icons.Sticker} />
<Box direction="Inherit">
<Text align="Center">No Sticker Packs!</Text>
<Text priority="300" align="Center" size="T200">
Add stickers from user, room or space settings.
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,53 @@
import { Box, Text } from 'folds';
import React from 'react';
import { Atom, atom, useAtomValue } from 'jotai';
import * as css from './styles.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../../utils/matrix';
export type PreviewData = {
key: string;
shortcode: string;
};
export const createPreviewDataAtom = (initial?: PreviewData) =>
atom<PreviewData | undefined>(initial);
type PreviewProps = {
previewAtom: Atom<PreviewData | undefined>;
};
export function Preview({ previewAtom }: PreviewProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { key, shortcode } = useAtomValue(previewAtom) ?? {};
if (!shortcode) return null;
return (
<Box shrink="No" className={css.Preview} gap="300" alignItems="Center">
{key && (
<Box
display="InlineFlex"
className={css.PreviewEmoji}
alignItems="Center"
justifyContent="Center"
>
{key.startsWith('mxc://') ? (
<img
className={css.PreviewImg}
src={mxcUrlToHttp(mx, key, useAuthentication) ?? key}
alt={shortcode}
/>
) : (
key
)}
</Box>
)}
<Text size="H5" truncate>
:{shortcode}:
</Text>
</Box>
);
}

View file

@ -0,0 +1,51 @@
import React, { ChangeEventHandler, useRef } from 'react';
import { Input, Chip, Icon, Icons, Text } from 'folds';
import { mobileOrTablet } from '../../../utils/user-agent';
type SearchInputProps = {
query?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
allowTextCustomEmoji?: boolean;
onTextCustomEmojiSelect?: (text: string) => void;
};
export function SearchInput({
query,
onChange,
allowTextCustomEmoji,
onTextCustomEmojiSelect,
}: SearchInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleReact = () => {
const textEmoji = inputRef.current?.value.trim();
if (!textEmoji) return;
onTextCustomEmojiSelect?.(textEmoji);
};
return (
<Input
ref={inputRef}
variant="SurfaceVariant"
size="400"
placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'}
maxLength={50}
after={
allowTextCustomEmoji && query ? (
<Chip
variant="Primary"
radii="Pill"
after={<Icon src={Icons.ArrowRight} size="50" />}
outlined
onClick={handleReact}
>
<Text size="L400">React</Text>
</Chip>
) : (
<Icon src={Icons.Search} size="50" />
)
}
onChange={onChange}
autoFocus={!mobileOrTablet()}
/>
);
}

View file

@ -0,0 +1,130 @@
import React, { ReactNode } from 'react';
import {
Box,
Scroll,
Line,
as,
TooltipProvider,
Tooltip,
Text,
IconButton,
Icon,
IconSrc,
Icons,
} from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
export function Sidebar({ children }: { children: ReactNode }) {
return (
<Box className={css.Sidebar} shrink="No">
<Scroll size="0">
<Box className={css.SidebarContent} direction="Column" alignItems="Center" gap="100">
{children}
</Box>
</Scroll>
</Box>
);
}
export const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => (
<Box
className={classNames(css.SidebarStack, className)}
direction="Column"
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
{children}
</Box>
));
export function SidebarDivider() {
return <Line className={css.SidebarDivider} size="300" variant="Surface" />;
}
function SidebarBtn<T extends string>({
active,
label,
id,
onClick,
children,
}: {
active?: boolean;
label: string;
id: T;
onClick: (id: T) => void;
children: ReactNode;
}) {
return (
<TooltipProvider
delay={500}
position="Left"
tooltip={
<Tooltip id={`SidebarStackItem-${id}-label`}>
<Text size="T300">{label}</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton
aria-pressed={active}
aria-labelledby={`SidebarStackItem-${id}-label`}
ref={ref}
onClick={() => onClick(id)}
size="400"
radii="300"
variant="Surface"
>
{children}
</IconButton>
)}
</TooltipProvider>
);
}
type GroupIconProps<T extends string> = {
active: boolean;
id: T;
label: string;
icon: IconSrc;
onClick: (id: T) => void;
};
export function GroupIcon<T extends string>({
active,
id,
label,
icon,
onClick,
}: GroupIconProps<T>) {
return (
<SidebarBtn active={active} id={id} label={label} onClick={onClick}>
<Icon src={icon} filled={active} />
</SidebarBtn>
);
}
type ImageGroupIconProps<T extends string> = {
active: boolean;
id: T;
label: string;
url?: string;
onClick: (id: T) => void;
};
export function ImageGroupIcon<T extends string>({
active,
id,
label,
url,
onClick,
}: ImageGroupIconProps<T>) {
return (
<SidebarBtn active={active} id={id} label={label} onClick={onClick}>
{url ? (
<img className={css.SidebarBtnImg} src={url} alt={label} />
) : (
<Icon src={Icons.Photo} filled={active} />
)}
</SidebarBtn>
);
}

View file

@ -0,0 +1,44 @@
import React, { CSSProperties } from 'react';
import { Badge, Box, Text } from 'folds';
import { EmojiBoardTab } from '../types';
const styles: CSSProperties = {
cursor: 'pointer',
};
export function EmojiBoardTabs({
tab,
onTabChange,
}: {
tab: EmojiBoardTab;
onTabChange: (tab: EmojiBoardTab) => void;
}) {
return (
<Box gap="100">
<Badge
style={styles}
as="button"
variant="Secondary"
fill={tab === EmojiBoardTab.Sticker ? 'Solid' : 'None'}
size="500"
onClick={() => onTabChange(EmojiBoardTab.Sticker)}
>
<Text as="span" size="L400">
Sticker
</Text>
</Badge>
<Badge
style={styles}
as="button"
variant="Secondary"
fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
size="500"
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
>
<Text as="span" size="L400">
Emoji
</Text>
</Badge>
</Box>
);
}

View file

@ -0,0 +1,8 @@
export * from './SearchInput';
export * from './Tabs';
export * from './Sidebar';
export * from './NoStickerPacks';
export * from './Preview';
export * from './Item';
export * from './Group';
export * from './Layout';

View file

@ -1,5 +1,9 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
import { toRem, color, config, DefaultReset, FocusOutline } from 'folds';
/**
* Layout
*/
export const Base = style({
maxWidth: toRem(432),
@ -13,6 +17,15 @@ export const Base = style({
overflow: 'hidden',
});
export const Header = style({
padding: config.space.S300,
paddingBottom: 0,
});
/**
* Sidebar
*/
export const Sidebar = style({
width: toRem(54),
backgroundColor: color.Surface.Container,
@ -29,26 +42,21 @@ export const SidebarStack = style({
backgroundColor: color.Surface.Container,
});
export const NativeEmojiSidebarStack = style({
position: 'sticky',
bottom: '-67%',
zIndex: 1,
});
export const SidebarDivider = style({
width: toRem(18),
});
export const Header = style({
padding: config.space.S300,
paddingBottom: 0,
export const SidebarBtnImg = style({
width: toRem(24),
height: toRem(24),
objectFit: 'contain',
});
export const EmojiBoardTab = style({
cursor: 'pointer',
});
/**
* Preview
*/
export const Footer = style({
export const Preview = style({
padding: config.space.S200,
margin: config.space.S300,
marginTop: 0,
@ -59,7 +67,30 @@ export const Footer = style({
color: color.SurfaceVariant.OnContainer,
});
export const PreviewEmoji = style([
DefaultReset,
{
width: toRem(32),
height: toRem(32),
fontSize: toRem(32),
lineHeight: toRem(32),
},
]);
export const PreviewImg = style([
DefaultReset,
{
width: toRem(32),
height: toRem(32),
objectFit: 'contain',
},
]);
/**
* Group
*/
export const EmojiGroup = style({
position: 'relative',
padding: `${config.space.S300} 0`,
});
@ -82,15 +113,9 @@ export const EmojiGroupContent = style([
},
]);
export const EmojiPreview = style([
DefaultReset,
{
width: toRem(32),
height: toRem(32),
fontSize: toRem(32),
lineHeight: toRem(32),
},
]);
/**
* Item
*/
export const EmojiItem = style([
DefaultReset,

View file

@ -1 +1,2 @@
export * from './EmojiBoard';
export * from './types';

View file

@ -0,0 +1,17 @@
export enum EmojiBoardTab {
Emoji = 'Emoji',
Sticker = 'Sticker',
}
export enum EmojiType {
Emoji = 'emoji',
CustomEmoji = 'customEmoji',
Sticker = 'sticker',
}
export type EmojiItemInfo = {
type: EmojiType;
data: string;
shortcode: string;
label: string;
};

View file

@ -1,6 +1,6 @@
import { Box, Icon, IconSrc } from 'folds';
import React, { ReactNode } from 'react';
import { CompactLayout, ModernLayout } from '..';
import { BubbleLayout, CompactLayout, ModernLayout } from '..';
import { MessageLayout } from '../../../state/settings';
export type EventContentProps = {
@ -30,9 +30,15 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
</Box>
);
return messageLayout === MessageLayout.Compact ? (
<CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
) : (
<ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
);
if (messageLayout === MessageLayout.Compact) {
return <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>;
}
if (messageLayout === MessageLayout.Bubble) {
return (
<BubbleLayout hideBubble before={beforeJSX}>
{msgContentJSX}
</BubbleLayout>
);
}
return <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>;
}

View file

@ -1,18 +1,63 @@
import React, { ReactNode } from 'react';
import { Box, as } from 'folds';
import classNames from 'classnames';
import { Box, ContainerColor, as, color } from 'folds';
import * as css from './layout.css';
type BubbleArrowProps = {
variant: ContainerColor;
};
function BubbleLeftArrow({ variant }: BubbleArrowProps) {
return (
<svg
className={css.BubbleLeftArrow}
width="9"
height="8"
viewBox="0 0 9 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.00004 8V0H4.82847C3.04666 0 2.15433 2.15428 3.41426 3.41421L8.00004 8H9.00004Z"
fill={color[variant].Container}
/>
</svg>
);
}
type BubbleLayoutProps = {
hideBubble?: boolean;
before?: ReactNode;
header?: ReactNode;
};
export const BubbleLayout = as<'div', BubbleLayoutProps>(({ before, children, ...props }, ref) => (
<Box gap="300" {...props} ref={ref}>
<Box className={css.BubbleBefore} shrink="No">
{before}
export const BubbleLayout = as<'div', BubbleLayoutProps>(
({ hideBubble, before, header, children, ...props }, ref) => (
<Box gap="300" {...props} ref={ref}>
<Box className={css.BubbleBefore} shrink="No">
{before}
</Box>
<Box grow="Yes" direction="Column">
{header}
{hideBubble ? (
children
) : (
<Box>
<Box
className={
hideBubble
? undefined
: classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined)
}
direction="Column"
>
{before ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
{children}
</Box>
</Box>
)}
</Box>
</Box>
<Box className={css.BubbleContent} direction="Column">
{children}
</Box>
</Box>
));
)
);

View file

@ -120,6 +120,7 @@ export const CompactHeader = style([
export const AvatarBase = style({
paddingTop: toRem(4),
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
display: 'flex',
alignSelf: 'start',
selectors: {
@ -133,14 +134,31 @@ export const ModernBefore = style({
minWidth: toRem(36),
});
export const BubbleBefore = style([ModernBefore]);
export const BubbleBefore = style({
minWidth: toRem(36),
});
export const BubbleContent = style({
maxWidth: toRem(800),
padding: config.space.S200,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
borderRadius: config.radii.R400,
borderRadius: config.radii.R500,
position: 'relative',
});
export const BubbleContentArrowLeft = style({
borderTopLeftRadius: 0,
});
export const BubbleLeftArrow = style({
width: toRem(9),
height: toRem(8),
position: 'absolute',
top: 0,
left: toRem(-8),
zIndex: 1,
});
export const Username = style({

View file

@ -723,6 +723,7 @@ export const Message = as<'div', MessageProps>(
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const senderId = mEvent.getSender() ?? '';
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
@ -790,7 +791,9 @@ export const Message = as<'div', MessageProps>(
);
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase>
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
>
<Avatar
className={css.MessageAvatar}
as="button"
@ -875,7 +878,9 @@ export const Message = as<'div', MessageProps>(
return (
<MessageBase
className={classNames(css.MessageBase, className)}
className={classNames(css.MessageBase, className, {
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
})}
tabIndex={0}
space={messageSpacing}
collapse={collapse}
@ -1132,8 +1137,7 @@ export const Message = as<'div', MessageProps>(
</CompactLayout>
)}
{messageLayout === MessageLayout.Bubble && (
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX}
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX}
</BubbleLayout>
)}

View file

@ -4,6 +4,9 @@ import { DefaultReset, config, toRem } from 'folds';
export const MessageBase = style({
position: 'relative',
});
export const MessageBaseBubbleCollapsed = style({
paddingTop: 0,
});
export const MessageOptionsBase = style([
DefaultReset,
@ -21,6 +24,10 @@ export const MessageOptionsBar = style([
},
]);
export const BubbleAvatarBase = style({
paddingTop: 0,
});
export const MessageAvatar = style({
cursor: 'pointer',
});

View file

@ -1,6 +1,7 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { color, config, DefaultReset, toRem } from 'folds';
import { ContainerColor } from './ContainerColor.css';
export const MarginSpaced = style({
marginBottom: config.space.S200,
@ -92,11 +93,14 @@ export const CodeBlock = style([
overflow: 'hidden',
},
]);
export const CodeBlockHeader = style({
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
gap: config.space.S200,
});
export const CodeBlockHeader = style([
ContainerColor({ variant: 'Surface' }),
{
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
gap: config.space.S200,
},
]);
export const CodeBlockInternal = style([
CodeFont,
{