Compare commits
2 Commits
ba31d65568
...
carousel-s
| Author | SHA1 | Date | |
|---|---|---|---|
| 3488606b68 | |||
| a3caafaf20 |
19
config.json
19
config.json
@@ -9,13 +9,30 @@
|
||||
"xmr.se"
|
||||
],
|
||||
"allowCustomHomeservers": true,
|
||||
|
||||
"calling": {
|
||||
"livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt"
|
||||
},
|
||||
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [
|
||||
"#cinny-space:matrix.org",
|
||||
"#community:matrix.org",
|
||||
"#space:envs.net",
|
||||
"#science-space:matrix.org",
|
||||
"#libregaming-games:tchncs.de",
|
||||
"#mathematics-on:matrix.org"
|
||||
],
|
||||
"rooms": [
|
||||
"#cinny:matrix.org",
|
||||
"#freesoftware:matrix.org",
|
||||
"#pcapdroid:matrix.org",
|
||||
"#gentoo:matrix.org",
|
||||
"#PrivSec.dev:arcticfoxes.net",
|
||||
"#disroot:aria-net.org"
|
||||
],
|
||||
"servers": []
|
||||
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
|
||||
},
|
||||
|
||||
"hashRouter": {
|
||||
|
||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -12,8 +12,10 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@capacitor/core": "8.3.4",
|
||||
"@capacitor/local-notifications": "8.2.0",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@paarrot/plugin-manager": "file:../../PluginManager",
|
||||
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
@@ -114,15 +116,6 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"../../PluginManager": {
|
||||
"name": "@paarrot/plugin-manager",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
@@ -1678,6 +1671,24 @@
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@capacitor/core": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz",
|
||||
"integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/local-notifications": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
|
||||
"integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
@@ -2344,8 +2355,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@paarrot/plugin-manager": {
|
||||
"resolved": "../../PluginManager",
|
||||
"link": true
|
||||
"version": "1.0.0",
|
||||
"resolved": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git#6c389381df3966568a40c39d8661f9a4300f7819"
|
||||
},
|
||||
"node_modules/@react-aria/breadcrumbs": {
|
||||
"version": "3.5.20",
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@capacitor/core": "8.3.4",
|
||||
"@capacitor/local-notifications": "8.2.0",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
|
||||
@@ -35,7 +35,6 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?
|
||||
size="T400"
|
||||
priority={notice ? '300' : '400'}
|
||||
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
|
||||
data-allow-text-selection="true"
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -215,8 +215,6 @@ export const MessageTextBody = recipe({
|
||||
overflow: 'clip',
|
||||
overflowY: 'clip',
|
||||
lineHeight: '1.5',
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
},
|
||||
variants: {
|
||||
preWrap: {
|
||||
|
||||
@@ -60,12 +60,10 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
||||
|
||||
export function PageNavContent({
|
||||
scrollRef,
|
||||
scrollProps,
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||
}) {
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
@@ -76,7 +74,6 @@ export function PageNavContent({
|
||||
size="300"
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
{...scrollProps}
|
||||
>
|
||||
<div className={css.PageNavContent}>{children}</div>
|
||||
</Scroll>
|
||||
|
||||
@@ -125,6 +125,17 @@ import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||
import { getMemberAvatarMxc } from '../../utils/room';
|
||||
import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter';
|
||||
|
||||
/**
|
||||
* Creates a UUID used to group image events into one carousel.
|
||||
*/
|
||||
const createCarouselUuid = (): string => {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
interface RoomInputProps {
|
||||
editor: Editor;
|
||||
fileDropContainerRef: RefObject<HTMLElement>;
|
||||
@@ -305,12 +316,34 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
};
|
||||
|
||||
const handleSendUpload = async (uploads: UploadSuccess[]) => {
|
||||
const imageUploads = uploads.filter((upload) => {
|
||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||
return !!fileItem && fileItem.file.type.startsWith('image');
|
||||
});
|
||||
const carouselUuid = imageUploads.length > 1 ? createCarouselUuid() : undefined;
|
||||
const imageUploadIndexByFile = new Map(
|
||||
imageUploads.map((upload, index) => [upload.file, index] as const)
|
||||
);
|
||||
|
||||
const contentsPromises = uploads.map(async (upload) => {
|
||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||
if (!fileItem) throw new Error('Broken upload');
|
||||
|
||||
if (fileItem.file.type.startsWith('image')) {
|
||||
return getImageMsgContent(mx, fileItem, upload.mxc);
|
||||
const imageIndex = imageUploadIndexByFile.get(upload.file);
|
||||
|
||||
return getImageMsgContent(
|
||||
mx,
|
||||
fileItem,
|
||||
upload.mxc,
|
||||
carouselUuid !== undefined && imageIndex !== undefined
|
||||
? {
|
||||
uuid: carouselUuid,
|
||||
index: imageIndex,
|
||||
total: imageUploads.length,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
if (fileItem.file.type.startsWith('video')) {
|
||||
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const TimelineFloat = recipe({
|
||||
|
||||
@@ -54,7 +54,7 @@ import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { autoScrollToBottom, editableActiveElement, isScrolledToBottom } from '../../utils/dom';
|
||||
import { autoScrollToBottom, editableActiveElement } from '../../utils/dom';
|
||||
import {
|
||||
DefaultPlaceholder,
|
||||
CompactPlaceholder,
|
||||
@@ -108,12 +108,17 @@ import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import {
|
||||
IImageContent,
|
||||
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
|
||||
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
|
||||
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
|
||||
} from '../../../types/matrix/common';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
import { Image } from '../../components/media';
|
||||
import { ImageViewer } from '../../components/image-viewer';
|
||||
import { useInertialHorizontalScroll } from '../../hooks/useInertialHorizontalScroll';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
@@ -138,12 +143,6 @@ import {
|
||||
useRoomWideEmbedFilters,
|
||||
combineEmbedFilters,
|
||||
} from '../../hooks/useRoomEmbedFilters';
|
||||
import {
|
||||
IImageContent,
|
||||
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
|
||||
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
|
||||
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
|
||||
} from '../../../types/matrix/common';
|
||||
|
||||
/** Information about a call member event for grouping */
|
||||
interface CallEventInfo {
|
||||
@@ -171,6 +170,9 @@ interface CarouselRenderableEvent {
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts carousel metadata from an m.image room message.
|
||||
*/
|
||||
const getCarouselEventMetadata = (mEvent: MatrixEvent): CarouselEventMetadata | undefined => {
|
||||
if (mEvent.getType() !== MessageEvent.RoomMessage || mEvent.isRedacted()) {
|
||||
return undefined;
|
||||
@@ -181,12 +183,25 @@ const getCarouselEventMetadata = (mEvent: MatrixEvent): CarouselEventMetadata |
|
||||
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;
|
||||
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 };
|
||||
return {
|
||||
uuid,
|
||||
index,
|
||||
total,
|
||||
content,
|
||||
};
|
||||
};
|
||||
|
||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
||||
@@ -216,28 +231,145 @@ type CarouselScrollerProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function CarouselScroller({ children }: CarouselScrollerProps) {
|
||||
const {
|
||||
scrollRef,
|
||||
isDragging,
|
||||
canScrollBack,
|
||||
canScrollForward,
|
||||
scrollBack,
|
||||
scrollForward,
|
||||
scrollProps,
|
||||
} = useInertialHorizontalScroll();
|
||||
const CarouselScroller = ({ children }: CarouselScrollerProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const backAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const frontAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const [backVisible, setBackVisible] = useState(true);
|
||||
const [frontVisible, setFrontVisible] = useState(true);
|
||||
const dragStateRef = useRef({
|
||||
startX: 0,
|
||||
startScrollLeft: 0,
|
||||
dragging: false,
|
||||
moved: false,
|
||||
suppressClick: false,
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const intersectionObserver = useIntersectionObserver(
|
||||
useCallback((entries) => {
|
||||
const backAnchor = backAnchorRef.current;
|
||||
const frontAnchor = frontAnchorRef.current;
|
||||
const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
|
||||
const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
|
||||
if (backEntry) {
|
||||
setBackVisible(backEntry.isIntersecting);
|
||||
}
|
||||
if (frontEntry) {
|
||||
setFrontVisible(frontEntry.isIntersecting);
|
||||
}
|
||||
}, []),
|
||||
useCallback(
|
||||
() => ({
|
||||
root: scrollRef.current,
|
||||
rootMargin: '10px',
|
||||
}),
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const backAnchor = backAnchorRef.current;
|
||||
const frontAnchor = frontAnchorRef.current;
|
||||
if (backAnchor) intersectionObserver?.observe(backAnchor);
|
||||
if (frontAnchor) intersectionObserver?.observe(frontAnchor);
|
||||
|
||||
return () => {
|
||||
if (backAnchor) intersectionObserver?.unobserve(backAnchor);
|
||||
if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
|
||||
};
|
||||
}, [intersectionObserver]);
|
||||
|
||||
const handleMouseDown = (evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (evt.button !== 0) return;
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
|
||||
const dragState = dragStateRef.current;
|
||||
dragState.startX = evt.clientX;
|
||||
dragState.startScrollLeft = scroll.scrollLeft;
|
||||
dragState.dragging = true;
|
||||
dragState.moved = false;
|
||||
dragState.suppressClick = false;
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleMouseMove = (evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState.dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
|
||||
const deltaX = evt.clientX - dragState.startX;
|
||||
if (Math.abs(deltaX) > 5) {
|
||||
dragState.moved = true;
|
||||
}
|
||||
scroll.scrollLeft = dragState.startScrollLeft - deltaX;
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState.dragging) return;
|
||||
|
||||
if (dragState.moved) {
|
||||
dragState.suppressClick = true;
|
||||
}
|
||||
dragState.dragging = false;
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleClickCapture = (evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState.suppressClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragState.suppressClick = false;
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
|
||||
const handleScrollFront = () => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
|
||||
scroll.scrollTo({
|
||||
left: scroll.scrollLeft + scroll.offsetWidth / 1.3,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
const handleScrollBack = () => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
|
||||
scroll.scrollTo({
|
||||
left: scroll.scrollLeft - scroll.offsetWidth / 1.3,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ position: 'relative' }}>
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
className={classNames(css.CarouselScroller, isDragging && css.CarouselScrollerDragging)}
|
||||
{...scrollProps}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={endDrag}
|
||||
onMouseLeave={endDrag}
|
||||
onClickCapture={handleClickCapture}
|
||||
>
|
||||
<div ref={backAnchorRef} />
|
||||
{children}
|
||||
<div ref={frontAnchorRef} />
|
||||
</Box>
|
||||
|
||||
{canScrollBack && (
|
||||
{!backVisible && (
|
||||
<>
|
||||
<div className={css.CarouselEdgeGradient({ position: 'Left' })} />
|
||||
<IconButton
|
||||
@@ -246,14 +378,14 @@ function CarouselScroller({ children }: CarouselScrollerProps) {
|
||||
radii="Pill"
|
||||
size="300"
|
||||
outlined
|
||||
onClick={scrollBack}
|
||||
onClick={handleScrollBack}
|
||||
>
|
||||
<Icon size="300" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canScrollForward && (
|
||||
{!frontVisible && (
|
||||
<>
|
||||
<div className={css.CarouselEdgeGradient({ position: 'Right' })} />
|
||||
<IconButton
|
||||
@@ -262,7 +394,7 @@ function CarouselScroller({ children }: CarouselScrollerProps) {
|
||||
radii="Pill"
|
||||
size="300"
|
||||
outlined
|
||||
onClick={scrollForward}
|
||||
onClick={handleScrollFront}
|
||||
>
|
||||
<Icon size="300" src={Icons.ArrowRight} />
|
||||
</IconButton>
|
||||
@@ -270,7 +402,7 @@ function CarouselScroller({ children }: CarouselScrollerProps) {
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getCarouselItemWidth = (content: IImageContent): string => {
|
||||
const width = content.info?.w;
|
||||
@@ -769,9 +901,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
useCallback(
|
||||
(mEvt: MatrixEvent) => {
|
||||
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||
const isBottomPinnedEvent =
|
||||
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
||||
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
||||
const shouldStickToBottom = atBottomRef.current || isOwnMessage;
|
||||
|
||||
// Always update timeline with new message
|
||||
setTimeline((ct) => ({
|
||||
@@ -796,12 +926,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
setUnreadInfo(getRoomUnreadInfo(room));
|
||||
}
|
||||
|
||||
// Preserve the pre-update bottom state so newly added chat messages keep the view pinned.
|
||||
if (shouldStickToBottom) {
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = !isOwnMessage;
|
||||
scrollToBottomRef.current.force = true;
|
||||
}
|
||||
// Preserve the pre-update bottom state so a newly added message keeps the view pinned.
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = !isOwnMessage;
|
||||
scrollToBottomRef.current.force = shouldStickToBottom;
|
||||
|
||||
// Show unread message preview for incoming messages when not at bottom
|
||||
if (!isOwnMessage && !atBottomRef.current) {
|
||||
@@ -875,7 +1003,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const scrollElement = getScrollElement();
|
||||
if (!editorBaseEntry || !scrollElement) return;
|
||||
|
||||
if (isScrolledToBottom(scrollElement)) {
|
||||
if (atBottomRef.current) {
|
||||
autoScrollToBottom(scrollElement, false, true);
|
||||
}
|
||||
};
|
||||
@@ -896,7 +1024,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const previousHeight = timelineContentHeightRef.current;
|
||||
timelineContentHeightRef.current = nextHeight;
|
||||
|
||||
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
|
||||
if (previousHeight === 0 || nextHeight <= previousHeight || !atBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2120,12 +2248,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
|
||||
}
|
||||
|
||||
if (groupedCarouselEventIds.has(mEventId)) {
|
||||
prevEvent = mEvent;
|
||||
isPrevRendered = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
const collapsed =
|
||||
isPrevRendered &&
|
||||
!dayDivider &&
|
||||
@@ -2135,6 +2257,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
prevEvent.getType() === mEvent.getType() &&
|
||||
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
||||
|
||||
if (groupedCarouselEventIds.has(mEventId)) {
|
||||
prevEvent = mEvent;
|
||||
isPrevRendered = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
const getEventByItem = (
|
||||
timelineItem: number
|
||||
): { event: MatrixEvent; eventId: string } | undefined => {
|
||||
@@ -2142,10 +2270,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
if (!tm) return undefined;
|
||||
|
||||
const event = getTimelineEvent(tm, getTimelineRelativeIndex(timelineItem, bIndex));
|
||||
const nextEventId = event?.getId();
|
||||
if (!event || !nextEventId) return undefined;
|
||||
const eventId = event?.getId();
|
||||
if (!event || !eventId) return undefined;
|
||||
|
||||
return { event, eventId: nextEventId };
|
||||
return { event, eventId };
|
||||
};
|
||||
|
||||
const carouselMetadata = getCarouselEventMetadata(mEvent);
|
||||
@@ -2310,12 +2438,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
ref={timelineContentRef}
|
||||
direction="Column"
|
||||
justifyContent="End"
|
||||
style={{
|
||||
minHeight: '100%',
|
||||
padding: `${config.space.S600} 0`,
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
}}
|
||||
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
|
||||
>
|
||||
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
|
||||
<div
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
IThumbnailContent,
|
||||
MATRIX_BLUR_HASH_PROPERTY_NAME,
|
||||
MATRIX_SPOILER_PROPERTY_NAME,
|
||||
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
|
||||
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
|
||||
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
|
||||
} from '../../../types/matrix/common';
|
||||
import {
|
||||
getImageFileUrl,
|
||||
@@ -18,6 +21,12 @@ import { TUploadItem } from '../../state/room/roomInputDrafts';
|
||||
import { encodeBlurHash } from '../../utils/blurHash';
|
||||
import { scaleYDimension } from '../../utils/common';
|
||||
|
||||
type CarouselMetadata = {
|
||||
uuid: string;
|
||||
index: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
const generateThumbnailContent = async (
|
||||
mx: MatrixClient,
|
||||
img: HTMLImageElement | HTMLVideoElement,
|
||||
@@ -46,7 +55,8 @@ const generateThumbnailContent = async (
|
||||
export const getImageMsgContent = async (
|
||||
mx: MatrixClient,
|
||||
item: TUploadItem,
|
||||
mxc: string
|
||||
mxc: string,
|
||||
carouselMetadata?: CarouselMetadata
|
||||
): Promise<IContent> => {
|
||||
const { file, originalFile, encInfo, metadata } = item;
|
||||
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
|
||||
@@ -74,6 +84,11 @@ export const getImageMsgContent = async (
|
||||
} else {
|
||||
content.url = mxc;
|
||||
}
|
||||
if (carouselMetadata) {
|
||||
content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME] = carouselMetadata.uuid;
|
||||
content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME] = carouselMetadata.index;
|
||||
content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME] = carouselMetadata.total;
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
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 { allInvitesAtom } from '../../../state/room-list/inviteList';
|
||||
import { UnreadBadge } from '../../../components/unread-badge';
|
||||
|
||||
|
||||
type DirectMenuProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
@@ -235,6 +235,7 @@ const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
|
||||
export function Direct() {
|
||||
const mx = useMatrixClient();
|
||||
useNavToActivePathMapper('direct');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const directs = useDirectRooms();
|
||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
@@ -245,7 +246,6 @@ export function Direct() {
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const noRoomToDisplay = directs.length === 0;
|
||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const reorderTrigger = useRoomListReorder(mx, directs);
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type { PluginListenerHandle } from '@capacitor/core';
|
||||
|
||||
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);
|
||||
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||
|
||||
/** Metadata for a file received from an Android share intent. */
|
||||
export type AndroidSharedFile = {
|
||||
@@ -34,21 +27,15 @@ interface AndroidShareHandlerPlugin {
|
||||
): Promise<PluginListenerHandle>;
|
||||
}
|
||||
|
||||
const getAndroidShareHandler = async (): Promise<AndroidShareHandlerPlugin> => {
|
||||
const { registerPlugin } = await loadCapacitorCore();
|
||||
return registerPlugin<AndroidShareHandlerPlugin>('AndroidShareHandler');
|
||||
};
|
||||
const AndroidShareHandler = registerPlugin<AndroidShareHandlerPlugin>('AndroidShareHandler');
|
||||
|
||||
/** Returns true when the Android share bridge is available. */
|
||||
export const isAndroidShareSupported = (): boolean => {
|
||||
const capacitor = (window as typeof window & { Capacitor?: CapacitorCoreModule['Capacitor'] }).Capacitor;
|
||||
return Boolean(capacitor?.isNativePlatform?.() && capacitor.getPlatform() === 'android');
|
||||
};
|
||||
export const isAndroidShareSupported = (): boolean =>
|
||||
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||
|
||||
/** Fetches the current pending native share payload. */
|
||||
export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | null> => {
|
||||
if (!isAndroidShareSupported()) return null;
|
||||
const AndroidShareHandler = await getAndroidShareHandler();
|
||||
const result = await AndroidShareHandler.getPendingShare();
|
||||
return result.share;
|
||||
};
|
||||
@@ -56,7 +43,6 @@ export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | nu
|
||||
/** Clears the native pending share payload. */
|
||||
export const clearPendingAndroidShare = async (): Promise<void> => {
|
||||
if (!isAndroidShareSupported()) return;
|
||||
const AndroidShareHandler = await getAndroidShareHandler();
|
||||
await AndroidShareHandler.clearPendingShare();
|
||||
};
|
||||
|
||||
@@ -65,7 +51,6 @@ export const listenForAndroidShares = async (
|
||||
listener: (payload: AndroidSharePayload) => void
|
||||
): Promise<PluginListenerHandle | undefined> => {
|
||||
if (!isAndroidShareSupported()) return undefined;
|
||||
const AndroidShareHandler = await getAndroidShareHandler();
|
||||
return AndroidShareHandler.addListener('shareReceived', listener);
|
||||
};
|
||||
|
||||
@@ -74,7 +59,6 @@ export const materializeSharedFile = async (
|
||||
sharedFile: AndroidSharedFile,
|
||||
receivedAt: number
|
||||
): Promise<File> => {
|
||||
const { Capacitor } = await loadCapacitorCore();
|
||||
const fileUrl = Capacitor.convertFileSrc(sharedFile.path);
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import type { PluginListenerHandle } from '@capacitor/core';
|
||||
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||
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 = {
|
||||
running: boolean;
|
||||
endpoint: string;
|
||||
@@ -74,15 +67,14 @@ type StoredPusherState = {
|
||||
appId: string;
|
||||
};
|
||||
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||
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_STORAGE_PREFIX = 'paarrot.unifiedpush';
|
||||
|
||||
/** Returns true when the current platform is Android Capacitor. */
|
||||
export const isBackgroundSyncSupported = (): boolean => {
|
||||
const capacitor = (window as typeof window & { Capacitor?: CapacitorCoreModule['Capacitor'] }).Capacitor;
|
||||
return Boolean(capacitor?.isNativePlatform?.() && capacitor.getPlatform() === 'android');
|
||||
};
|
||||
export const isBackgroundSyncSupported = (): boolean =>
|
||||
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||
|
||||
const getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
|
||||
`${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
|
||||
@@ -177,9 +169,6 @@ class AndroidUnifiedPushManager {
|
||||
|
||||
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */
|
||||
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());
|
||||
|
||||
if (!isBackgroundSyncSupported()) {
|
||||
@@ -227,9 +216,6 @@ class AndroidUnifiedPushManager {
|
||||
async stop(): Promise<void> {
|
||||
if (!isBackgroundSyncSupported()) return;
|
||||
|
||||
const { registerPlugin } = await loadCapacitorCore();
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||
|
||||
const client = this.client;
|
||||
if (client) {
|
||||
const deviceId = client.getDeviceId();
|
||||
@@ -294,8 +280,6 @@ class AndroidUnifiedPushManager {
|
||||
/** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
|
||||
private async tryRequestDistributorSetup(): Promise<void> {
|
||||
try {
|
||||
const { registerPlugin } = await loadCapacitorCore();
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||
const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
|
||||
console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
|
||||
const status = await this.safeGetStatus();
|
||||
@@ -351,9 +335,6 @@ class AndroidUnifiedPushManager {
|
||||
private async ensureListeners(): Promise<void> {
|
||||
if (this.listenersReady) return;
|
||||
|
||||
const { registerPlugin } = await loadCapacitorCore();
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||
|
||||
this.listenerHandles = [
|
||||
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
|
||||
void this.handleNewEndpoint(event).catch((err) => {
|
||||
@@ -382,9 +363,6 @@ class AndroidUnifiedPushManager {
|
||||
const client = this.client;
|
||||
if (!client) return;
|
||||
|
||||
const { registerPlugin } = await loadCapacitorCore();
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||
|
||||
const status = await this.safeGetStatus();
|
||||
console.log('[BackgroundSync] Native status before pusher sync:', status);
|
||||
if (status?.registered && status.endpoint) {
|
||||
@@ -457,8 +435,6 @@ class AndroidUnifiedPushManager {
|
||||
/** Read native registration state without failing the caller. */
|
||||
private async safeGetStatus(): Promise<UnifiedPushStatus | undefined> {
|
||||
try {
|
||||
const { registerPlugin } = await loadCapacitorCore();
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||
return await MatrixBackgroundSync.getStatus();
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err);
|
||||
@@ -482,8 +458,6 @@ export const triggerBackgroundSyncPing = async (reason?: string): Promise<void>
|
||||
if (!isBackgroundSyncSupported()) return;
|
||||
|
||||
try {
|
||||
const { registerPlugin } = await loadCapacitorCore();
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||
await MatrixBackgroundSync.triggerPing({ reason });
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] triggerPing failed:', err);
|
||||
@@ -505,8 +479,6 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
|
||||
if (!isBackgroundSyncSupported()) return;
|
||||
|
||||
try {
|
||||
const { registerPlugin } = await loadCapacitorCore();
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||
await MatrixBackgroundSync.setAppForeground({ foreground });
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] setAppForeground failed:', err);
|
||||
@@ -525,8 +497,6 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
|
||||
}
|
||||
|
||||
try {
|
||||
const { registerPlugin } = await loadCapacitorCore();
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||
const result = await MatrixBackgroundSync.requestDistributorSetup();
|
||||
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
|
||||
return result ?? { success: false };
|
||||
@@ -540,8 +510,6 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
|
||||
export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => {
|
||||
if (!isBackgroundSyncSupported()) return undefined;
|
||||
try {
|
||||
const { registerPlugin } = await loadCapacitorCore();
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||
return await MatrixBackgroundSync.getStatus();
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] getStatus failed:', err);
|
||||
|
||||
@@ -119,17 +119,6 @@ let notificationTapCallback: ((path: string) => void) | null = null;
|
||||
const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot';
|
||||
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
|
||||
*/
|
||||
@@ -207,7 +196,7 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void
|
||||
|
||||
if (isCapacitorNative()) {
|
||||
try {
|
||||
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||
await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => {
|
||||
await focusWindow();
|
||||
const path = event?.notification?.extra?.path;
|
||||
@@ -347,7 +336,7 @@ const ensureCapacitorNotificationChannel = async (): Promise<void> => {
|
||||
if (notificationChannelCreated || !isAndroid()) return;
|
||||
|
||||
try {
|
||||
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||
await LocalNotifications.createChannel({
|
||||
id: 'messages',
|
||||
name: 'Messages',
|
||||
@@ -376,7 +365,7 @@ export const requestSystemNotificationPermission = async (): Promise<boolean> =>
|
||||
|
||||
if (isCapacitorNative()) {
|
||||
try {
|
||||
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||
let perm = await LocalNotifications.checkPermissions();
|
||||
if (perm.display !== 'granted') {
|
||||
perm = await LocalNotifications.requestPermissions();
|
||||
@@ -396,7 +385,7 @@ export const requestSystemNotificationPermission = async (): Promise<boolean> =>
|
||||
export const getSystemNotificationPermissionState = async (): Promise<PermissionState> => {
|
||||
if (isCapacitorNative()) {
|
||||
try {
|
||||
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||
const perm = await LocalNotifications.checkPermissions();
|
||||
return perm.display === 'granted' ? 'granted' : 'prompt';
|
||||
} catch (err) {
|
||||
@@ -482,7 +471,7 @@ export const sendNotification = async (options: {
|
||||
|
||||
if (isCapacitorNative()) {
|
||||
try {
|
||||
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||
let perm = await LocalNotifications.checkPermissions();
|
||||
if (perm.display !== 'granted') {
|
||||
perm = await LocalNotifications.requestPermissions();
|
||||
|
||||
@@ -220,6 +220,7 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 38347,
|
||||
host: '0.0.0.0',
|
||||
open: false,
|
||||
cors: true, // Enable CORS for dev server
|
||||
fs: {
|
||||
// Allow serving files from one level up to the project root
|
||||
|
||||
Reference in New Issue
Block a user