mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-12 18:20:28 +03:00
Refactor timeline (#1346)
* fix intersection & resize observer * add binary search util * add scroll info util * add virtual paginator hook - WIP * render timeline using paginator hook * add continuous pagination to fill timeline * add doc comments in virtual paginator hook * add scroll to element func in virtual paginator * extract timeline pagination login into hook * add sliding name for timeline messages - testing * scroll with live event * change message rending style * make message timestamp smaller * remove unused imports * add random number between util * add compact message component * add sanitize html types * fix sending alias in room mention * get room member display name util * add get room with canonical alias util * add sanitize html util * render custom html with new styles * fix linkifying link text * add reaction component * display message reactions in timeline * Change mention color * show edited message * add event sent by function factory * add functions to get emoji shortcode * add component for reaction msg * add tooltip for who has reacted * add message layouts & placeholder * fix reaction size * fix dark theme colors * add code highlight with prismjs * add options to configure spacing in msgs * render message reply * fix trim reply from body regex * fix crash when loading reply * fix reply hover style * decrypt event on timeline paginate * update custom html code style * remove console logs * fix virtual paginator scroll to func * fix virtual paginator scroll to types * add stop scroll for in view item options * fix virtual paginator out of range scroll to index * scroll to and highlight reply on click * fix reply hover style * make message avatar clickable * fix scrollTo issue in virtual paginator * load reply from fetch * import virtual paginator restore scroll * load timeline for specific event * Fix back pagination recalibration * fix reply min height * revert code block colors to secondary * stop sanitizing text in code block * add decrypt file util * add image media component * update folds * fix code block font style * add msg event type * add scale dimension util * strict msg layout type * add image renderer component * add message content fallback components * add message matrix event renderer components * render matrix event using hooks * add attachment component * add attachment content types * handle error when rendering image in timeline * add video component * render video * include blurhash in thumbnails * generate thumbnails for image message * fix reactToDom spoiler opts * add hooks for HTMLMediaElement * render audio file in timeline * add msg image content component * fix image content props * add video content component * render new image/video component in timeline * remove console.log * convert seconds to milliseconds in video info * add load thumbnail prop to video content component * add file saver types * add file header component * add file content component * render file in timeline * add media control component * render audio message in room timeline * remove moved components * safely load message reply * add media loading hook * update media control layout * add loading indication in audio component * fill audio play icon when playing audio * fix media expanding * add image viewer - WIP * add pan and zoom control to image viewer * add text based file viewer * add pdf viewer * add error handling in pdf viewer * add download btn to pdf viewer * fix file button spinner fill * fix file opens on re-render * add range slider in audio content player * render location in timeline * update folds * display membership event in timeline * make reactions toggle * render sticker messages in timeline * render room name, topic, avatar change and event * fix typos * update render state event type style * add room intro in start of timeline * add power levels context * fix wrong param passing in RoomView * fix sending typing notification in wrong room Slate onChange callback was not updating with react re-renders. * send typing status on key up * add typing indicator component * add typing member atom * display typing status in member drawer * add room view typing member component * display typing members in room view * remove old roomTimeline uses * add event readers hook * add latest event hook * display following members in room view * fetch event instead of event context for reply * fix typo in virtual paginator hook * add scroll to latest btn in timeline * change scroll to latest chip variant * destructure paginator object to improve perf * restore forward dir scroll in virtual paginator * run scroll to bottom in layout effect * display unread message indicator in timeline * make component for room timeline float * add timeline divider component * add day divider and format message time * apply message spacing to dividers * format date in room intro * send read receipt on message arrive * add event readers component * add reply, read receipt, source delete opt * bug fixes * update timeline on delete & show reason * fix empty reaction container style * show msg selection effect on msg option open * add report message options * add options to send quick reactions * add emoji board in message options * add reaction viewer * fix styles * show view reaction in msg options menu * fix spacing between two msg by same person * add option menu in other rendered event * handle m.room.encrypted messages * fix italic reply text overflow cut * handle encrypted sticker messages * remove console log * prevent message context menu with alt key pressed * make mentions clickable in messages * add options to show and hidden events in timeline * add option to disable media autoload * remove old emojiboard opener * add options to use system emoji * refresh timeline on reset * fix stuck typing member in member drawer
This commit is contained in:
parent
fcd7723f73
commit
3a95d0da01
124 changed files with 9438 additions and 258 deletions
37
src/app/components/Pdf-viewer/PdfViewer.css.ts
Normal file
37
src/app/components/Pdf-viewer/PdfViewer.css.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config } from 'folds';
|
||||
|
||||
export const PdfViewer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const PdfViewerHeader = style([
|
||||
DefaultReset,
|
||||
{
|
||||
paddingLeft: config.space.S200,
|
||||
paddingRight: config.space.S200,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
flexShrink: 0,
|
||||
gap: config.space.S200,
|
||||
},
|
||||
]);
|
||||
export const PdfViewerFooter = style([
|
||||
PdfViewerHeader,
|
||||
{
|
||||
borderTopWidth: config.borderWidth.B300,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
export const PdfViewerContent = style([
|
||||
DefaultReset,
|
||||
{
|
||||
margin: 'auto',
|
||||
display: 'inline-block',
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
},
|
||||
]);
|
||||
257
src/app/components/Pdf-viewer/PdfViewer.tsx
Normal file
257
src/app/components/Pdf-viewer/PdfViewer.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||
import React, { FormEventHandler, useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Menu,
|
||||
PopOut,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
as,
|
||||
config,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import FileSaver from 'file-saver';
|
||||
import * as css from './PdfViewer.css';
|
||||
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
|
||||
|
||||
export type PdfViewerProps = {
|
||||
name: string;
|
||||
src: string;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
export const PdfViewer = as<'div', PdfViewerProps>(
|
||||
({ className, name, src, requestClose, ...props }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
|
||||
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
||||
const [docState, loadPdfDocument] = usePdfDocumentLoader(
|
||||
pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined,
|
||||
src
|
||||
);
|
||||
const isLoading =
|
||||
pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading;
|
||||
const isError =
|
||||
pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [openJump, setOpenJump] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadPdfJS();
|
||||
}, [loadPdfJS]);
|
||||
useEffect(() => {
|
||||
if (pdfJSState.status === AsyncStatus.Success) {
|
||||
loadPdfDocument();
|
||||
}
|
||||
}, [pdfJSState, loadPdfDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
if (docState.status === AsyncStatus.Success) {
|
||||
const doc = docState.data;
|
||||
if (pageNo < 0 || pageNo > doc.numPages) return;
|
||||
createPage(doc, pageNo, { scale: zoom }).then((canvas) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.textContent = '';
|
||||
container.append(canvas);
|
||||
scrollRef.current?.scrollTo({
|
||||
top: 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [docState, pageNo, zoom]);
|
||||
|
||||
const handleDownload = () => {
|
||||
FileSaver.saveAs(src, name);
|
||||
};
|
||||
|
||||
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (docState.status !== AsyncStatus.Success) return;
|
||||
const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement;
|
||||
if (!jumpInput) return;
|
||||
const jumpTo = parseInt(jumpInput.value, 10);
|
||||
setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
|
||||
setOpenJump(false);
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setPageNo((n) => Math.max(n - 1, 1));
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (docState.status !== AsyncStatus.Success) return;
|
||||
setPageNo((n) => Math.min(n + 1, docState.data.numPages));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
|
||||
<Header className={css.PdfViewerHeader} size="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<Icon size="50" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<IconButton
|
||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom < 1}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
onClick={zoomOut}
|
||||
aria-label="Zoom Out"
|
||||
>
|
||||
<Icon size="50" src={Icons.Minus} />
|
||||
</IconButton>
|
||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom > 1}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
onClick={zoomIn}
|
||||
aria-label="Zoom In"
|
||||
>
|
||||
<Icon size="50" src={Icons.Plus} />
|
||||
</IconButton>
|
||||
<Chip
|
||||
variant="Primary"
|
||||
onClick={handleDownload}
|
||||
radii="300"
|
||||
before={<Icon size="50" src={Icons.Download} />}
|
||||
>
|
||||
<Text size="B300">Download</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||
{isLoading && <Spinner variant="Secondary" size="600" />}
|
||||
{isError && (
|
||||
<>
|
||||
<Text>Failed to load PDF</Text>
|
||||
<Button
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
before={<Icon src={Icons.Warning} size="50" />}
|
||||
onClick={loadPdfJS}
|
||||
>
|
||||
<Text size="B300">Retry</Text>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{docState.status === AsyncStatus.Success && (
|
||||
<Scroll
|
||||
ref={scrollRef}
|
||||
size="300"
|
||||
direction="Both"
|
||||
variant="Surface"
|
||||
visibility="Hover"
|
||||
>
|
||||
<Box>
|
||||
<div className={css.PdfViewerContent} ref={containerRef} />
|
||||
</Box>
|
||||
</Scroll>
|
||||
)}
|
||||
</Box>
|
||||
{docState.status === AsyncStatus.Success && (
|
||||
<Header as="footer" className={css.PdfViewerFooter} size="400">
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
before={<Icon size="50" src={Icons.ChevronLeft} />}
|
||||
onClick={handlePrevPage}
|
||||
aria-disabled={pageNo <= 1}
|
||||
>
|
||||
<Text size="B300">Previous</Text>
|
||||
</Chip>
|
||||
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
|
||||
<PopOut
|
||||
open={openJump}
|
||||
align="Center"
|
||||
position="Top"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setOpenJump(false),
|
||||
clickOutsideDeactivates: true,
|
||||
}}
|
||||
>
|
||||
<Menu variant="Surface">
|
||||
<Box
|
||||
as="form"
|
||||
onSubmit={handleJumpSubmit}
|
||||
style={{ padding: config.space.S200 }}
|
||||
direction="Column"
|
||||
gap="200"
|
||||
>
|
||||
<Input
|
||||
name="jumpInput"
|
||||
size="300"
|
||||
variant="Background"
|
||||
defaultValue={pageNo}
|
||||
min={1}
|
||||
max={docState.data.numPages}
|
||||
step={1}
|
||||
outlined
|
||||
type="number"
|
||||
radii="300"
|
||||
aria-label="Page Number"
|
||||
/>
|
||||
<Button type="submit" size="300" variant="Primary" radii="300">
|
||||
<Text size="B300">Jump To Page</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
{(anchorRef) => (
|
||||
<Chip
|
||||
onClick={() => setOpenJump(!openJump)}
|
||||
ref={anchorRef}
|
||||
variant="SurfaceVariant"
|
||||
radii="300"
|
||||
aria-pressed={openJump}
|
||||
>
|
||||
<Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</PopOut>
|
||||
</Box>
|
||||
<Chip
|
||||
variant="Primary"
|
||||
radii="300"
|
||||
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||
onClick={handleNextPage}
|
||||
aria-disabled={pageNo >= docState.data.numPages}
|
||||
>
|
||||
<Text size="B300">Next</Text>
|
||||
</Chip>
|
||||
</Header>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
1
src/app/components/Pdf-viewer/index.ts
Normal file
1
src/app/components/Pdf-viewer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './PdfViewer';
|
||||
|
|
@ -54,7 +54,7 @@ export const useEditor = (): Editor => {
|
|||
return editor;
|
||||
};
|
||||
|
||||
export type EditorChangeHandler = ((value: Descendant[]) => void) | undefined;
|
||||
export type EditorChangeHandler = (value: Descendant[]) => void;
|
||||
type CustomEditorProps = {
|
||||
top?: ReactNode;
|
||||
bottom?: ReactNode;
|
||||
|
|
@ -64,6 +64,7 @@ type CustomEditorProps = {
|
|||
editor: Editor;
|
||||
placeholder?: string;
|
||||
onKeyDown?: KeyboardEventHandler;
|
||||
onKeyUp?: KeyboardEventHandler;
|
||||
onChange?: EditorChangeHandler;
|
||||
onPaste?: ClipboardEventHandler;
|
||||
};
|
||||
|
|
@ -78,6 +79,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||
editor,
|
||||
placeholder,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
onChange,
|
||||
onPaste,
|
||||
},
|
||||
|
|
@ -141,6 +143,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||
renderElement={renderElement}
|
||||
renderLeaf={renderLeaf}
|
||||
onKeyDown={handleKeydown}
|
||||
onKeyUp={onKeyUp}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
</Scroll>
|
||||
|
|
|
|||
|
|
@ -1,142 +0,0 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { recipe } from '@vanilla-extract/recipes';
|
||||
import { color, config, DefaultReset, toRem } from 'folds';
|
||||
|
||||
const MarginBottom = style({
|
||||
marginBottom: config.space.S200,
|
||||
selectors: {
|
||||
'&:last-child': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Paragraph = style([MarginBottom]);
|
||||
|
||||
export const Heading = style([MarginBottom]);
|
||||
|
||||
export const BlockQuote = style([
|
||||
DefaultReset,
|
||||
MarginBottom,
|
||||
{
|
||||
paddingLeft: config.space.S200,
|
||||
borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
]);
|
||||
|
||||
const BaseCode = style({
|
||||
fontFamily: 'monospace',
|
||||
color: color.Warning.OnContainer,
|
||||
background: color.Warning.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.Warning.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
});
|
||||
|
||||
export const Code = style([
|
||||
DefaultReset,
|
||||
BaseCode,
|
||||
{
|
||||
padding: `0 ${config.space.S100}`,
|
||||
},
|
||||
]);
|
||||
export const Spoiler = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: `0 ${config.space.S100}`,
|
||||
backgroundColor: color.SurfaceVariant.ContainerActive,
|
||||
borderRadius: config.radii.R300,
|
||||
},
|
||||
]);
|
||||
|
||||
export const CodeBlock = style([DefaultReset, BaseCode, MarginBottom]);
|
||||
export const CodeBlockInternal = style({
|
||||
padding: `${config.space.S200} ${config.space.S200} 0`,
|
||||
});
|
||||
|
||||
export const List = style([
|
||||
DefaultReset,
|
||||
MarginBottom,
|
||||
{
|
||||
padding: `0 ${config.space.S100}`,
|
||||
paddingLeft: config.space.S600,
|
||||
},
|
||||
]);
|
||||
|
||||
export const InlineChromiumBugfix = style({
|
||||
fontSize: 0,
|
||||
lineHeight: 0,
|
||||
});
|
||||
|
||||
export const Mention = recipe({
|
||||
base: [
|
||||
DefaultReset,
|
||||
{
|
||||
backgroundColor: color.Secondary.Container,
|
||||
color: color.Secondary.OnContainer,
|
||||
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Secondary.ContainerLine}`,
|
||||
padding: `0 ${toRem(2)}`,
|
||||
borderRadius: config.radii.R300,
|
||||
fontWeight: config.fontWeight.W500,
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
highlight: {
|
||||
true: {
|
||||
backgroundColor: color.Primary.Container,
|
||||
color: color.Primary.OnContainer,
|
||||
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Primary.ContainerLine}`,
|
||||
},
|
||||
},
|
||||
focus: {
|
||||
true: {
|
||||
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.OnContainer}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const EmoticonBase = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'inline-block',
|
||||
padding: '0.05rem',
|
||||
height: '1em',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Emoticon = recipe({
|
||||
base: [
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
height: '1em',
|
||||
minWidth: '1em',
|
||||
fontSize: '1.47em',
|
||||
lineHeight: '1em',
|
||||
verticalAlign: 'middle',
|
||||
position: 'relative',
|
||||
top: '-0.25em',
|
||||
borderRadius: config.radii.R300,
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
focus: {
|
||||
true: {
|
||||
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.OnContainer}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const EmoticonImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
height: '1em',
|
||||
cursor: 'default',
|
||||
},
|
||||
]);
|
||||
|
|
@ -2,7 +2,7 @@ import { Scroll, Text } from 'folds';
|
|||
import React from 'react';
|
||||
import { RenderElementProps, RenderLeafProps, useFocused, useSelected } from 'slate-react';
|
||||
|
||||
import * as css from './Elements.css';
|
||||
import * as css from '../../styles/CustomHtml.css';
|
||||
import { EmoticonElement, LinkElement, MentionElement } from './slate';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
|
|
@ -145,7 +145,13 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
|
|||
case BlockType.CodeBlock:
|
||||
return (
|
||||
<Text as="pre" className={css.CodeBlock} {...attributes}>
|
||||
<Scroll direction="Horizontal" variant="Warning" size="300" visibility="Hover" hideTrack>
|
||||
<Scroll
|
||||
direction="Horizontal"
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
visibility="Hover"
|
||||
hideTrack
|
||||
>
|
||||
<div className={css.CodeBlockInternal}>{children}</div>
|
||||
</Scroll>
|
||||
</Text>
|
||||
|
|
@ -242,7 +248,7 @@ export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
|
|||
);
|
||||
if (leaf.spoiler)
|
||||
child = (
|
||||
<span className={css.Spoiler} {...attributes}>
|
||||
<span className={css.Spoiler()} {...attributes}>
|
||||
<InlineChromiumBugfix />
|
||||
{child}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -122,8 +122,9 @@ export function RoomMentionAutocomplete({
|
|||
return;
|
||||
}
|
||||
const rId = autoCompleteRoomIds[0];
|
||||
const name = mx.getRoom(rId)?.name ?? rId;
|
||||
handleAutocomplete(rId, name);
|
||||
const r = mx.getRoom(rId);
|
||||
const name = r?.name ?? rId;
|
||||
handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -147,7 +148,7 @@ export function RoomMentionAutocomplete({
|
|||
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||
onTabPress(evt, () => handleAutocomplete(rId, room.name))
|
||||
}
|
||||
onClick={() => handleAutocomplete(rId, room.name)}
|
||||
onClick={() => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name)}
|
||||
after={
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{room.getCanonicalAlias() ?? ''}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||
import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji';
|
||||
import { isUserId } from '../../utils/matrix';
|
||||
import { editableActiveElement, inVisibleScrollArea, targetFromEvent } from '../../utils/dom';
|
||||
import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
|
||||
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { useThrottle } from '../../hooks/useThrottle';
|
||||
|
|
@ -675,7 +675,7 @@ export function EmojiBoard({
|
|||
const targetEl = contentScrollRef.current;
|
||||
if (!targetEl) return;
|
||||
const groupEls = [...targetEl.querySelectorAll('div[data-group-id]')] as HTMLElement[];
|
||||
const groupEl = groupEls.find((el) => inVisibleScrollArea(targetEl, el));
|
||||
const groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el));
|
||||
const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
|
||||
setActiveGroupId(groupId);
|
||||
}, [setActiveGroupId]);
|
||||
|
|
|
|||
21
src/app/components/event-readers/EventReaders.css.ts
Normal file
21
src/app/components/event-readers/EventReaders.css.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, config } from 'folds';
|
||||
|
||||
export const EventReaders = style([
|
||||
DefaultReset,
|
||||
{
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Header = style({
|
||||
paddingLeft: config.space.S400,
|
||||
paddingRight: config.space.S300,
|
||||
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const Content = style({
|
||||
paddingLeft: config.space.S200,
|
||||
paddingBottom: config.space.S400,
|
||||
});
|
||||
110
src/app/components/event-readers/EventReaders.tsx
Normal file
110
src/app/components/event-readers/EventReaders.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Box,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
MenuItem,
|
||||
Scroll,
|
||||
Text,
|
||||
as,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import * as css from './EventReaders.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { openProfileViewer } from '../../../client/action/navigation';
|
||||
|
||||
export type EventReadersProps = {
|
||||
room: Room;
|
||||
eventId: string;
|
||||
requestClose: () => void;
|
||||
};
|
||||
export const EventReaders = as<'div', EventReadersProps>(
|
||||
({ className, room, eventId, requestClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const latestEventReaders = useRoomEventReaders(room, eventId);
|
||||
const followingMembers = latestEventReaders
|
||||
.map((readerId) => room.getMember(readerId))
|
||||
.filter((member) => member) as RoomMember[];
|
||||
|
||||
const getName = (member: RoomMember) =>
|
||||
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.EventReaders, className)}
|
||||
direction="Column"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Header className={css.Header} variant="Surface" size="600">
|
||||
<Box grow="Yes">
|
||||
<Text size="H3">Seen by</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={requestClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box grow="Yes">
|
||||
<Scroll visibility="Hover" hideTrack size="300">
|
||||
<Box className={css.Content} direction="Column">
|
||||
{followingMembers.map((member) => {
|
||||
const name = getName(member);
|
||||
const avatarUrl = member.getAvatarUrl(
|
||||
mx.baseUrl,
|
||||
100,
|
||||
100,
|
||||
'crop',
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={member.userId}
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
radii="400"
|
||||
onClick={() => {
|
||||
requestClose();
|
||||
openProfileViewer(member.userId, room.roomId);
|
||||
}}
|
||||
before={
|
||||
<Avatar size="200">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback
|
||||
style={{
|
||||
background: colorMXID(member.userId),
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Text size="H6">{name[0]}</Text>
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Text size="T400" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
1
src/app/components/event-readers/index.ts
Normal file
1
src/app/components/event-readers/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './EventReaders';
|
||||
40
src/app/components/image-viewer/ImageViewer.css.ts
Normal file
40
src/app/components/image-viewer/ImageViewer.css.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config } from 'folds';
|
||||
|
||||
export const ImageViewer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const ImageViewerHeader = style([
|
||||
DefaultReset,
|
||||
{
|
||||
paddingLeft: config.space.S200,
|
||||
paddingRight: config.space.S200,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
flexShrink: 0,
|
||||
gap: config.space.S200,
|
||||
},
|
||||
]);
|
||||
|
||||
export const ImageViewerContent = style([
|
||||
DefaultReset,
|
||||
{
|
||||
backgroundColor: color.Background.Container,
|
||||
color: color.Background.OnContainer,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
export const ImageViewerImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: color.Surface.Container,
|
||||
transition: 'transform 100ms linear',
|
||||
},
|
||||
]);
|
||||
95
src/app/components/image-viewer/ImageViewer.tsx
Normal file
95
src/app/components/image-viewer/ImageViewer.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||
import React from 'react';
|
||||
import FileSaver from 'file-saver';
|
||||
import classNames from 'classnames';
|
||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||
import * as css from './ImageViewer.css';
|
||||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { usePan } from '../../hooks/usePan';
|
||||
|
||||
export type ImageViewerProps = {
|
||||
alt: string;
|
||||
src: string;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||
|
||||
const handleDownload = () => {
|
||||
FileSaver.saveAs(src, alt);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.ImageViewer, className)}
|
||||
direction="Column"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Header className={css.ImageViewerHeader} size="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<Icon size="50" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
{alt}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<IconButton
|
||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom < 1}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
onClick={zoomOut}
|
||||
aria-label="Zoom Out"
|
||||
>
|
||||
<Icon size="50" src={Icons.Minus} />
|
||||
</IconButton>
|
||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom > 1}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
onClick={zoomIn}
|
||||
aria-label="Zoom In"
|
||||
>
|
||||
<Icon size="50" src={Icons.Plus} />
|
||||
</IconButton>
|
||||
<Chip
|
||||
variant="Primary"
|
||||
onClick={handleDownload}
|
||||
radii="300"
|
||||
before={<Icon size="50" src={Icons.Download} />}
|
||||
>
|
||||
<Text size="B300">Download</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box
|
||||
grow="Yes"
|
||||
className={css.ImageViewerContent}
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
>
|
||||
<img
|
||||
className={css.ImageViewerImg}
|
||||
style={{
|
||||
cursor,
|
||||
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
|
||||
}}
|
||||
src={src}
|
||||
alt={alt}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
1
src/app/components/image-viewer/index.ts
Normal file
1
src/app/components/image-viewer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './ImageViewer';
|
||||
9
src/app/components/media/Image.tsx
Normal file
9
src/app/components/media/Image.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React, { ImgHTMLAttributes, forwardRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './media.css';
|
||||
|
||||
export const Image = forwardRef<HTMLImageElement, ImgHTMLAttributes<HTMLImageElement>>(
|
||||
({ className, alt, ...props }, ref) => (
|
||||
<img className={classNames(css.Image, className)} alt={alt} {...props} ref={ref} />
|
||||
)
|
||||
);
|
||||
27
src/app/components/media/MediaControls.tsx
Normal file
27
src/app/components/media/MediaControls.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Box, as } from 'folds';
|
||||
|
||||
export type MediaControlProps = {
|
||||
before?: ReactNode;
|
||||
after?: ReactNode;
|
||||
leftControl?: ReactNode;
|
||||
rightControl?: ReactNode;
|
||||
};
|
||||
export const MediaControl = as<'div', MediaControlProps>(
|
||||
({ before, after, leftControl, rightControl, children, ...props }, ref) => (
|
||||
<Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}>
|
||||
{before && <Box direction="Column">{before}</Box>}
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Box alignItems="Center" grow="Yes" gap="Inherit">
|
||||
{leftControl}
|
||||
</Box>
|
||||
|
||||
<Box justifyItems="End" alignItems="Center" gap="Inherit">
|
||||
{rightControl}
|
||||
</Box>
|
||||
</Box>
|
||||
{after && <Box direction="Column">{after}</Box>}
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
10
src/app/components/media/Video.tsx
Normal file
10
src/app/components/media/Video.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React, { VideoHTMLAttributes, forwardRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './media.css';
|
||||
|
||||
export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video className={classNames(css.Image, className)} {...props} ref={ref} />
|
||||
)
|
||||
);
|
||||
3
src/app/components/media/index.ts
Normal file
3
src/app/components/media/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './Image';
|
||||
export * from './Video';
|
||||
export * from './MediaControls';
|
||||
20
src/app/components/media/media.css.ts
Normal file
20
src/app/components/media/media.css.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset } from 'folds';
|
||||
|
||||
export const Image = style([
|
||||
DefaultReset,
|
||||
{
|
||||
objectFit: 'cover',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Video = style([
|
||||
DefaultReset,
|
||||
{
|
||||
objectFit: 'cover',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
66
src/app/components/message/MessageContentFallback.tsx
Normal file
66
src/app/components/message/MessageContentFallback.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Box, Icon, Icons, Text, as, color, config } from 'folds';
|
||||
import React from 'react';
|
||||
|
||||
const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
|
||||
const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
|
||||
|
||||
export const MessageDeletedContent = as<'div', { children?: never; reason?: string }>(
|
||||
({ reason, ...props }, ref) => (
|
||||
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
|
||||
<Icon size="50" src={Icons.Delete} />
|
||||
{reason ? (
|
||||
<i>This message has been deleted. {reason}</i>
|
||||
) : (
|
||||
<i>This message has been deleted</i>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
export const MessageUnsupportedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
|
||||
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
|
||||
<Icon size="50" src={Icons.Warning} />
|
||||
<i>Unsupported message</i>
|
||||
</Box>
|
||||
));
|
||||
|
||||
export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
|
||||
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
|
||||
<Icon size="50" src={Icons.Warning} />
|
||||
<i>Failed to load message</i>
|
||||
</Box>
|
||||
));
|
||||
|
||||
export const MessageBadEncryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
|
||||
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
|
||||
<Icon size="50" src={Icons.Lock} />
|
||||
<i>Unable to decrypt message</i>
|
||||
</Box>
|
||||
));
|
||||
|
||||
export const MessageNotDecryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
|
||||
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
|
||||
<Icon size="50" src={Icons.Lock} />
|
||||
<i>This message is not decrypted yet</i>
|
||||
</Box>
|
||||
));
|
||||
|
||||
export const MessageBrokenContent = as<'div', { children?: never }>(({ ...props }, ref) => (
|
||||
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
|
||||
<Icon size="50" src={Icons.Warning} />
|
||||
<i>Broken message</i>
|
||||
</Box>
|
||||
));
|
||||
|
||||
export const MessageEmptyContent = as<'div', { children?: never }>(({ ...props }, ref) => (
|
||||
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
|
||||
<Icon size="50" src={Icons.Warning} />
|
||||
<i>Empty message</i>
|
||||
</Box>
|
||||
));
|
||||
|
||||
export const MessageEditedContent = as<'span', { children?: never }>(({ ...props }, ref) => (
|
||||
<Text as="span" size="T200" priority="300" {...props} ref={ref}>
|
||||
{' (edited)'}
|
||||
</Text>
|
||||
));
|
||||
75
src/app/components/message/Reaction.css.ts
Normal file
75
src/app/components/message/Reaction.css.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { createVar, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
||||
|
||||
const Container = createVar();
|
||||
const ContainerHover = createVar();
|
||||
const ContainerActive = createVar();
|
||||
const ContainerLine = createVar();
|
||||
const OnContainer = createVar();
|
||||
|
||||
export const Reaction = style([
|
||||
FocusOutline,
|
||||
{
|
||||
vars: {
|
||||
[Container]: color.SurfaceVariant.Container,
|
||||
[ContainerHover]: color.SurfaceVariant.ContainerHover,
|
||||
[ContainerActive]: color.SurfaceVariant.ContainerActive,
|
||||
[ContainerLine]: color.SurfaceVariant.ContainerLine,
|
||||
[OnContainer]: color.SurfaceVariant.OnContainer,
|
||||
},
|
||||
padding: `${toRem(2)} ${config.space.S200} ${toRem(2)} ${config.space.S100}`,
|
||||
backgroundColor: Container,
|
||||
border: `${config.borderWidth.B300} solid ${ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
|
||||
selectors: {
|
||||
'button&': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'&[aria-pressed=true]': {
|
||||
vars: {
|
||||
[Container]: color.Primary.Container,
|
||||
[ContainerHover]: color.Primary.ContainerHover,
|
||||
[ContainerActive]: color.Primary.ContainerActive,
|
||||
[ContainerLine]: color.Primary.ContainerLine,
|
||||
[OnContainer]: color.Primary.OnContainer,
|
||||
},
|
||||
backgroundColor: Container,
|
||||
},
|
||||
'&[aria-selected=true]': {
|
||||
borderColor: color.Secondary.Main,
|
||||
borderWidth: config.borderWidth.B400,
|
||||
},
|
||||
'&:hover, &:focus-visible': {
|
||||
backgroundColor: ContainerHover,
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: ContainerActive,
|
||||
},
|
||||
'&[aria-disabled=true], &:disabled': {
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const ReactionText = style([
|
||||
DefaultReset,
|
||||
{
|
||||
minWidth: 0,
|
||||
maxWidth: toRem(150),
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
lineHeight: toRem(20),
|
||||
},
|
||||
]);
|
||||
|
||||
export const ReactionImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
height: '1em',
|
||||
minWidth: 0,
|
||||
maxWidth: toRem(150),
|
||||
objectFit: 'contain',
|
||||
},
|
||||
]);
|
||||
113
src/app/components/message/Reaction.tsx
Normal file
113
src/app/components/message/Reaction.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import React from 'react';
|
||||
import { Box, Text, as } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import * as css from './Reaction.css';
|
||||
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { eventWithShortcode, getMxIdLocalPart } from '../../utils/matrix';
|
||||
|
||||
export const Reaction = as<
|
||||
'button',
|
||||
{
|
||||
mx: MatrixClient;
|
||||
count: number;
|
||||
reaction: string;
|
||||
}
|
||||
>(({ className, mx, count, reaction, ...props }, ref) => (
|
||||
<Box
|
||||
as="button"
|
||||
className={classNames(css.Reaction, className)}
|
||||
alignItems="Center"
|
||||
shrink="No"
|
||||
gap="200"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Text className={css.ReactionText} as="span" size="T400">
|
||||
{reaction.startsWith('mxc://') ? (
|
||||
<img
|
||||
className={css.ReactionImg}
|
||||
src={mx.mxcUrlToHttp(reaction) ?? reaction}
|
||||
alt={reaction}
|
||||
/>
|
||||
) : (
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{reaction}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text as="span" size="T300">
|
||||
{count}
|
||||
</Text>
|
||||
</Box>
|
||||
));
|
||||
|
||||
type ReactionTooltipMsgProps = {
|
||||
room: Room;
|
||||
reaction: string;
|
||||
events: MatrixEvent[];
|
||||
};
|
||||
|
||||
export function ReactionTooltipMsg({ room, reaction, events }: ReactionTooltipMsgProps) {
|
||||
const shortCodeEvt = events.find(eventWithShortcode);
|
||||
const shortcode =
|
||||
shortCodeEvt?.getContent().shortcode ??
|
||||
getShortcodeFor(getHexcodeForEmoji(reaction)) ??
|
||||
reaction;
|
||||
const names = events.map(
|
||||
(ev: MatrixEvent) =>
|
||||
getMemberDisplayName(room, ev.getSender() ?? 'Unknown') ??
|
||||
getMxIdLocalPart(ev.getSender() ?? 'Unknown') ??
|
||||
'Unknown'
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{names.length === 1 && <b>{names[0]}</b>}
|
||||
{names.length === 2 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
</>
|
||||
)}
|
||||
{names.length === 3 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names[2]}</b>
|
||||
</>
|
||||
)}
|
||||
{names.length > 3 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names.length - 3} others</b>
|
||||
</>
|
||||
)}
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' reacted with '}
|
||||
</Text>
|
||||
:<b>{shortcode}</b>:
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/app/components/message/Reply.css.ts
Normal file
25
src/app/components/message/Reply.css.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
|
||||
export const Reply = style({
|
||||
padding: `0 ${config.space.S100}`,
|
||||
marginBottom: toRem(1),
|
||||
cursor: 'pointer',
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
minHeight: config.lineHeight.T300,
|
||||
});
|
||||
|
||||
export const ReplyContent = style({
|
||||
opacity: config.opacity.P300,
|
||||
|
||||
selectors: {
|
||||
[`${Reply}:hover &`]: {
|
||||
opacity: config.opacity.P500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ReplyContentText = style({
|
||||
paddingRight: config.space.S100,
|
||||
});
|
||||
100
src/app/components/message/Reply.tsx
Normal file
100
src/app/components/message/Reply.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
|
||||
import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import to from 'await-to-js';
|
||||
import classNames from 'classnames';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart, trimReplyFromBody } from '../../utils/matrix';
|
||||
import { LinePlaceholder } from './placeholder';
|
||||
import { randomNumberBetween } from '../../utils/common';
|
||||
import * as css from './Reply.css';
|
||||
import {
|
||||
MessageBadEncryptedContent,
|
||||
MessageDeletedContent,
|
||||
MessageFailedContent,
|
||||
} from './MessageContentFallback';
|
||||
|
||||
type ReplyProps = {
|
||||
mx: MatrixClient;
|
||||
room: Room;
|
||||
timelineSet: EventTimelineSet;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
export const Reply = as<'div', ReplyProps>(
|
||||
({ className, mx, room, timelineSet, eventId, ...props }, ref) => {
|
||||
const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
|
||||
timelineSet.findEventById(eventId)
|
||||
);
|
||||
|
||||
const { body } = replyEvent?.getContent() ?? {};
|
||||
const sender = replyEvent?.getSender();
|
||||
|
||||
const fallbackBody = replyEvent?.isRedacted() ? (
|
||||
<MessageDeletedContent />
|
||||
) : (
|
||||
<MessageFailedContent />
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const loadEvent = async () => {
|
||||
const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
|
||||
const mEvent = new MatrixEvent(evt);
|
||||
if (disposed) return;
|
||||
if (err) {
|
||||
setReplyEvent(null);
|
||||
return;
|
||||
}
|
||||
if (mEvent.isEncrypted() && mx.getCrypto()) {
|
||||
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
|
||||
}
|
||||
setReplyEvent(mEvent);
|
||||
};
|
||||
if (replyEvent === undefined) loadEvent();
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [replyEvent, mx, room, eventId]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.Reply, className)}
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Box style={{ color: colorMXID(sender ?? eventId) }} alignItems="Center" shrink="No">
|
||||
<Icon src={Icons.ReplyArrow} size="50" />
|
||||
{sender && (
|
||||
<Text size="T300" truncate>
|
||||
{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box grow="Yes" className={css.ReplyContent}>
|
||||
{replyEvent !== undefined ? (
|
||||
<Text className={css.ReplyContentText} size="T300" truncate>
|
||||
{replyEvent?.getContent().msgtype === 'm.bad.encrypted' ? (
|
||||
<MessageBadEncryptedContent />
|
||||
) : (
|
||||
(body && trimReplyFromBody(body)) ?? fallbackBody
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<LinePlaceholder
|
||||
style={{
|
||||
backgroundColor: color.SurfaceVariant.ContainerActive,
|
||||
maxWidth: toRem(randomNumberBetween(40, 400)),
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
27
src/app/components/message/Time.tsx
Normal file
27
src/app/components/message/Time.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import { Text, as } from 'folds';
|
||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
|
||||
|
||||
export type TimeProps = {
|
||||
compact?: boolean;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export const Time = as<'span', TimeProps>(({ compact, ts, ...props }, ref) => {
|
||||
let time = '';
|
||||
if (compact) {
|
||||
time = timeHourMinute(ts);
|
||||
} else if (today(ts)) {
|
||||
time = timeHourMinute(ts);
|
||||
} else if (yesterday(ts)) {
|
||||
time = `Yesterday ${timeHourMinute(ts)}`;
|
||||
} else {
|
||||
time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
|
||||
{time}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
42
src/app/components/message/attachment/Attachment.css.ts
Normal file
42
src/app/components/message/attachment/Attachment.css.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const Attachment = recipe({
|
||||
base: {
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
maxWidth: '100%',
|
||||
width: toRem(400),
|
||||
},
|
||||
variants: {
|
||||
outlined: {
|
||||
true: {
|
||||
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type AttachmentVariants = RecipeVariants<typeof Attachment>;
|
||||
|
||||
export const AttachmentHeader = style({
|
||||
padding: config.space.S300,
|
||||
});
|
||||
|
||||
export const AttachmentBox = style([
|
||||
DefaultReset,
|
||||
{
|
||||
maxWidth: '100%',
|
||||
maxHeight: toRem(600),
|
||||
width: toRem(400),
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
export const AttachmentContent = style({
|
||||
padding: config.space.S300,
|
||||
paddingTop: 0,
|
||||
});
|
||||
44
src/app/components/message/attachment/Attachment.tsx
Normal file
44
src/app/components/message/attachment/Attachment.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
import { Box, as } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './Attachment.css';
|
||||
|
||||
export const Attachment = as<'div', css.AttachmentVariants>(
|
||||
({ className, outlined, ...props }, ref) => (
|
||||
<Box
|
||||
display="InlineFlex"
|
||||
direction="Column"
|
||||
className={classNames(css.Attachment({ outlined }), className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
export const AttachmentHeader = as<'div'>(({ className, ...props }, ref) => (
|
||||
<Box
|
||||
shrink="No"
|
||||
gap="200"
|
||||
className={classNames(css.AttachmentHeader, className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
export const AttachmentBox = as<'div'>(({ className, ...props }, ref) => (
|
||||
<Box
|
||||
direction="Column"
|
||||
className={classNames(css.AttachmentBox, className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
export const AttachmentContent = as<'div'>(({ className, ...props }, ref) => (
|
||||
<Box
|
||||
direction="Column"
|
||||
className={classNames(css.AttachmentContent, className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
1
src/app/components/message/attachment/index.ts
Normal file
1
src/app/components/message/attachment/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Attachment';
|
||||
7
src/app/components/message/index.ts
Normal file
7
src/app/components/message/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export * from './layout';
|
||||
export * from './placeholder';
|
||||
export * from './Reaction';
|
||||
export * from './attachment';
|
||||
export * from './Reply';
|
||||
export * from './MessageContentFallback';
|
||||
export * from './Time';
|
||||
25
src/app/components/message/layout/Base.tsx
Normal file
25
src/app/components/message/layout/Base.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { as } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './layout.css';
|
||||
|
||||
export const MessageBase = as<'div', css.MessageBaseVariants>(
|
||||
({ className, highlight, selected, collapse, autoCollapse, space, ...props }, ref) => (
|
||||
<div
|
||||
className={classNames(
|
||||
css.MessageBase({ highlight, selected, collapse, autoCollapse, space }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
|
||||
<span className={classNames(css.AvatarBase, className)} {...props} ref={ref} />
|
||||
));
|
||||
|
||||
export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
|
||||
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
|
||||
));
|
||||
18
src/app/components/message/layout/Bubble.tsx
Normal file
18
src/app/components/message/layout/Bubble.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Box, as } from 'folds';
|
||||
import * as css from './layout.css';
|
||||
|
||||
type BubbleLayoutProps = {
|
||||
before?: ReactNode;
|
||||
};
|
||||
|
||||
export const BubbleLayout = as<'div', BubbleLayoutProps>(({ before, children, ...props }, ref) => (
|
||||
<Box gap="300" {...props} ref={ref}>
|
||||
<Box className={css.BubbleBefore} shrink="No">
|
||||
{before}
|
||||
</Box>
|
||||
<Box className={css.BubbleContent} direction="Column">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
));
|
||||
18
src/app/components/message/layout/Compact.tsx
Normal file
18
src/app/components/message/layout/Compact.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Box, as } from 'folds';
|
||||
import * as css from './layout.css';
|
||||
|
||||
type CompactLayoutProps = {
|
||||
before?: ReactNode;
|
||||
};
|
||||
|
||||
export const CompactLayout = as<'div', CompactLayoutProps>(
|
||||
({ before, children, ...props }, ref) => (
|
||||
<Box gap="200" {...props} ref={ref}>
|
||||
<Box className={css.CompactHeader} gap="200" shrink="No">
|
||||
{before}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
18
src/app/components/message/layout/Modern.tsx
Normal file
18
src/app/components/message/layout/Modern.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Box, as } from 'folds';
|
||||
import * as css from './layout.css';
|
||||
|
||||
type ModernLayoutProps = {
|
||||
before?: ReactNode;
|
||||
};
|
||||
|
||||
export const ModernLayout = as<'div', ModernLayoutProps>(({ before, children, ...props }, ref) => (
|
||||
<Box gap="300" {...props} ref={ref}>
|
||||
<Box className={css.ModernBefore} shrink="No">
|
||||
{before}
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
));
|
||||
4
src/app/components/message/layout/index.ts
Normal file
4
src/app/components/message/layout/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './Modern';
|
||||
export * from './Compact';
|
||||
export * from './Bubble';
|
||||
export * from './Base';
|
||||
155
src/app/components/message/layout/layout.css.ts
Normal file
155
src/app/components/message/layout/layout.css.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css';
|
||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const StickySection = style({
|
||||
position: 'sticky',
|
||||
top: config.space.S100,
|
||||
});
|
||||
|
||||
const SpacingVar = createVar();
|
||||
const SpacingVariant = styleVariants({
|
||||
'0': {
|
||||
vars: {
|
||||
[SpacingVar]: config.space.S0,
|
||||
},
|
||||
},
|
||||
'100': {
|
||||
vars: {
|
||||
[SpacingVar]: config.space.S100,
|
||||
},
|
||||
},
|
||||
'200': {
|
||||
vars: {
|
||||
[SpacingVar]: config.space.S200,
|
||||
},
|
||||
},
|
||||
'300': {
|
||||
vars: {
|
||||
[SpacingVar]: config.space.S300,
|
||||
},
|
||||
},
|
||||
'400': {
|
||||
vars: {
|
||||
[SpacingVar]: config.space.S400,
|
||||
},
|
||||
},
|
||||
'500': {
|
||||
vars: {
|
||||
[SpacingVar]: config.space.S500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const highlightAnime = keyframes({
|
||||
'0%': {
|
||||
backgroundColor: color.Primary.Container,
|
||||
},
|
||||
'25%': {
|
||||
backgroundColor: color.Primary.ContainerActive,
|
||||
},
|
||||
'50%': {
|
||||
backgroundColor: color.Primary.Container,
|
||||
},
|
||||
'75%': {
|
||||
backgroundColor: color.Primary.ContainerActive,
|
||||
},
|
||||
'100%': {
|
||||
backgroundColor: color.Primary.Container,
|
||||
},
|
||||
});
|
||||
const HighlightVariant = styleVariants({
|
||||
true: {
|
||||
animation: `${highlightAnime} 2000ms ease-in-out`,
|
||||
},
|
||||
});
|
||||
|
||||
const SelectedVariant = styleVariants({
|
||||
true: {
|
||||
backgroundColor: color.Surface.ContainerActive,
|
||||
},
|
||||
});
|
||||
|
||||
const AutoCollapse = style({
|
||||
selectors: {
|
||||
[`&+&`]: {
|
||||
marginTop: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const MessageBase = recipe({
|
||||
base: [
|
||||
DefaultReset,
|
||||
{
|
||||
marginTop: SpacingVar,
|
||||
padding: `${config.space.S100} ${config.space.S200} ${config.space.S100} ${config.space.S400}`,
|
||||
borderRadius: `0 ${config.radii.R400} ${config.radii.R400} 0`,
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
space: SpacingVariant,
|
||||
collapse: {
|
||||
true: {
|
||||
marginTop: 0,
|
||||
},
|
||||
},
|
||||
autoCollapse: {
|
||||
true: AutoCollapse,
|
||||
},
|
||||
highlight: HighlightVariant,
|
||||
selected: SelectedVariant,
|
||||
},
|
||||
defaultVariants: {
|
||||
space: '400',
|
||||
},
|
||||
});
|
||||
|
||||
export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
|
||||
|
||||
export const CompactHeader = style([
|
||||
DefaultReset,
|
||||
StickySection,
|
||||
{
|
||||
maxWidth: toRem(170),
|
||||
width: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const AvatarBase = style({
|
||||
paddingTop: toRem(4),
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
transform: `translateY(${toRem(-4)})`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ModernBefore = style({
|
||||
minWidth: toRem(36),
|
||||
});
|
||||
|
||||
export const BubbleBefore = style([ModernBefore]);
|
||||
|
||||
export const BubbleContent = style({
|
||||
maxWidth: toRem(800),
|
||||
padding: config.space.S200,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
borderRadius: config.radii.R400,
|
||||
});
|
||||
|
||||
export const Username = style({
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
selectors: {
|
||||
'&:hover, &:focus-visible': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { as, toRem } from 'folds';
|
||||
import { randomNumberBetween } from '../../../utils/common';
|
||||
import { LinePlaceholder } from './LinePlaceholder';
|
||||
import { CompactLayout, MessageBase } from '../layout';
|
||||
|
||||
export const CompactPlaceholder = as<'div'>(({ ...props }, ref) => (
|
||||
<MessageBase>
|
||||
<CompactLayout
|
||||
{...props}
|
||||
ref={ref}
|
||||
before={
|
||||
<>
|
||||
<LinePlaceholder style={{ maxWidth: toRem(50) }} />
|
||||
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(120, 500)) }} />
|
||||
</CompactLayout>
|
||||
</MessageBase>
|
||||
));
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import React, { CSSProperties } from 'react';
|
||||
import { Avatar, Box, as, color, toRem } from 'folds';
|
||||
import { randomNumberBetween } from '../../../utils/common';
|
||||
import { LinePlaceholder } from './LinePlaceholder';
|
||||
import { MessageBase, ModernLayout } from '../layout';
|
||||
|
||||
const contentMargin: CSSProperties = { marginTop: toRem(3) };
|
||||
const avatarBg: CSSProperties = { backgroundColor: color.SurfaceVariant.Container };
|
||||
|
||||
export const DefaultPlaceholder = as<'div'>(({ ...props }, ref) => (
|
||||
<MessageBase>
|
||||
<ModernLayout {...props} ref={ref} before={<Avatar style={avatarBg} size="300" />}>
|
||||
<Box style={contentMargin} grow="Yes" direction="Column" gap="200">
|
||||
<Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween">
|
||||
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
|
||||
<LinePlaceholder style={{ maxWidth: toRem(50) }} />
|
||||
</Box>
|
||||
<Box grow="Yes" gap="200" wrap="Wrap">
|
||||
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
|
||||
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</ModernLayout>
|
||||
</MessageBase>
|
||||
));
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const LinePlaceholder = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
height: toRem(16),
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
},
|
||||
]);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Box, as } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './LinePlaceholder.css';
|
||||
|
||||
export const LinePlaceholder = as<'div'>(({ className, ...props }, ref) => (
|
||||
<Box className={classNames(css.LinePlaceholder, className)} shrink="No" {...props} ref={ref} />
|
||||
));
|
||||
3
src/app/components/message/placeholder/index.ts
Normal file
3
src/app/components/message/placeholder/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './LinePlaceholder';
|
||||
export * from './CompactPlaceholder';
|
||||
export * from './DefaultPlaceholder';
|
||||
114
src/app/components/room-intro/RoomIntro.tsx
Normal file
114
src/app/components/room-intro/RoomIntro.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { Avatar, AvatarFallback, AvatarImage, Box, Button, Spinner, Text, as, color } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { openInviteUser, selectRoom } from '../../../client/action/navigation';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
|
||||
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
|
||||
|
||||
export type RoomIntroProps = {
|
||||
room: Room;
|
||||
};
|
||||
|
||||
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
|
||||
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
|
||||
const nameEvent = useStateEvent(room, StateEvent.RoomName);
|
||||
const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
|
||||
const createContent = createEvent?.getContent<IRoomCreateContent>();
|
||||
|
||||
const ts = createEvent?.getTs();
|
||||
const creatorId = createEvent?.getSender();
|
||||
const creatorName =
|
||||
creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
|
||||
const prevRoomId = createContent?.predecessor?.room_id;
|
||||
const avatarMxc = (avatarEvent?.getContent().url as string) || undefined;
|
||||
const avatarHttpUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc) : undefined;
|
||||
const name = (nameEvent?.getContent().name || room.name) as string;
|
||||
const topic = (topicEvent?.getContent().topic as string) || undefined;
|
||||
|
||||
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
|
||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
||||
<Box>
|
||||
<Avatar size="500">
|
||||
{avatarHttpUrl ? (
|
||||
<AvatarImage src={avatarHttpUrl} alt={name} />
|
||||
) : (
|
||||
<AvatarFallback
|
||||
style={{
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
}}
|
||||
>
|
||||
<Text size="H2">{name[0]}</Text>
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
</Box>
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="H3" priority="500">
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="T400" priority="400">
|
||||
{typeof topic === 'string' ? topic : 'This is the beginning of conversation.'}
|
||||
</Text>
|
||||
{creatorName && ts && (
|
||||
<Text size="T200" priority="300">
|
||||
{'Created by '}
|
||||
<b>@{creatorName}</b>
|
||||
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts)}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Button
|
||||
onClick={() => openInviteUser(room.roomId)}
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Text size="B300">Invite Member</Text>
|
||||
</Button>
|
||||
{typeof prevRoomId === 'string' &&
|
||||
(mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
|
||||
<Button
|
||||
onClick={() => selectRoom(prevRoomId)}
|
||||
variant="Success"
|
||||
size="300"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
>
|
||||
<Text size="B300">Open Old Room</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => joinPrevRoom(prevRoomId)}
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
disabled={prevRoomState.status === AsyncStatus.Loading}
|
||||
after={
|
||||
prevRoomState.status === AsyncStatus.Loading ? (
|
||||
<Spinner size="50" variant="Secondary" fill="Soft" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Text size="B300">Join Old Room</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
1
src/app/components/room-intro/index.ts
Normal file
1
src/app/components/room-intro/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './RoomIntro';
|
||||
37
src/app/components/text-viewer/TextViewer.css.ts
Normal file
37
src/app/components/text-viewer/TextViewer.css.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config } from 'folds';
|
||||
|
||||
export const TextViewer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const TextViewerHeader = style([
|
||||
DefaultReset,
|
||||
{
|
||||
paddingLeft: config.space.S200,
|
||||
paddingRight: config.space.S200,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
flexShrink: 0,
|
||||
gap: config.space.S200,
|
||||
},
|
||||
]);
|
||||
|
||||
export const TextViewerContent = style([
|
||||
DefaultReset,
|
||||
{
|
||||
backgroundColor: color.Background.Container,
|
||||
color: color.Background.OnContainer,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
export const TextViewerPre = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: config.space.S600,
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
]);
|
||||
69
src/app/components/text-viewer/TextViewer.tsx
Normal file
69
src/app/components/text-viewer/TextViewer.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import * as css from './TextViewer.css';
|
||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
|
||||
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
|
||||
|
||||
export type TextViewerProps = {
|
||||
name: string;
|
||||
text: string;
|
||||
mimeType: string;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
export const TextViewer = as<'div', TextViewerProps>(
|
||||
({ className, name, text, mimeType, requestClose, ...props }, ref) => {
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.TextViewer, className)}
|
||||
direction="Column"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Header className={css.TextViewerHeader} size="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<Icon size="50" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<Chip variant="Primary" radii="300" onClick={handleCopy}>
|
||||
<Text size="B300">Copy All</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box
|
||||
grow="Yes"
|
||||
className={css.TextViewerContent}
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
>
|
||||
<Scroll hideTrack variant="Background" visibility="Hover">
|
||||
<Text
|
||||
as="pre"
|
||||
className={classNames(css.TextViewerPre, `language-${mimeTypeToExt(mimeType)}`)}
|
||||
>
|
||||
<ErrorBoundary fallback={<code>{text}</code>}>
|
||||
<Suspense fallback={<code>{text}</code>}>
|
||||
<ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Text>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
1
src/app/components/text-viewer/index.ts
Normal file
1
src/app/components/text-viewer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './TextViewer';
|
||||
49
src/app/components/typing-indicator/TypingIndicator.css.ts
Normal file
49
src/app/components/typing-indicator/TypingIndicator.css.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { keyframes } from '@vanilla-extract/css';
|
||||
import { recipe } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, toRem } from 'folds';
|
||||
|
||||
const TypingDotAnime = keyframes({
|
||||
to: {
|
||||
opacity: '0.4',
|
||||
transform: 'translateY(-15%)',
|
||||
},
|
||||
});
|
||||
|
||||
export const TypingDot = recipe({
|
||||
base: [
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'inline-block',
|
||||
backgroundColor: 'currentColor',
|
||||
borderRadius: '50%',
|
||||
transform: 'translateY(15%)',
|
||||
animation: `${TypingDotAnime} 0.6s infinite alternate`,
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
size: {
|
||||
'300': {
|
||||
width: toRem(4),
|
||||
height: toRem(4),
|
||||
},
|
||||
'400': {
|
||||
width: toRem(8),
|
||||
height: toRem(8),
|
||||
},
|
||||
},
|
||||
index: {
|
||||
'0': {
|
||||
animationDelay: '0s',
|
||||
},
|
||||
'1': {
|
||||
animationDelay: '0.2s',
|
||||
},
|
||||
'2': {
|
||||
animationDelay: '0.4s',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: '400',
|
||||
},
|
||||
});
|
||||
21
src/app/components/typing-indicator/TypingIndicator.tsx
Normal file
21
src/app/components/typing-indicator/TypingIndicator.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import { Box, as, toRem } from 'folds';
|
||||
import * as css from './TypingIndicator.css';
|
||||
|
||||
export type TypingIndicatorProps = {
|
||||
size?: '300' | '400';
|
||||
};
|
||||
|
||||
export const TypingIndicator = as<'div', TypingIndicatorProps>(({ size, style, ...props }, ref) => (
|
||||
<Box
|
||||
as="span"
|
||||
alignItems="Center"
|
||||
style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<span className={css.TypingDot({ size, index: '0' })} />
|
||||
<span className={css.TypingDot({ size, index: '1' })} />
|
||||
<span className={css.TypingDot({ size, index: '2' })} />
|
||||
</Box>
|
||||
));
|
||||
1
src/app/components/typing-indicator/index.ts
Normal file
1
src/app/components/typing-indicator/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './TypingIndicator';
|
||||
Loading…
Add table
Add a link
Reference in a new issue