Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feat_add_gallery_support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

# Added a gallery support as per msc4274
66 changes: 63 additions & 3 deletions src/app/components/RenderMessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { settingsAtom, CaptionPosition } from '$state/settings';
import type { HTMLReactParserOptions } from 'html-react-parser';
import type { Opts } from 'linkifyjs';
import { Box, config } from 'folds';
import { Box, config, toRem } from 'folds';

Check failure on line 13 in src/app/components/RenderMessageContent.tsx

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-unused-vars)

Identifier 'toRem' is imported but never used.
import {
AudioContent,
DownloadFile,
Expand All @@ -25,6 +25,7 @@
MNotice,
MText,
MVideo,
MGallery,
ReadPdfFile,
ReadTextFile,
RenderBody,
Expand All @@ -49,7 +50,8 @@
import { CuteEventType, MCuteEvent } from './message/MCuteEvent';
import { PollEvent } from './message/PollEvent';
import { M_TEXT } from 'matrix-js-sdk';
import type { IImageInfo } from '$types/matrix/common';
import type { IImageInfo, IGalleryContent } from '$types/matrix/common';
import { GALLERY_MSGTYPE } from '$types/matrix/common';

type RenderMessageContentProps = {
displayName: string;
Expand All @@ -61,6 +63,7 @@
bundledPreview?: boolean;
urlPreview?: boolean;
clientUrlPreview?: boolean;
isGallery?: boolean;
showMaps?: boolean;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
Expand Down Expand Up @@ -94,6 +97,7 @@
edited,
getContent,
mediaAutoLoad,
isGallery,
bundledPreview,
urlPreview,
clientUrlPreview,
Expand Down Expand Up @@ -262,7 +266,14 @@
flexDirection: attachmentDirection,
}}
>
<div>{attachment}</div>
<div
style={{
// height: '100%',
backgroundColor: '#00AA00',
}}
>
{attachment}
</div>
{renderCaption()}
</div>
);
Expand All @@ -272,6 +283,7 @@
renderCaptionedAttachment(
<MFile
content={content as Record<string, never> & { msgtype: MsgType.File }}
fitParent={isGallery}
renderFileContent={({ body, mimeType, info, encInfo, url }) => (
<FileContent
body={body}
Expand Down Expand Up @@ -368,6 +380,7 @@
return renderCaptionedAttachment(
<MImage
content={content as Record<string, never> & { msgtype: MsgType.Image }}
fitParent={isGallery}
renderImageContent={(imageProps) => (
<ImageContent
{...imageProps}
Expand Down Expand Up @@ -395,6 +408,7 @@
<MVideo
content={content as Record<string, never> & { msgtype: MsgType.Video }}
renderAsFile={renderFile}
fitParent={isGallery}
renderVideoContent={({ body, info, ...videoProps }) => (
<VideoContent
body={body}
Expand Down Expand Up @@ -429,13 +443,59 @@
<AudioContent {...audioProps} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
outlined={outlineAttachment}
fitParent={isGallery}
/>
);
}

if (msgType === (MsgType.File as string)) return renderFile();
if (msgType === (MsgType.Location as string))
return <MLocation showMaps={showMaps} content={content} />;

if (msgType === GALLERY_MSGTYPE) {
const galleryContent = getContent() as IGalleryContent;
return (
<MGallery
content={galleryContent}
renderItem={(itemContent) => (
<RenderMessageContent
displayName={displayName}
msgType={itemContent.msgtype as string}
ts={ts}
getContent={() => itemContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={outlineAttachment}
isGallery={true}
/>
)}
renderCaption={
galleryContent.body
? () => (
<MText
style={{ marginTop: config.space.S200 }}
edited={edited}
content={galleryContent}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
)
: undefined
}
/>
);
}

if (msgType === 'm.bad.encrypted') return <MBadEncrypted />;

// cute events
Expand Down
69 changes: 69 additions & 0 deletions src/app/components/message/MGallery.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { recipe } from '@vanilla-extract/recipes';
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';

export const GalleryHolder = style({
marginTop: config.space.S200,
});

export const GalleryItem = recipe({
base: [
DefaultReset,
{
display: 'flex',
maxWidth: toRem(450),
flexShrink: 0,
flexGrow: 1,
overflow: 'hidden',
borderRadius: config.radii.R300,
alignSelf: 'stretch',
backgroundColor: '#AA00AA',
},
],
});

export const GalleryHolderGradient = recipe({
base: [
DefaultReset,
{
position: 'absolute',
height: '100%',
width: toRem(10),
zIndex: 1,
},
],
variants: {
position: {
Left: {
left: 0,
background: `linear-gradient(to right,${color.Surface.Container} , rgba(116,116,116,0))`,
},
Right: {
right: 0,
background: `linear-gradient(to left,${color.Surface.Container} , rgba(116,116,116,0))`,
},
},
},
});

export const GalleryHolderBtn = recipe({
base: [
DefaultReset,
{
position: 'absolute',
zIndex: 1,
},
],
variants: {
position: {
Left: {
left: 0,
transform: 'translateX(-25%)',
},
Right: {
right: 0,
transform: 'translateX(25%)',
},
},
},
});
134 changes: 134 additions & 0 deletions src/app/components/message/MGallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { ReactNode } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Scroll } from 'folds';
import { MsgType, type IContent } from 'matrix-js-sdk';

Check failure on line 4 in src/app/components/message/MGallery.tsx

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-unused-vars)

Identifier 'MsgType' is imported but never used.
import type { IGalleryContent, IGalleryItem } from '$types/matrix/common';
import {
getIntersectionObserverEntry,
useIntersectionObserver,
} from '$hooks/useIntersectionObserver';
import * as css from './MGallery.css';

function galleryItemToContent(item: IGalleryItem): IContent {
const { itemtype, ...rest } = item;
return { ...rest, msgtype: itemtype } as IContent;
}

type MGalleryProps = {
content: IGalleryContent;
renderItem: (content: IContent, index: number) => ReactNode;
renderCaption?: () => ReactNode;
};

export function MGallery({ content, renderItem, renderCaption }: MGalleryProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const backAnchorRef = useRef<HTMLDivElement>(null);
const frontAnchorRef = useRef<HTMLDivElement>(null);
const [backVisible, setBackVisible] = useState(true);
const [frontVisible, setFrontVisible] = useState(true);

const intersectionObserver = useIntersectionObserver(
useCallback((entries) => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
if (backEntry) {
setBackVisible(backEntry.isIntersecting);
}
if (frontEntry) {
setFrontVisible(frontEntry.isIntersecting);
}
}, []),
useCallback(
() => ({
root: scrollRef.current,
rootMargin: '10px',
}),
[]
)
);

useEffect(() => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
if (backAnchor) intersectionObserver?.observe(backAnchor);
if (frontAnchor) intersectionObserver?.observe(frontAnchor);
return () => {
if (backAnchor) intersectionObserver?.unobserve(backAnchor);
if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
};
}, [intersectionObserver]);

const handleScrollBack = () => {
const scroll = scrollRef.current;
if (!scroll) return;
const { offsetWidth, scrollLeft } = scroll;
scroll.scrollTo({
left: scrollLeft - offsetWidth / 1.3,
behavior: 'smooth',
});
};
const handleScrollFront = () => {
const scroll = scrollRef.current;
if (!scroll) return;
const { offsetWidth, scrollLeft } = scroll;
scroll.scrollTo({
left: scrollLeft + offsetWidth / 1.3,
behavior: 'smooth',
});
};

const items = content.itemtypes;

return (
<Box direction="Column">
<Box className={css.GalleryHolder} direction="Column" style={{ position: 'relative' }}>
<Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
<Box shrink="No" alignItems="Center">
<div ref={backAnchorRef} />
{!backVisible && (
<>
<div className={css.GalleryHolderGradient({ position: 'Left' })} />
<IconButton
className={css.GalleryHolderBtn({ position: 'Left' })}
variant="Secondary"
radii="Pill"
size="300"
outlined
onClick={handleScrollBack}
>
<Icon size="300" src={Icons.ArrowLeft} />
</IconButton>
</>
)}
<Box alignItems="Inherit" gap="200">
{items.map((item, index) => (
<div key={item.url ?? item.file?.url ?? index} className={css.GalleryItem()}>
{renderItem(galleryItemToContent(item), index)}
</div>
))}
{!frontVisible && (
<>
<div className={css.GalleryHolderGradient({ position: 'Right' })} />
<IconButton
className={css.GalleryHolderBtn({ position: 'Right' })}
variant="Primary"
radii="Pill"
size="300"
outlined
onClick={handleScrollFront}
>
<Icon size="300" src={Icons.ArrowRight} />
</IconButton>
</>
)}
<div ref={frontAnchorRef} />
</Box>
</Box>
</Scroll>
</Box>
{renderCaption?.()}
</Box>
);
}
Loading
Loading