diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 9cd428e..f42b411 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -1,5 +1,6 @@ +import { style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; -import { DefaultReset, config } from 'folds'; +import { DefaultReset, color, config, toRem } from 'folds'; export const TimelineFloat = recipe({ base: [ @@ -28,3 +29,81 @@ export const TimelineFloat = recipe({ }); export type TimelineFloatVariants = RecipeVariants; + +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%)', + }, + }, + }, +}); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 38a7ea5..8443e4f 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -19,6 +19,7 @@ import { IContent, MatrixClient, MatrixEvent, + MsgType, Room, RoomEvent, RoomEventHandlerMap, @@ -35,6 +36,7 @@ import { Chip, ContainerColor, Icon, + IconButton, Icons, Line, Scroll, @@ -62,6 +64,7 @@ import { Time, MessageNotDecryptedContent, RedactedContent, + MImage, MSticker, ImageContent, EventContent, @@ -135,6 +138,12 @@ import { useRoomWideEmbedFilters, combineEmbedFilters, } 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 */ interface CallEventInfo { @@ -147,6 +156,39 @@ interface CallEventInfo { 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>( ({ position, className, ...props }, ref) => ( ( ) ); +type CarouselScrollerProps = { + children: React.ReactNode; +}; + +function CarouselScroller({ children }: CarouselScrollerProps) { + const { + scrollRef, + isDragging, + canScrollBack, + canScrollForward, + scrollBack, + scrollForward, + scrollProps, + } = useInertialHorizontalScroll(); + + return ( + + + {children} + + + {canScrollBack && ( + <> +
+ + + + + )} + + {canScrollForward && ( + <> +
+ + + + + )} + + ); +} + +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 => room.getUnfilteredTimelineSet().getLiveTimeline(); @@ -1753,6 +1870,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli let isPrevRendered = false; let newDivider = false; let dayDivider = false; + const groupedCarouselEventIds = new Set(); // Clear pending call events at the start of each render 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 ( + + ) + } + reactions={ + reactionRelations && ( + + ) + } + hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(senderId)} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {mEvent.isRedacted() ? ( + + ) : ( + + {orderedCarouselEvents.map((carouselEvent) => ( + + ( + } + renderViewer={(p) => } + /> + )} + outlined={messageLayout === MessageLayout.Bubble} + /> + + ))} + + )} + + ); + }; + const eventRenderer = (item: number, index: number, allItems: number[]) => { const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); if (!eventTimeline) return null; @@ -1898,6 +2120,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; } + if (groupedCarouselEventIds.has(mEventId)) { + prevEvent = mEvent; + isPrevRendered = true; + return null; + } + const collapsed = isPrevRendered && !dayDivider && @@ -1907,17 +2135,71 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli prevEvent.getType() === mEvent.getType() && minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; - const rawEventJSX = reactionOrEditEvent(mEvent) - ? null - : renderMatrixEvent( - mEvent.getType(), - typeof mEvent.getStateKey() === 'string', - mEventId, - mEvent, - item, - timelineSet, - collapsed - ); + const getEventByItem = ( + timelineItem: number + ): { event: MatrixEvent; eventId: string } | undefined => { + const [tm, bIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, timelineItem); + if (!tm) return undefined; + + const event = getTimelineEvent(tm, getTimelineRelativeIndex(timelineItem, bIndex)); + const nextEventId = event?.getId(); + if (!event || !nextEventId) return undefined; + + 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 const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode); diff --git a/src/types/matrix/common.ts b/src/types/matrix/common.ts index 210c711..5684402 100644 --- a/src/types/matrix/common.ts +++ b/src/types/matrix/common.ts @@ -4,6 +4,9 @@ import { MsgType } from 'matrix-js-sdk'; 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_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 = { w?: number; @@ -51,6 +54,9 @@ export type IImageContent = { file?: IEncryptedFile; [MATRIX_SPOILER_PROPERTY_NAME]?: boolean; [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 = {