From a3caafaf2041aa271a2412fe048da803acf37a74 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sun, 17 May 2026 13:24:44 +1000 Subject: [PATCH] feat: Implement image carousel functionality with metadata handling and UI enhancements --- package-lock.json | 35 +- package.json | 2 + src/app/features/room/RoomInput.tsx | 35 +- src/app/features/room/RoomTimeline.css.ts | 82 ++++- src/app/features/room/RoomTimeline.tsx | 418 +++++++++++++++++++++- src/app/features/room/msgContent.ts | 17 +- src/types/matrix/common.ts | 6 + vite.config.js | 1 + 8 files changed, 570 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index df80177..b58dfbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@capacitor/core": "8.3.4", + "@capacitor/local-notifications": "8.2.0", "@fontsource/inter": "4.5.14", - "@paarrot/plugin-manager": "file:../../PluginManager", + "@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -114,15 +116,6 @@ "node": ">=16.0.0" } }, - "../../PluginManager": { - "name": "@paarrot/plugin-manager", - "version": "1.0.0", - "devDependencies": { - "@types/node": "^25.6.0", - "rimraf": "^5.0.0", - "typescript": "^5.4.0" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -1678,6 +1671,24 @@ "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@capacitor/core": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz", + "integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/local-notifications": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz", + "integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@emotion/hash": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", @@ -2344,8 +2355,8 @@ } }, "node_modules/@paarrot/plugin-manager": { - "resolved": "../../PluginManager", - "link": true + "version": "1.0.0", + "resolved": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git#6c389381df3966568a40c39d8661f9a4300f7819" }, "node_modules/@react-aria/breadcrumbs": { "version": "3.5.20", diff --git a/package.json b/package.json index ce876f3..8e599e7 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@capacitor/core": "8.3.4", + "@capacitor/local-notifications": "8.2.0", "@fontsource/inter": "4.5.14", "@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git", "@tanstack/react-query": "5.24.1", diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 8242850..b39861d 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -125,6 +125,17 @@ import { useOtherUserColor } from '../../hooks/useUserColor'; import { getMemberAvatarMxc } from '../../utils/room'; import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter'; +/** + * Creates a UUID used to group image events into one carousel. + */ +const createCarouselUuid = (): string => { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + interface RoomInputProps { editor: Editor; fileDropContainerRef: RefObject; @@ -305,12 +316,34 @@ export const RoomInput = forwardRef( }; const handleSendUpload = async (uploads: UploadSuccess[]) => { + const imageUploads = uploads.filter((upload) => { + const fileItem = selectedFiles.find((f) => f.file === upload.file); + return !!fileItem && fileItem.file.type.startsWith('image'); + }); + const carouselUuid = imageUploads.length > 1 ? createCarouselUuid() : undefined; + const imageUploadIndexByFile = new Map( + imageUploads.map((upload, index) => [upload.file, index] as const) + ); + const contentsPromises = uploads.map(async (upload) => { const fileItem = selectedFiles.find((f) => f.file === upload.file); if (!fileItem) throw new Error('Broken upload'); if (fileItem.file.type.startsWith('image')) { - return getImageMsgContent(mx, fileItem, upload.mxc); + const imageIndex = imageUploadIndexByFile.get(upload.file); + + return getImageMsgContent( + mx, + fileItem, + upload.mxc, + carouselUuid !== undefined && imageIndex !== undefined + ? { + uuid: carouselUuid, + index: imageIndex, + total: imageUploads.length, + } + : undefined + ); } if (fileItem.file.type.startsWith('video')) { return getVideoMsgContent(mx, fileItem, upload.mxc); diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 9cd428e..ed298cc 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -1,5 +1,6 @@ import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; -import { DefaultReset, config } from 'folds'; +import { style } from '@vanilla-extract/css'; +import { DefaultReset, color, config, toRem } from 'folds'; export const TimelineFloat = recipe({ base: [ @@ -28,3 +29,82 @@ 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', + width: `min(${toRem(380)}, 72vw)`, + 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 2997026..5d7b1a5 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, @@ -105,6 +108,12 @@ import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; import { activeThreadIdAtomFamily } from '../../state/activeThread'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; +import { + IImageContent, + PAARROT_CAROUSEL_INDEX_PROPERTY_NAME, + PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME, + PAARROT_CAROUSEL_UUID_PROPERTY_NAME, +} from '../../../types/matrix/common'; import { useKeyDown } from '../../hooks/useKeyDown'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { RenderMessageContent } from '../../components/RenderMessageContent'; @@ -146,6 +155,55 @@ 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; +} + +/** + * Extracts carousel metadata from an m.image room message. + */ +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; +}; + +const CarouselScroller = ({ children }: CarouselScrollerProps) => { + const scrollRef = useRef(null); + const backAnchorRef = useRef(null); + const frontAnchorRef = useRef(null); + const [backVisible, setBackVisible] = useState(true); + const [frontVisible, setFrontVisible] = useState(true); + const dragStateRef = useRef({ + startX: 0, + startScrollLeft: 0, + dragging: false, + moved: false, + suppressClick: false, + }); + const [isDragging, setIsDragging] = useState(false); + + 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 handleMouseDown = (evt: React.MouseEvent) => { + if (evt.button !== 0) return; + const scroll = scrollRef.current; + if (!scroll) return; + + const dragState = dragStateRef.current; + dragState.startX = evt.clientX; + dragState.startScrollLeft = scroll.scrollLeft; + dragState.dragging = true; + dragState.moved = false; + dragState.suppressClick = false; + setIsDragging(true); + }; + + const handleMouseMove = (evt: React.MouseEvent) => { + const dragState = dragStateRef.current; + if (!dragState.dragging) { + return; + } + + evt.preventDefault(); + const scroll = scrollRef.current; + if (!scroll) return; + + const deltaX = evt.clientX - dragState.startX; + if (Math.abs(deltaX) > 5) { + dragState.moved = true; + } + scroll.scrollLeft = dragState.startScrollLeft - deltaX; + }; + + const endDrag = () => { + const dragState = dragStateRef.current; + if (!dragState.dragging) return; + + if (dragState.moved) { + dragState.suppressClick = true; + } + dragState.dragging = false; + setIsDragging(false); + }; + + const handleClickCapture = (evt: React.MouseEvent) => { + const dragState = dragStateRef.current; + if (!dragState.suppressClick) { + return; + } + + dragState.suppressClick = false; + evt.preventDefault(); + evt.stopPropagation(); + }; + + const handleScrollFront = () => { + const scroll = scrollRef.current; + if (!scroll) return; + + scroll.scrollTo({ + left: scroll.scrollLeft + scroll.offsetWidth / 1.3, + behavior: 'smooth', + }); + }; + + const handleScrollBack = () => { + const scroll = scrollRef.current; + if (!scroll) return; + + scroll.scrollTo({ + left: scroll.scrollLeft - scroll.offsetWidth / 1.3, + behavior: 'smooth', + }); + }; + + return ( + + +
+ {children} +
+ + + {!backVisible && ( + <> +
+ + + + + )} + + {!frontVisible && ( + <> +
+ + + + + )} + + ); +}; + export const getLiveTimeline = (room: Room): EventTimeline => room.getUnfilteredTimelineSet().getLiveTimeline(); @@ -1748,6 +1983,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 = []; @@ -1869,6 +2105,106 @@ 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; @@ -1902,17 +2238,77 @@ 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 - ); + if (groupedCarouselEventIds.has(mEventId)) { + prevEvent = mEvent; + isPrevRendered = true; + return null; + } + + 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 eventId = event?.getId(); + if (!event || !eventId) return undefined; + + return { event, eventId }; + }; + + 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/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index 5b7cd14..26bb5f0 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -4,6 +4,9 @@ import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME, MATRIX_SPOILER_PROPERTY_NAME, + PAARROT_CAROUSEL_INDEX_PROPERTY_NAME, + PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME, + PAARROT_CAROUSEL_UUID_PROPERTY_NAME, } from '../../../types/matrix/common'; import { getImageFileUrl, @@ -18,6 +21,12 @@ import { TUploadItem } from '../../state/room/roomInputDrafts'; import { encodeBlurHash } from '../../utils/blurHash'; import { scaleYDimension } from '../../utils/common'; +type CarouselMetadata = { + uuid: string; + index: number; + total: number; +}; + const generateThumbnailContent = async ( mx: MatrixClient, img: HTMLImageElement | HTMLVideoElement, @@ -46,7 +55,8 @@ const generateThumbnailContent = async ( export const getImageMsgContent = async ( mx: MatrixClient, item: TUploadItem, - mxc: string + mxc: string, + carouselMetadata?: CarouselMetadata ): Promise => { const { file, originalFile, encInfo, metadata } = item; const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile))); @@ -74,6 +84,11 @@ export const getImageMsgContent = async ( } else { content.url = mxc; } + if (carouselMetadata) { + content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME] = carouselMetadata.uuid; + content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME] = carouselMetadata.index; + content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME] = carouselMetadata.total; + } return content; }; 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 = { diff --git a/vite.config.js b/vite.config.js index 95d9911..f0b755a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -220,6 +220,7 @@ export default defineConfig({ server: { port: 38347, host: '0.0.0.0', + open: false, cors: true, // Enable CORS for dev server fs: { // Allow serving files from one level up to the project root