Skip to content

Commit 55a9032

Browse files
nikiwastakensmokku
andcommitted
Add gallery support
Co-Authored-By: Tomasz Sterna <tomasz@sterna.link>
1 parent 4d6137b commit 55a9032

13 files changed

Lines changed: 469 additions & 15 deletions

File tree

src/app/components/RenderMessageContent.tsx

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
MNotice,
2626
MText,
2727
MVideo,
28+
MGallery,
2829
ReadPdfFile,
2930
ReadTextFile,
3031
RenderBody,
@@ -49,7 +50,8 @@ import { ClientSideHoverFreeze } from './ClientSideHoverFreeze';
4950
import { CuteEventType, MCuteEvent } from './message/MCuteEvent';
5051
import { PollEvent } from './message/PollEvent';
5152
import { M_TEXT } from 'matrix-js-sdk';
52-
import type { IImageInfo } from '$types/matrix/common';
53+
import type { IImageInfo, IGalleryContent } from '$types/matrix/common';
54+
import { GALLERY_MSGTYPE } from '$types/matrix/common';
5355

5456
type RenderMessageContentProps = {
5557
displayName: string;
@@ -61,6 +63,7 @@ type RenderMessageContentProps = {
6163
bundledPreview?: boolean;
6264
urlPreview?: boolean;
6365
clientUrlPreview?: boolean;
66+
isGallery?: boolean;
6467
showMaps?: boolean;
6568
highlightRegex?: RegExp;
6669
htmlReactParserOptions: HTMLReactParserOptions;
@@ -94,6 +97,7 @@ function RenderMessageContentInternal({
9497
edited,
9598
getContent,
9699
mediaAutoLoad,
100+
isGallery,
97101
bundledPreview,
98102
urlPreview,
99103
clientUrlPreview,
@@ -260,9 +264,18 @@ function RenderMessageContentInternal({
260264
style={{
261265
display: 'flex',
262266
flexDirection: attachmentDirection,
267+
width: '100%',
268+
height: '100%',
263269
}}
264270
>
265-
<div>{attachment}</div>
271+
<div
272+
style={{
273+
width: '100%',
274+
height: '100%',
275+
}}
276+
>
277+
{attachment}
278+
</div>
266279
{renderCaption()}
267280
</div>
268281
);
@@ -272,6 +285,7 @@ function RenderMessageContentInternal({
272285
renderCaptionedAttachment(
273286
<MFile
274287
content={content as Record<string, never> & { msgtype: MsgType.File }}
288+
fitParent={isGallery}
275289
renderFileContent={({ body, mimeType, info, encInfo, url }) => (
276290
<FileContent
277291
body={body}
@@ -368,6 +382,7 @@ function RenderMessageContentInternal({
368382
return renderCaptionedAttachment(
369383
<MImage
370384
content={content as Record<string, never> & { msgtype: MsgType.Image }}
385+
fitParent={isGallery}
371386
renderImageContent={(imageProps) => (
372387
<ImageContent
373388
{...imageProps}
@@ -395,6 +410,7 @@ function RenderMessageContentInternal({
395410
<MVideo
396411
content={content as Record<string, never> & { msgtype: MsgType.Video }}
397412
renderAsFile={renderFile}
413+
fitParent={isGallery}
398414
renderVideoContent={({ body, info, ...videoProps }) => (
399415
<VideoContent
400416
body={body}
@@ -429,13 +445,59 @@ function RenderMessageContentInternal({
429445
<AudioContent {...audioProps} renderMediaControl={(p) => <MediaControl {...p} />} />
430446
)}
431447
outlined={outlineAttachment}
448+
fitParent={isGallery}
432449
/>
433450
);
434451
}
435452

436453
if (msgType === (MsgType.File as string)) return renderFile();
437454
if (msgType === (MsgType.Location as string))
438455
return <MLocation showMaps={showMaps} content={content} />;
456+
457+
if (msgType === GALLERY_MSGTYPE) {
458+
const galleryContent = getContent() as IGalleryContent;
459+
return (
460+
<MGallery
461+
content={galleryContent}
462+
renderItem={(itemContent) => (
463+
<RenderMessageContent
464+
displayName={displayName}
465+
msgType={itemContent.msgtype as string}
466+
ts={ts}
467+
getContent={() => itemContent}
468+
mediaAutoLoad={mediaAutoLoad}
469+
urlPreview={urlPreview}
470+
highlightRegex={highlightRegex}
471+
htmlReactParserOptions={htmlReactParserOptions}
472+
linkifyOpts={linkifyOpts}
473+
outlineAttachment={outlineAttachment}
474+
isGallery={true}
475+
/>
476+
)}
477+
renderCaption={
478+
galleryContent.body
479+
? () => (
480+
<MText
481+
style={{ marginTop: config.space.S200 }}
482+
edited={edited}
483+
content={galleryContent}
484+
renderBody={(props) => (
485+
<RenderBody
486+
{...props}
487+
highlightRegex={highlightRegex}
488+
htmlReactParserOptions={htmlReactParserOptions}
489+
linkifyOpts={linkifyOpts}
490+
/>
491+
)}
492+
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
493+
/>
494+
)
495+
: undefined
496+
}
497+
/>
498+
);
499+
}
500+
439501
if (msgType === 'm.bad.encrypted') return <MBadEncrypted />;
440502

441503
// cute events

src/app/components/media/media.css.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { DefaultReset } from 'folds';
44
export const Image = style([
55
DefaultReset,
66
{
7-
objectFit: 'contain',
7+
objectFit: 'cover',
88
width: '100%',
99
height: '100%',
1010
},
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { recipe } from '@vanilla-extract/recipes';
2+
import { style } from '@vanilla-extract/css';
3+
import { DefaultReset, color, config, toRem } from 'folds';
4+
5+
export const GalleryHolder = style({
6+
position: 'relative',
7+
marginTop: config.space.S200,
8+
});
9+
10+
export const GalleryItem = style({
11+
width: toRem(300),
12+
height: toRem(200),
13+
flexShrink: 0,
14+
overflow: 'hidden',
15+
borderRadius: config.radii.R300,
16+
});
17+
18+
export const GalleryHolderGradient = recipe({
19+
base: [
20+
DefaultReset,
21+
{
22+
position: 'absolute',
23+
height: '100%',
24+
width: toRem(10),
25+
zIndex: 1,
26+
},
27+
],
28+
variants: {
29+
position: {
30+
Left: {
31+
left: 0,
32+
background: `linear-gradient(to right,${color.Surface.Container} , rgba(116,116,116,0))`,
33+
},
34+
Right: {
35+
right: 0,
36+
background: `linear-gradient(to left,${color.Surface.Container} , rgba(116,116,116,0))`,
37+
},
38+
},
39+
},
40+
});
41+
42+
export const GalleryHolderBtn = recipe({
43+
base: [
44+
DefaultReset,
45+
{
46+
position: 'absolute',
47+
zIndex: 1,
48+
},
49+
],
50+
variants: {
51+
position: {
52+
Left: {
53+
left: 0,
54+
transform: 'translateX(-25%)',
55+
},
56+
Right: {
57+
right: 0,
58+
transform: 'translateX(25%)',
59+
},
60+
},
61+
},
62+
});
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import type { ReactNode } from 'react';
2+
import React, { useCallback, useEffect, useRef, useState } from 'react';
3+
import { Box, Icon, IconButton, Icons, Scroll } from 'folds';
4+
import type { IContent } from 'matrix-js-sdk';
5+
import type { IGalleryContent, IGalleryItem } from '$types/matrix/common';
6+
import {
7+
getIntersectionObserverEntry,
8+
useIntersectionObserver,
9+
} from '$hooks/useIntersectionObserver';
10+
import * as css from './MGallery.css';
11+
12+
function galleryItemToContent(item: IGalleryItem): IContent {
13+
const { itemtype, ...rest } = item;
14+
return { ...rest, msgtype: itemtype } as IContent;
15+
}
16+
17+
type MGalleryProps = {
18+
content: IGalleryContent;
19+
renderItem: (content: IContent, index: number) => ReactNode;
20+
renderCaption?: () => ReactNode;
21+
};
22+
23+
export function MGallery({ content, renderItem, renderCaption }: MGalleryProps) {
24+
const scrollRef = useRef<HTMLDivElement>(null);
25+
const backAnchorRef = useRef<HTMLDivElement>(null);
26+
const frontAnchorRef = useRef<HTMLDivElement>(null);
27+
const [backVisible, setBackVisible] = useState(true);
28+
const [frontVisible, setFrontVisible] = useState(true);
29+
30+
const intersectionObserver = useIntersectionObserver(
31+
useCallback((entries) => {
32+
const backAnchor = backAnchorRef.current;
33+
const frontAnchor = frontAnchorRef.current;
34+
const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
35+
const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
36+
if (backEntry) {
37+
setBackVisible(backEntry.isIntersecting);
38+
}
39+
if (frontEntry) {
40+
setFrontVisible(frontEntry.isIntersecting);
41+
}
42+
}, []),
43+
useCallback(
44+
() => ({
45+
root: scrollRef.current,
46+
rootMargin: '10px',
47+
}),
48+
[]
49+
)
50+
);
51+
52+
useEffect(() => {
53+
const backAnchor = backAnchorRef.current;
54+
const frontAnchor = frontAnchorRef.current;
55+
if (backAnchor) intersectionObserver?.observe(backAnchor);
56+
if (frontAnchor) intersectionObserver?.observe(frontAnchor);
57+
return () => {
58+
if (backAnchor) intersectionObserver?.unobserve(backAnchor);
59+
if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
60+
};
61+
}, [intersectionObserver]);
62+
63+
const handleScrollBack = () => {
64+
const scroll = scrollRef.current;
65+
if (!scroll) return;
66+
const { offsetWidth, scrollLeft } = scroll;
67+
scroll.scrollTo({
68+
left: scrollLeft - offsetWidth / 1.3,
69+
behavior: 'smooth',
70+
});
71+
};
72+
const handleScrollFront = () => {
73+
const scroll = scrollRef.current;
74+
if (!scroll) return;
75+
const { offsetWidth, scrollLeft } = scroll;
76+
scroll.scrollTo({
77+
left: scrollLeft + offsetWidth / 1.3,
78+
behavior: 'smooth',
79+
});
80+
};
81+
82+
const items = content.itemtypes;
83+
84+
return (
85+
<Box direction="Column">
86+
<Box className={css.GalleryHolder} direction="Column" style={{ position: 'relative' }}>
87+
<Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
88+
<Box shrink="No" alignItems="Center">
89+
<div ref={backAnchorRef} />
90+
{!backVisible && (
91+
<>
92+
<div className={css.GalleryHolderGradient({ position: 'Left' })} />
93+
<IconButton
94+
className={css.GalleryHolderBtn({ position: 'Left' })}
95+
variant="Secondary"
96+
radii="Pill"
97+
size="300"
98+
outlined
99+
onClick={handleScrollBack}
100+
>
101+
<Icon size="300" src={Icons.ArrowLeft} />
102+
</IconButton>
103+
</>
104+
)}
105+
<Box alignItems="Inherit" gap="200">
106+
{items.map((item, index) => (
107+
<div key={item.url ?? item.file?.url ?? index} className={css.GalleryItem}>
108+
{renderItem(galleryItemToContent(item), index)}
109+
</div>
110+
))}
111+
{!frontVisible && (
112+
<>
113+
<div className={css.GalleryHolderGradient({ position: 'Right' })} />
114+
<IconButton
115+
className={css.GalleryHolderBtn({ position: 'Right' })}
116+
variant="Primary"
117+
radii="Pill"
118+
size="300"
119+
outlined
120+
onClick={handleScrollFront}
121+
>
122+
<Icon size="300" src={Icons.ArrowRight} />
123+
</IconButton>
124+
</>
125+
)}
126+
<div ref={frontAnchorRef} />
127+
</Box>
128+
</Box>
129+
</Scroll>
130+
</Box>
131+
{renderCaption?.()}
132+
</Box>
133+
);
134+
}

0 commit comments

Comments
 (0)