Compare commits
5 Commits
carousel-s
...
c2fc71ddca
| Author | SHA1 | Date | |
|---|---|---|---|
| c2fc71ddca | |||
| 40dc552c8d | |||
| 62876edd77 | |||
| 9b81530012 | |||
| af02b215f4 |
@@ -35,6 +35,7 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?
|
|||||||
size="T400"
|
size="T400"
|
||||||
priority={notice ? '300' : '400'}
|
priority={notice ? '300' : '400'}
|
||||||
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
|
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
|
||||||
|
data-allow-text-selection="true"
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ export const MessageTextBody = recipe({
|
|||||||
overflow: 'clip',
|
overflow: 'clip',
|
||||||
overflowY: 'clip',
|
overflowY: 'clip',
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
|
userSelect: 'text',
|
||||||
|
WebkitUserSelect: 'text',
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
preWrap: {
|
preWrap: {
|
||||||
|
|||||||
@@ -60,10 +60,12 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
|||||||
|
|
||||||
export function PageNavContent({
|
export function PageNavContent({
|
||||||
scrollRef,
|
scrollRef,
|
||||||
|
scrollProps,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
@@ -74,6 +76,7 @@ export function PageNavContent({
|
|||||||
size="300"
|
size="300"
|
||||||
hideTrack
|
hideTrack
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
|
{...scrollProps}
|
||||||
>
|
>
|
||||||
<div className={css.PageNavContent}>{children}</div>
|
<div className={css.PageNavContent}>{children}</div>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
@@ -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%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -52,7 +54,7 @@ import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { autoScrollToBottom, editableActiveElement } from '../../utils/dom';
|
import { autoScrollToBottom, editableActiveElement, isScrolledToBottom } from '../../utils/dom';
|
||||||
import {
|
import {
|
||||||
DefaultPlaceholder,
|
DefaultPlaceholder,
|
||||||
CompactPlaceholder,
|
CompactPlaceholder,
|
||||||
@@ -62,6 +64,7 @@ import {
|
|||||||
Time,
|
Time,
|
||||||
MessageNotDecryptedContent,
|
MessageNotDecryptedContent,
|
||||||
RedactedContent,
|
RedactedContent,
|
||||||
|
MImage,
|
||||||
MSticker,
|
MSticker,
|
||||||
ImageContent,
|
ImageContent,
|
||||||
EventContent,
|
EventContent,
|
||||||
@@ -110,6 +113,7 @@ import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
|||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
import { Image } from '../../components/media';
|
import { Image } from '../../components/media';
|
||||||
import { ImageViewer } from '../../components/image-viewer';
|
import { ImageViewer } from '../../components/image-viewer';
|
||||||
|
import { useInertialHorizontalScroll } from '../../hooks/useInertialHorizontalScroll';
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { useRoomUnread } from '../../state/hooks/unread';
|
import { useRoomUnread } from '../../state/hooks/unread';
|
||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
@@ -134,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 {
|
||||||
@@ -146,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
|
||||||
@@ -169,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();
|
||||||
|
|
||||||
@@ -651,7 +769,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
useCallback(
|
useCallback(
|
||||||
(mEvt: MatrixEvent) => {
|
(mEvt: MatrixEvent) => {
|
||||||
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||||
const shouldStickToBottom = atBottomRef.current || isOwnMessage;
|
const isBottomPinnedEvent =
|
||||||
|
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
||||||
|
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
||||||
|
|
||||||
// Always update timeline with new message
|
// Always update timeline with new message
|
||||||
setTimeline((ct) => ({
|
setTimeline((ct) => ({
|
||||||
@@ -676,10 +796,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
setUnreadInfo(getRoomUnreadInfo(room));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve the pre-update bottom state so a newly added message keeps the view pinned.
|
// Preserve the pre-update bottom state so newly added chat messages keep the view pinned.
|
||||||
scrollToBottomRef.current.count += 1;
|
if (shouldStickToBottom) {
|
||||||
scrollToBottomRef.current.smooth = !isOwnMessage;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.force = shouldStickToBottom;
|
scrollToBottomRef.current.smooth = !isOwnMessage;
|
||||||
|
scrollToBottomRef.current.force = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Show unread message preview for incoming messages when not at bottom
|
// Show unread message preview for incoming messages when not at bottom
|
||||||
if (!isOwnMessage && !atBottomRef.current) {
|
if (!isOwnMessage && !atBottomRef.current) {
|
||||||
@@ -753,7 +875,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const scrollElement = getScrollElement();
|
const scrollElement = getScrollElement();
|
||||||
if (!editorBaseEntry || !scrollElement) return;
|
if (!editorBaseEntry || !scrollElement) return;
|
||||||
|
|
||||||
if (atBottomRef.current) {
|
if (isScrolledToBottom(scrollElement)) {
|
||||||
autoScrollToBottom(scrollElement, false, true);
|
autoScrollToBottom(scrollElement, false, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -774,7 +896,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const previousHeight = timelineContentHeightRef.current;
|
const previousHeight = timelineContentHeightRef.current;
|
||||||
timelineContentHeightRef.current = nextHeight;
|
timelineContentHeightRef.current = nextHeight;
|
||||||
|
|
||||||
if (previousHeight === 0 || nextHeight <= previousHeight || !atBottomRef.current) {
|
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1748,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 = [];
|
||||||
@@ -1869,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;
|
||||||
@@ -1893,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 &&
|
||||||
@@ -1902,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);
|
||||||
@@ -2023,7 +2310,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
ref={timelineContentRef}
|
ref={timelineContentRef}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
justifyContent="End"
|
justifyContent="End"
|
||||||
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
|
style={{
|
||||||
|
minHeight: '100%',
|
||||||
|
padding: `${config.space.S600} 0`,
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
|
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
397
src/app/hooks/useInertialHorizontalScroll.ts
Normal file
397
src/app/hooks/useInertialHorizontalScroll.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type {
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
PointerEvent as ReactPointerEvent,
|
||||||
|
RefObject,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export type UseInertialHorizontalScrollOptions = {
|
||||||
|
axis?: 'x' | 'y';
|
||||||
|
scrollStepRatio?: number;
|
||||||
|
dragThreshold?: number;
|
||||||
|
friction?: number;
|
||||||
|
minVelocity?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseInertialHorizontalScrollResult = {
|
||||||
|
scrollRef: RefObject<HTMLDivElement>;
|
||||||
|
isDragging: boolean;
|
||||||
|
canScrollBack: boolean;
|
||||||
|
canScrollForward: boolean;
|
||||||
|
scrollBack: () => void;
|
||||||
|
scrollForward: () => void;
|
||||||
|
scrollProps: {
|
||||||
|
onPointerDown: (evt: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
|
onPointerMove: (evt: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
|
onPointerUp: (evt: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
|
onPointerCancel: (evt: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
|
onLostPointerCapture: (evt: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
|
onDragStartCapture: (evt: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
onClickCapture: (evt: ReactMouseEvent<HTMLDivElement>) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEXT_SELECTION_TARGET_SELECTOR =
|
||||||
|
'[data-allow-text-selection="true"], input, textarea, [contenteditable="true"]';
|
||||||
|
|
||||||
|
type ScrollState = {
|
||||||
|
activePointerId: number | null;
|
||||||
|
startPrimary: number;
|
||||||
|
startScrollPrimary: number;
|
||||||
|
lastPrimary: number;
|
||||||
|
lastTime: number;
|
||||||
|
velocity: number;
|
||||||
|
dragging: boolean;
|
||||||
|
moved: boolean;
|
||||||
|
suppressClick: boolean;
|
||||||
|
animationFrameId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PointerLikeEvent = {
|
||||||
|
pointerId: number;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
timeStamp: number;
|
||||||
|
preventDefault?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<UseInertialHorizontalScrollOptions> = {
|
||||||
|
axis: 'x',
|
||||||
|
scrollStepRatio: 1.3,
|
||||||
|
dragThreshold: 3,
|
||||||
|
friction: 0.95,
|
||||||
|
minVelocity: 0.02,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared hook for drag-scrolling horizontal overflow areas with inertial momentum.
|
||||||
|
*/
|
||||||
|
export function useInertialHorizontalScroll(
|
||||||
|
options: UseInertialHorizontalScrollOptions = {}
|
||||||
|
): UseInertialHorizontalScrollResult {
|
||||||
|
const resolvedOptions = useMemo(
|
||||||
|
() => ({ ...DEFAULT_OPTIONS, ...options }),
|
||||||
|
[options]
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const stateRef = useRef<ScrollState>({
|
||||||
|
activePointerId: null,
|
||||||
|
startPrimary: 0,
|
||||||
|
startScrollPrimary: 0,
|
||||||
|
lastPrimary: 0,
|
||||||
|
lastTime: 0,
|
||||||
|
velocity: 0,
|
||||||
|
dragging: false,
|
||||||
|
moved: false,
|
||||||
|
suppressClick: false,
|
||||||
|
animationFrameId: null,
|
||||||
|
});
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [canScrollBack, setCanScrollBack] = useState(false);
|
||||||
|
const [canScrollForward, setCanScrollForward] = useState(false);
|
||||||
|
|
||||||
|
const updateScrollState = useCallback(() => {
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!scroll) return;
|
||||||
|
|
||||||
|
const isHorizontal = resolvedOptions.axis === 'x';
|
||||||
|
const scrollPrimary = isHorizontal ? scroll.scrollLeft : scroll.scrollTop;
|
||||||
|
const scrollSize = isHorizontal ? scroll.scrollWidth : scroll.scrollHeight;
|
||||||
|
const clientSize = isHorizontal ? scroll.clientWidth : scroll.clientHeight;
|
||||||
|
const maxScrollPrimary = Math.max(scrollSize - clientSize, 0);
|
||||||
|
setCanScrollBack(scrollPrimary > 1);
|
||||||
|
setCanScrollForward(scrollPrimary < maxScrollPrimary - 1);
|
||||||
|
}, [resolvedOptions.axis]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateScrollState();
|
||||||
|
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!scroll) return undefined;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
updateScrollState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
updateScrollState();
|
||||||
|
};
|
||||||
|
|
||||||
|
scroll.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', handleResize, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scroll.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [updateScrollState]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
const { animationFrameId } = stateRef.current;
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollByStep = useCallback((direction: 1 | -1) => {
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!scroll) return;
|
||||||
|
|
||||||
|
const isHorizontal = resolvedOptions.axis === 'x';
|
||||||
|
const currentPrimary = isHorizontal ? scroll.scrollLeft : scroll.scrollTop;
|
||||||
|
const offsetSize = isHorizontal ? scroll.offsetWidth : scroll.offsetHeight;
|
||||||
|
scroll.scrollTo({
|
||||||
|
...(isHorizontal
|
||||||
|
? { left: currentPrimary + offsetSize / resolvedOptions.scrollStepRatio * direction }
|
||||||
|
: { top: currentPrimary + offsetSize / resolvedOptions.scrollStepRatio * direction }),
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}, [resolvedOptions.axis, resolvedOptions.scrollStepRatio]);
|
||||||
|
|
||||||
|
const stopInertia = useCallback(() => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (state.animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(state.animationFrameId);
|
||||||
|
state.animationFrameId = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startInertia = useCallback(() => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!scroll) return;
|
||||||
|
const isHorizontal = resolvedOptions.axis === 'x';
|
||||||
|
|
||||||
|
const step = (timestamp: number, previousTimestamp: number) => {
|
||||||
|
const currentState = stateRef.current;
|
||||||
|
const currentScroll = scrollRef.current;
|
||||||
|
if (!currentScroll) return;
|
||||||
|
|
||||||
|
const deltaMs = Math.max(timestamp - previousTimestamp, 1);
|
||||||
|
const deltaScroll = currentState.velocity * deltaMs;
|
||||||
|
if (Math.abs(currentState.velocity) < resolvedOptions.minVelocity) {
|
||||||
|
currentState.animationFrameId = null;
|
||||||
|
updateScrollState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = isHorizontal ? currentScroll.scrollLeft : currentScroll.scrollTop;
|
||||||
|
if (isHorizontal) {
|
||||||
|
currentScroll.scrollLeft = before - deltaScroll;
|
||||||
|
} else {
|
||||||
|
currentScroll.scrollTop = before - deltaScroll;
|
||||||
|
}
|
||||||
|
updateScrollState();
|
||||||
|
|
||||||
|
const after = isHorizontal ? currentScroll.scrollLeft : currentScroll.scrollTop;
|
||||||
|
if (after === before) {
|
||||||
|
currentState.animationFrameId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentState.velocity *= Math.pow(resolvedOptions.friction, deltaMs / 16.6667);
|
||||||
|
currentState.animationFrameId = requestAnimationFrame((nextTimestamp) => step(nextTimestamp, timestamp));
|
||||||
|
};
|
||||||
|
|
||||||
|
stopInertia();
|
||||||
|
state.animationFrameId = requestAnimationFrame((timestamp) => step(timestamp, timestamp));
|
||||||
|
}, [resolvedOptions.axis, resolvedOptions.friction, resolvedOptions.minVelocity, stopInertia, updateScrollState]);
|
||||||
|
|
||||||
|
const endDrag = useCallback((pointerId: number | null) => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (!state.dragging) return;
|
||||||
|
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (scroll && pointerId !== null && scroll.hasPointerCapture(pointerId)) {
|
||||||
|
scroll.releasePointerCapture(pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.moved) {
|
||||||
|
state.suppressClick = true;
|
||||||
|
startInertia();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.dragging = false;
|
||||||
|
state.activePointerId = null;
|
||||||
|
state.moved = false;
|
||||||
|
state.velocity = 0;
|
||||||
|
setIsDragging(false);
|
||||||
|
}, [startInertia]);
|
||||||
|
|
||||||
|
const processPointerMove = useCallback((evt: PointerLikeEvent) => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (!state.dragging || state.activePointerId !== evt.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!scroll) return;
|
||||||
|
const isHorizontal = resolvedOptions.axis === 'x';
|
||||||
|
|
||||||
|
const currentPrimary = isHorizontal ? evt.clientX : evt.clientY;
|
||||||
|
const deltaPrimary = currentPrimary - state.startPrimary;
|
||||||
|
const now = evt.timeStamp;
|
||||||
|
const deltaTime = Math.max(now - state.lastTime, 1);
|
||||||
|
|
||||||
|
if (Math.abs(deltaPrimary) >= resolvedOptions.dragThreshold) {
|
||||||
|
state.moved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
evt.preventDefault?.();
|
||||||
|
|
||||||
|
if (isHorizontal) {
|
||||||
|
scroll.scrollLeft = state.startScrollPrimary - deltaPrimary;
|
||||||
|
} else {
|
||||||
|
scroll.scrollTop = state.startScrollPrimary - deltaPrimary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawVelocity = (currentPrimary - state.lastPrimary) / deltaTime;
|
||||||
|
state.velocity = Math.max(Math.min(rawVelocity, 2), -2);
|
||||||
|
state.lastPrimary = currentPrimary;
|
||||||
|
state.lastTime = now;
|
||||||
|
}, [resolvedOptions.axis, resolvedOptions.dragThreshold]);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (evt.button !== 0) return;
|
||||||
|
if (!evt.isPrimary) return;
|
||||||
|
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (state.dragging && state.activePointerId === evt.pointerId) return;
|
||||||
|
|
||||||
|
const target = evt.target;
|
||||||
|
if (target instanceof Element && target.closest(TEXT_SELECTION_TARGET_SELECTOR)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!scroll) return;
|
||||||
|
const isHorizontal = resolvedOptions.axis === 'x';
|
||||||
|
|
||||||
|
stopInertia();
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
|
||||||
|
state.activePointerId = evt.pointerId;
|
||||||
|
state.startPrimary = isHorizontal ? evt.clientX : evt.clientY;
|
||||||
|
state.startScrollPrimary = isHorizontal ? scroll.scrollLeft : scroll.scrollTop;
|
||||||
|
state.lastPrimary = isHorizontal ? evt.clientX : evt.clientY;
|
||||||
|
state.lastTime = evt.timeStamp;
|
||||||
|
state.velocity = 0;
|
||||||
|
state.dragging = true;
|
||||||
|
state.moved = false;
|
||||||
|
state.suppressClick = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
scroll.setPointerCapture(evt.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Ignore capture failures when browser has already ended pointer interaction.
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDragging(true);
|
||||||
|
}, [resolvedOptions.axis, stopInertia]);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback((evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
processPointerMove(evt);
|
||||||
|
}, [processPointerMove]);
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback((evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (state.activePointerId !== evt.pointerId) return;
|
||||||
|
endDrag(evt.pointerId);
|
||||||
|
}, [endDrag]);
|
||||||
|
|
||||||
|
const handlePointerCancel = useCallback((evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (state.activePointerId !== evt.pointerId) return;
|
||||||
|
endDrag(evt.pointerId);
|
||||||
|
}, [endDrag]);
|
||||||
|
|
||||||
|
const handleLostPointerCapture = useCallback((evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (state.activePointerId !== evt.pointerId) return;
|
||||||
|
endDrag(evt.pointerId);
|
||||||
|
}, [endDrag]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleWindowPointerMove = (evt: PointerEvent) => {
|
||||||
|
processPointerMove(evt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowPointerUpOrCancel = (evt: PointerEvent) => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (state.activePointerId !== evt.pointerId) return;
|
||||||
|
endDrag(evt.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', handleWindowPointerMove, true);
|
||||||
|
window.addEventListener('pointerup', handleWindowPointerUpOrCancel, true);
|
||||||
|
window.addEventListener('pointercancel', handleWindowPointerUpOrCancel, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', handleWindowPointerMove, true);
|
||||||
|
window.removeEventListener('pointerup', handleWindowPointerUpOrCancel, true);
|
||||||
|
window.removeEventListener('pointercancel', handleWindowPointerUpOrCancel, true);
|
||||||
|
};
|
||||||
|
}, [endDrag, processPointerMove]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalDragTermination = () => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (!state.dragging) return;
|
||||||
|
endDrag(state.activePointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
handleGlobalDragTermination();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', handleGlobalDragTermination, true);
|
||||||
|
window.addEventListener('pointerup', handleGlobalDragTermination, true);
|
||||||
|
window.addEventListener('blur', handleGlobalDragTermination, true);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mouseup', handleGlobalDragTermination, true);
|
||||||
|
window.removeEventListener('pointerup', handleGlobalDragTermination, true);
|
||||||
|
window.removeEventListener('blur', handleGlobalDragTermination, true);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange, true);
|
||||||
|
};
|
||||||
|
}, [endDrag]);
|
||||||
|
|
||||||
|
const handleClickCapture = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (!state.suppressClick) return;
|
||||||
|
|
||||||
|
state.suppressClick = false;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragStartCapture = useCallback((evt: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollRef,
|
||||||
|
isDragging,
|
||||||
|
canScrollBack,
|
||||||
|
canScrollForward,
|
||||||
|
scrollBack: () => scrollByStep(-1),
|
||||||
|
scrollForward: () => scrollByStep(1),
|
||||||
|
scrollProps: {
|
||||||
|
onPointerDown: handlePointerDown,
|
||||||
|
onPointerMove: handlePointerMove,
|
||||||
|
onPointerUp: handlePointerUp,
|
||||||
|
onPointerCancel: handlePointerCancel,
|
||||||
|
onLostPointerCapture: handleLostPointerCapture,
|
||||||
|
onDragStartCapture: handleDragStartCapture,
|
||||||
|
onClickCapture: handleClickCapture,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ import {
|
|||||||
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
|
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
|
||||||
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
||||||
import { UnreadBadge } from '../../../components/unread-badge';
|
import { UnreadBadge } from '../../../components/unread-badge';
|
||||||
|
|
||||||
type DirectMenuProps = {
|
type DirectMenuProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -235,7 +235,6 @@ const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
|
|||||||
export function Direct() {
|
export function Direct() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('direct');
|
useNavToActivePathMapper('direct');
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const directs = useDirectRooms();
|
const directs = useDirectRooms();
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||||
@@ -246,6 +245,7 @@ export function Direct() {
|
|||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
const noRoomToDisplay = directs.length === 0;
|
const noRoomToDisplay = directs.length === 0;
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const reorderTrigger = useRoomListReorder(mx, directs);
|
const reorderTrigger = useRoomListReorder(mx, directs);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
import type { PluginListenerHandle } from '@capacitor/core';
|
||||||
import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
|
import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
const CAPACITOR_CORE_MODULE_ID = '@capacitor/core';
|
||||||
|
|
||||||
|
type CapacitorCoreModule = typeof import('@capacitor/core');
|
||||||
|
|
||||||
|
const loadCapacitorCore = async (): Promise<CapacitorCoreModule> =>
|
||||||
|
import(/* @vite-ignore */ CAPACITOR_CORE_MODULE_ID);
|
||||||
|
|
||||||
type UnifiedPushStatus = {
|
type UnifiedPushStatus = {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -67,14 +74,15 @@ type StoredPusherState = {
|
|||||||
appId: string;
|
appId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
|
||||||
const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
|
const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
|
||||||
const PUSHER_APP_ID_BASE = 'com.paarrot.app.android';
|
const PUSHER_APP_ID_BASE = 'com.paarrot.app.android';
|
||||||
const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush';
|
const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush';
|
||||||
|
|
||||||
/** Returns true when the current platform is Android Capacitor. */
|
/** Returns true when the current platform is Android Capacitor. */
|
||||||
export const isBackgroundSyncSupported = (): boolean =>
|
export const isBackgroundSyncSupported = (): boolean => {
|
||||||
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
const capacitor = (window as typeof window & { Capacitor?: CapacitorCoreModule['Capacitor'] }).Capacitor;
|
||||||
|
return Boolean(capacitor?.isNativePlatform?.() && capacitor.getPlatform() === 'android');
|
||||||
|
};
|
||||||
|
|
||||||
const getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
|
const getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
|
||||||
`${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
|
`${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
|
||||||
@@ -169,6 +177,9 @@ class AndroidUnifiedPushManager {
|
|||||||
|
|
||||||
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */
|
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */
|
||||||
async start(mx: MatrixClient): Promise<void> {
|
async start(mx: MatrixClient): Promise<void> {
|
||||||
|
const { Capacitor, registerPlugin } = await loadCapacitorCore();
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
|
|
||||||
console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
|
console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
|
||||||
|
|
||||||
if (!isBackgroundSyncSupported()) {
|
if (!isBackgroundSyncSupported()) {
|
||||||
@@ -216,6 +227,9 @@ class AndroidUnifiedPushManager {
|
|||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (!isBackgroundSyncSupported()) return;
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
const { registerPlugin } = await loadCapacitorCore();
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
|
|
||||||
const client = this.client;
|
const client = this.client;
|
||||||
if (client) {
|
if (client) {
|
||||||
const deviceId = client.getDeviceId();
|
const deviceId = client.getDeviceId();
|
||||||
@@ -280,6 +294,8 @@ class AndroidUnifiedPushManager {
|
|||||||
/** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
|
/** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
|
||||||
private async tryRequestDistributorSetup(): Promise<void> {
|
private async tryRequestDistributorSetup(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const { registerPlugin } = await loadCapacitorCore();
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
|
const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
|
||||||
console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
|
console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
|
||||||
const status = await this.safeGetStatus();
|
const status = await this.safeGetStatus();
|
||||||
@@ -335,6 +351,9 @@ class AndroidUnifiedPushManager {
|
|||||||
private async ensureListeners(): Promise<void> {
|
private async ensureListeners(): Promise<void> {
|
||||||
if (this.listenersReady) return;
|
if (this.listenersReady) return;
|
||||||
|
|
||||||
|
const { registerPlugin } = await loadCapacitorCore();
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
|
|
||||||
this.listenerHandles = [
|
this.listenerHandles = [
|
||||||
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
|
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
|
||||||
void this.handleNewEndpoint(event).catch((err) => {
|
void this.handleNewEndpoint(event).catch((err) => {
|
||||||
@@ -363,6 +382,9 @@ class AndroidUnifiedPushManager {
|
|||||||
const client = this.client;
|
const client = this.client;
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
|
const { registerPlugin } = await loadCapacitorCore();
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
|
|
||||||
const status = await this.safeGetStatus();
|
const status = await this.safeGetStatus();
|
||||||
console.log('[BackgroundSync] Native status before pusher sync:', status);
|
console.log('[BackgroundSync] Native status before pusher sync:', status);
|
||||||
if (status?.registered && status.endpoint) {
|
if (status?.registered && status.endpoint) {
|
||||||
@@ -435,6 +457,8 @@ class AndroidUnifiedPushManager {
|
|||||||
/** Read native registration state without failing the caller. */
|
/** Read native registration state without failing the caller. */
|
||||||
private async safeGetStatus(): Promise<UnifiedPushStatus | undefined> {
|
private async safeGetStatus(): Promise<UnifiedPushStatus | undefined> {
|
||||||
try {
|
try {
|
||||||
|
const { registerPlugin } = await loadCapacitorCore();
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
return await MatrixBackgroundSync.getStatus();
|
return await MatrixBackgroundSync.getStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err);
|
console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err);
|
||||||
@@ -458,6 +482,8 @@ export const triggerBackgroundSyncPing = async (reason?: string): Promise<void>
|
|||||||
if (!isBackgroundSyncSupported()) return;
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { registerPlugin } = await loadCapacitorCore();
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
await MatrixBackgroundSync.triggerPing({ reason });
|
await MatrixBackgroundSync.triggerPing({ reason });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[BackgroundSync] triggerPing failed:', err);
|
console.warn('[BackgroundSync] triggerPing failed:', err);
|
||||||
@@ -479,6 +505,8 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
|
|||||||
if (!isBackgroundSyncSupported()) return;
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { registerPlugin } = await loadCapacitorCore();
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
await MatrixBackgroundSync.setAppForeground({ foreground });
|
await MatrixBackgroundSync.setAppForeground({ foreground });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[BackgroundSync] setAppForeground failed:', err);
|
console.warn('[BackgroundSync] setAppForeground failed:', err);
|
||||||
@@ -497,6 +525,8 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { registerPlugin } = await loadCapacitorCore();
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
const result = await MatrixBackgroundSync.requestDistributorSetup();
|
const result = await MatrixBackgroundSync.requestDistributorSetup();
|
||||||
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
|
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
|
||||||
return result ?? { success: false };
|
return result ?? { success: false };
|
||||||
@@ -510,6 +540,8 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
|
|||||||
export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => {
|
export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => {
|
||||||
if (!isBackgroundSyncSupported()) return undefined;
|
if (!isBackgroundSyncSupported()) return undefined;
|
||||||
try {
|
try {
|
||||||
|
const { registerPlugin } = await loadCapacitorCore();
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
return await MatrixBackgroundSync.getStatus();
|
return await MatrixBackgroundSync.getStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[BackgroundSync] getStatus failed:', err);
|
console.warn('[BackgroundSync] getStatus failed:', err);
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ let notificationTapCallback: ((path: string) => void) | null = null;
|
|||||||
const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot';
|
const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot';
|
||||||
const ANDROID_NOTIFICATION_ICON_COLOR = '#FF8A00';
|
const ANDROID_NOTIFICATION_ICON_COLOR = '#FF8A00';
|
||||||
|
|
||||||
|
const CAPACITOR_LOCAL_NOTIFICATIONS_MODULE_ID = '@capacitor/local-notifications';
|
||||||
|
|
||||||
|
type CapacitorLocalNotificationsModule = typeof import('@capacitor/local-notifications');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-load Capacitor local notifications at runtime only.
|
||||||
|
* Vite must not pre-resolve this module because some desktop builds do not include it.
|
||||||
|
*/
|
||||||
|
const importCapacitorLocalNotifications = async (): Promise<CapacitorLocalNotificationsModule> =>
|
||||||
|
import(/* @vite-ignore */ CAPACITOR_LOCAL_NOTIFICATIONS_MODULE_ID);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bring the Tauri window to the front and focus it
|
* Bring the Tauri window to the front and focus it
|
||||||
*/
|
*/
|
||||||
@@ -196,7 +207,7 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void
|
|||||||
|
|
||||||
if (isCapacitorNative()) {
|
if (isCapacitorNative()) {
|
||||||
try {
|
try {
|
||||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
||||||
await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => {
|
await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => {
|
||||||
await focusWindow();
|
await focusWindow();
|
||||||
const path = event?.notification?.extra?.path;
|
const path = event?.notification?.extra?.path;
|
||||||
@@ -336,7 +347,7 @@ const ensureCapacitorNotificationChannel = async (): Promise<void> => {
|
|||||||
if (notificationChannelCreated || !isAndroid()) return;
|
if (notificationChannelCreated || !isAndroid()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
||||||
await LocalNotifications.createChannel({
|
await LocalNotifications.createChannel({
|
||||||
id: 'messages',
|
id: 'messages',
|
||||||
name: 'Messages',
|
name: 'Messages',
|
||||||
@@ -365,7 +376,7 @@ export const requestSystemNotificationPermission = async (): Promise<boolean> =>
|
|||||||
|
|
||||||
if (isCapacitorNative()) {
|
if (isCapacitorNative()) {
|
||||||
try {
|
try {
|
||||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
||||||
let perm = await LocalNotifications.checkPermissions();
|
let perm = await LocalNotifications.checkPermissions();
|
||||||
if (perm.display !== 'granted') {
|
if (perm.display !== 'granted') {
|
||||||
perm = await LocalNotifications.requestPermissions();
|
perm = await LocalNotifications.requestPermissions();
|
||||||
@@ -385,7 +396,7 @@ export const requestSystemNotificationPermission = async (): Promise<boolean> =>
|
|||||||
export const getSystemNotificationPermissionState = async (): Promise<PermissionState> => {
|
export const getSystemNotificationPermissionState = async (): Promise<PermissionState> => {
|
||||||
if (isCapacitorNative()) {
|
if (isCapacitorNative()) {
|
||||||
try {
|
try {
|
||||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
||||||
const perm = await LocalNotifications.checkPermissions();
|
const perm = await LocalNotifications.checkPermissions();
|
||||||
return perm.display === 'granted' ? 'granted' : 'prompt';
|
return perm.display === 'granted' ? 'granted' : 'prompt';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -471,7 +482,7 @@ export const sendNotification = async (options: {
|
|||||||
|
|
||||||
if (isCapacitorNative()) {
|
if (isCapacitorNative()) {
|
||||||
try {
|
try {
|
||||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
||||||
let perm = await LocalNotifications.checkPermissions();
|
let perm = await LocalNotifications.checkPermissions();
|
||||||
if (perm.display !== 'granted') {
|
if (perm.display !== 'granted') {
|
||||||
perm = await LocalNotifications.requestPermissions();
|
perm = await LocalNotifications.requestPermissions();
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user