5 Commits

10 changed files with 855 additions and 32 deletions

View File

@@ -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}
/> />

View File

@@ -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: {

View File

@@ -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>

View File

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

View File

@@ -19,6 +19,7 @@ import {
IContent, IContent,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
MsgType,
Room, Room,
RoomEvent, RoomEvent,
RoomEventHandlerMap, RoomEventHandlerMap,
@@ -35,6 +36,7 @@ import {
Chip, Chip,
ContainerColor, ContainerColor,
Icon, Icon,
IconButton,
Icons, Icons,
Line, Line,
Scroll, Scroll,
@@ -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

View 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,
},
};
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();

View File

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