feat: Add carousel functionality with scrolling and image handling in RoomTimeline

This commit is contained in:
2026-05-17 16:46:31 +10:00
parent 9b81530012
commit 62876edd77
3 changed files with 379 additions and 12 deletions

View File

@@ -1,5 +1,6 @@
import { style } from '@vanilla-extract/css';
import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { DefaultReset, config } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
export const TimelineFloat = recipe({ export const TimelineFloat = recipe({
base: [ base: [
@@ -28,3 +29,81 @@ export const TimelineFloat = recipe({
}); });
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>; export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
export const CarouselScroller = style([
DefaultReset,
{
display: 'flex',
gap: config.space.S200,
overflowX: 'auto',
overflowY: 'hidden',
paddingBottom: config.space.S100,
width: '100%',
cursor: 'grab',
userSelect: 'none',
},
]);
export const CarouselScrollerDragging = style([
DefaultReset,
{
cursor: 'grabbing',
},
]);
export const CarouselItem = style([
DefaultReset,
{
flex: '0 0 auto',
maxWidth: '100%',
},
]);
export const CarouselEdgeGradient = recipe({
base: [
DefaultReset,
{
position: 'absolute',
top: 0,
height: '100%',
width: toRem(16),
pointerEvents: 'none',
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 CarouselScrollBtn = recipe({
base: [
DefaultReset,
{
position: 'absolute',
top: '50%',
zIndex: 2,
},
],
variants: {
position: {
Left: {
left: 0,
transform: 'translate(-25%, -50%)',
},
Right: {
right: 0,
transform: 'translate(25%, -50%)',
},
},
},
});

View File

@@ -19,6 +19,7 @@ import {
IContent, IContent,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
MsgType,
Room, Room,
RoomEvent, RoomEvent,
RoomEventHandlerMap, RoomEventHandlerMap,
@@ -35,6 +36,7 @@ import {
Chip, Chip,
ContainerColor, ContainerColor,
Icon, Icon,
IconButton,
Icons, Icons,
Line, Line,
Scroll, Scroll,
@@ -62,6 +64,7 @@ import {
Time, Time,
MessageNotDecryptedContent, MessageNotDecryptedContent,
RedactedContent, RedactedContent,
MImage,
MSticker, MSticker,
ImageContent, ImageContent,
EventContent, EventContent,
@@ -135,6 +138,12 @@ import {
useRoomWideEmbedFilters, useRoomWideEmbedFilters,
combineEmbedFilters, combineEmbedFilters,
} from '../../hooks/useRoomEmbedFilters'; } from '../../hooks/useRoomEmbedFilters';
import {
IImageContent,
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common';
/** Information about a call member event for grouping */ /** Information about a call member event for grouping */
interface CallEventInfo { interface CallEventInfo {
@@ -147,6 +156,39 @@ interface CallEventInfo {
mEvent: MatrixEvent; mEvent: MatrixEvent;
} }
interface CarouselEventMetadata {
uuid: string;
index: number;
total: number;
content: IImageContent;
}
interface CarouselRenderableEvent {
mEvent: MatrixEvent;
mEventId: string;
item: number;
content: IImageContent;
index: number;
}
const getCarouselEventMetadata = (mEvent: MatrixEvent): CarouselEventMetadata | undefined => {
if (mEvent.getType() !== MessageEvent.RoomMessage || mEvent.isRedacted()) {
return undefined;
}
const content = mEvent.getContent() as IImageContent;
const uuid = content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME];
const index = content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME];
const total = content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME];
if (content.msgtype !== MsgType.Image) return undefined;
if (typeof uuid !== 'string') return undefined;
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) return undefined;
if (typeof total !== 'number' || !Number.isInteger(total) || total < 1) return undefined;
return { uuid, index, total, content };
};
const TimelineFloat = as<'div', css.TimelineFloatVariants>( const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => ( ({ position, className, ...props }, ref) => (
<Box <Box
@@ -170,6 +212,81 @@ const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
) )
); );
type CarouselScrollerProps = {
children: React.ReactNode;
};
function CarouselScroller({ children }: CarouselScrollerProps) {
const {
scrollRef,
isDragging,
canScrollBack,
canScrollForward,
scrollBack,
scrollForward,
scrollProps,
} = useInertialHorizontalScroll();
return (
<Box style={{ position: 'relative' }}>
<Box
ref={scrollRef}
className={classNames(css.CarouselScroller, isDragging && css.CarouselScrollerDragging)}
{...scrollProps}
>
{children}
</Box>
{canScrollBack && (
<>
<div className={css.CarouselEdgeGradient({ position: 'Left' })} />
<IconButton
className={css.CarouselScrollBtn({ position: 'Left' })}
variant="Secondary"
radii="Pill"
size="300"
outlined
onClick={scrollBack}
>
<Icon size="300" src={Icons.ArrowLeft} />
</IconButton>
</>
)}
{canScrollForward && (
<>
<div className={css.CarouselEdgeGradient({ position: 'Right' })} />
<IconButton
className={css.CarouselScrollBtn({ position: 'Right' })}
variant="Primary"
radii="Pill"
size="300"
outlined
onClick={scrollForward}
>
<Icon size="300" src={Icons.ArrowRight} />
</IconButton>
</>
)}
</Box>
);
}
const getCarouselItemWidth = (content: IImageContent): string => {
const width = content.info?.w;
const height = content.info?.h;
if (!width || !height || height <= 0) {
return 'min(24rem, 72vw)';
}
const aspectRatio = width / height;
const baseHeightRem = 22;
const computedWidthRem = Math.max(11, Math.min(24, baseHeightRem * aspectRatio));
return `min(${computedWidthRem}rem, 72vw)`;
};
export const getLiveTimeline = (room: Room): EventTimeline => export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline(); room.getUnfilteredTimelineSet().getLiveTimeline();
@@ -1753,6 +1870,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
let isPrevRendered = false; let isPrevRendered = false;
let newDivider = false; let newDivider = false;
let dayDivider = false; let dayDivider = false;
const groupedCarouselEventIds = new Set<string>();
// Clear pending call events at the start of each render // Clear pending call events at the start of each render
pendingCallEventsRef.current = []; pendingCallEventsRef.current = [];
@@ -1874,6 +1992,110 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
); );
}; };
const renderImageCarouselMessage = (
carouselEvents: CarouselRenderableEvent[],
mEventId: string,
mEvent: MatrixEvent,
item: number,
timelineSet: EventTimelineSet,
collapse: boolean
): React.ReactNode => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const orderedCarouselEvents = [...carouselEvents].sort((a, b) => a.index - b.index);
return (
<Message
key={`carousel-${mEventId}`}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onOpenThread={handleOpenThread}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
/>
)
}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<CarouselScroller>
{orderedCarouselEvents.map((carouselEvent) => (
<Box
key={carouselEvent.mEventId}
className={css.CarouselItem}
style={{ width: getCarouselItemWidth(carouselEvent.content) }}
>
<MImage
content={carouselEvent.content}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={messageLayout === MessageLayout.Bubble}
/>
</Box>
))}
</CarouselScroller>
)}
</Message>
);
};
const eventRenderer = (item: number, index: number, allItems: number[]) => { const eventRenderer = (item: number, index: number, allItems: number[]) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!eventTimeline) return null; if (!eventTimeline) return null;
@@ -1898,6 +2120,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
} }
if (groupedCarouselEventIds.has(mEventId)) {
prevEvent = mEvent;
isPrevRendered = true;
return null;
}
const collapsed = const collapsed =
isPrevRendered && isPrevRendered &&
!dayDivider && !dayDivider &&
@@ -1907,17 +2135,71 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
prevEvent.getType() === mEvent.getType() && prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
const rawEventJSX = reactionOrEditEvent(mEvent) const getEventByItem = (
? null timelineItem: number
: renderMatrixEvent( ): { event: MatrixEvent; eventId: string } | undefined => {
mEvent.getType(), const [tm, bIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, timelineItem);
typeof mEvent.getStateKey() === 'string', if (!tm) return undefined;
mEventId,
mEvent, const event = getTimelineEvent(tm, getTimelineRelativeIndex(timelineItem, bIndex));
item, const nextEventId = event?.getId();
timelineSet, if (!event || !nextEventId) return undefined;
collapsed
); return { event, eventId: nextEventId };
};
const carouselMetadata = getCarouselEventMetadata(mEvent);
const carouselEvents: CarouselRenderableEvent[] = [];
if (carouselMetadata && eventSender) {
carouselEvents.push({
mEvent,
mEventId,
item,
content: carouselMetadata.content,
index: carouselMetadata.index,
});
for (let nextIndex = index + 1; nextIndex < allItems.length; nextIndex += 1) {
const nextEvt = getEventByItem(allItems[nextIndex]);
if (!nextEvt) break;
const nextSender = nextEvt.event.getSender();
const nextMeta = getCarouselEventMetadata(nextEvt.event);
if (!nextMeta || nextSender !== eventSender || nextMeta.uuid !== carouselMetadata.uuid) {
break;
}
groupedCarouselEventIds.add(nextEvt.eventId);
carouselEvents.push({
mEvent: nextEvt.event,
mEventId: nextEvt.eventId,
item: allItems[nextIndex],
content: nextMeta.content,
index: nextMeta.index,
});
if (carouselEvents.length >= carouselMetadata.total) {
break;
}
}
}
let rawEventJSX: React.ReactNode = null;
if (!reactionOrEditEvent(mEvent)) {
rawEventJSX =
carouselEvents.length > 1
? renderImageCarouselMessage(carouselEvents, mEventId, mEvent, item, timelineSet, collapsed)
: renderMatrixEvent(
mEvent.getType(),
typeof mEvent.getStateKey() === 'string',
mEventId,
mEvent,
item,
timelineSet,
collapsed
);
}
// Check if this is a call event that should be grouped // Check if this is a call event that should be grouped
const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode); const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode);

View File

@@ -4,6 +4,9 @@ import { MsgType } from 'matrix-js-sdk';
export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash'; export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler'; export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler';
export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason'; export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason';
export const PAARROT_CAROUSEL_UUID_PROPERTY_NAME = 'com.paarrot.carousel_uuid';
export const PAARROT_CAROUSEL_INDEX_PROPERTY_NAME = 'com.paarrot.carousel_index';
export const PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME = 'com.paarrot.carousel_total';
export type IImageInfo = { export type IImageInfo = {
w?: number; w?: number;
@@ -51,6 +54,9 @@ export type IImageContent = {
file?: IEncryptedFile; file?: IEncryptedFile;
[MATRIX_SPOILER_PROPERTY_NAME]?: boolean; [MATRIX_SPOILER_PROPERTY_NAME]?: boolean;
[MATRIX_SPOILER_REASON_PROPERTY_NAME]?: string; [MATRIX_SPOILER_REASON_PROPERTY_NAME]?: string;
[PAARROT_CAROUSEL_UUID_PROPERTY_NAME]?: string;
[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME]?: number;
[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME]?: number;
}; };
export type IVideoContent = { export type IVideoContent = {