feat: Implement image carousel functionality with metadata handling and UI enhancements
This commit is contained in:
35
package-lock.json
generated
35
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HTMLElement>;
|
||||
@@ -305,12 +316,34 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<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',
|
||||
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%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) => (
|
||||
<Box
|
||||
@@ -169,6 +227,183 @@ const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
|
||||
)
|
||||
);
|
||||
|
||||
type CarouselScrollerProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const CarouselScroller = ({ children }: CarouselScrollerProps) => {
|
||||
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 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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<Box style={{ position: 'relative' }}>
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
className={classNames(css.CarouselScroller, isDragging && css.CarouselScrollerDragging)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={endDrag}
|
||||
onMouseLeave={endDrag}
|
||||
onClickCapture={handleClickCapture}
|
||||
>
|
||||
<div ref={backAnchorRef} />
|
||||
{children}
|
||||
<div ref={frontAnchorRef} />
|
||||
</Box>
|
||||
|
||||
{!backVisible && (
|
||||
<>
|
||||
<div className={css.CarouselEdgeGradient({ position: 'Left' })} />
|
||||
<IconButton
|
||||
className={css.CarouselScrollBtn({ position: 'Left' })}
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
size="300"
|
||||
outlined
|
||||
onClick={handleScrollBack}
|
||||
>
|
||||
<Icon size="300" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!frontVisible && (
|
||||
<>
|
||||
<div className={css.CarouselEdgeGradient({ position: 'Right' })} />
|
||||
<IconButton
|
||||
className={css.CarouselScrollBtn({ position: 'Right' })}
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
size="300"
|
||||
outlined
|
||||
onClick={handleScrollFront}
|
||||
>
|
||||
<Icon size="300" src={Icons.ArrowRight} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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<string>();
|
||||
|
||||
// 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 (
|
||||
<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}>
|
||||
<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 [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);
|
||||
|
||||
@@ -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<IContent> => {
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user